openvibers/openvibe.design Commits
Elements: make a control with AI, and the Elements and packs gallery (#283)
Changes
@@@ -3,6 +3,7 @@ on:push:branches: [main]pull_request:++ workflow_dispatch:jobs:# install, node --check and npm test: the shared test job (Track Q).test:nnew file mode 100644@@@ -0,0 +1,12 @@++-- phase: expand++-- OpenVibe Elements stage 2 (2026-11-11): Design /create gets the kind Element. A design of this kind stores a++-- validated ui.element@1 document in each variant instead of an HTML take, and remembers where the picked element++-- was added: Live's element library and the Exchange gallery. The kind check is widened, never narrowed, and the++-- two links are JSONB so a failed hand-off leaves its plain reason beside the element.++++ALTER TABLE designs DROP CONSTRAINT IF EXISTS designs_kind;++ALTER TABLE designs ADD CONSTRAINT designs_kind++ CHECK (kind IN ('page', 'screen', 'component', 'slide', 'email', 'social-card', 'mark', 'element'));++++ALTER TABLE designs ADD COLUMN IF NOT EXISTS element_channel jsonb;++ALTER TABLE designs ADD COLUMN IF NOT EXISTS element_listing jsonb;nnew file mode 100644@@@ -0,0 +1,83 @@++-- phase: expand++-- OpenVibe Elements stage 2 (2026-11-11, owner correction: the gallery lives on OpenVibe.Design, Exchange stays++-- the AI model catalogue): the free "Elements and packs" gallery beside /create. A listing is a validated++-- ui.element@1 document or a ui.element-pack@1 pack, with the maker, the original it remixes, and its counts.++-- Installs add the element to the person's Live channel and are one row each; ratings come only from people who++-- installed it; reports are rows for staff. Nothing here carries a price.++++ALTER TABLE designs ADD COLUMN IF NOT EXISTS element_remix_of text;++++CREATE TABLE IF NOT EXISTS design_element_listings (++ id text COLLATE "C" PRIMARY KEY, -- del_ + 26 Crockford base32 characters (ULID)++ slug text COLLATE "C" NOT NULL UNIQUE,++ kind text COLLATE "C" NOT NULL, -- element | pack++ name text NOT NULL,++ summary text,++ document jsonb NOT NULL,++ look jsonb,++ author_subject text,++ author_name text NOT NULL,++ author_username text,++ source text COLLATE "C" NOT NULL DEFAULT 'design',++ source_id text,++ remix_of text COLLATE "C" REFERENCES design_element_listings(id) ON DELETE SET NULL,++ official boolean NOT NULL DEFAULT false,++ status text COLLATE "C" NOT NULL DEFAULT 'published',++ installs integer NOT NULL DEFAULT 0,++ rating_sum integer NOT NULL DEFAULT 0,++ rating_count integer NOT NULL DEFAULT 0,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT design_element_listings_kind CHECK (kind IN ('element', 'pack')),++ CONSTRAINT design_element_listings_source CHECK (source IN ('design', 'gallery', 'remix', 'official', 'live')),++ CONSTRAINT design_element_listings_status CHECK (status IN ('published', 'hidden')),++ CONSTRAINT design_element_listings_name CHECK (length(name) BETWEEN 2 AND 120),++ CONSTRAINT design_element_listings_summary CHECK (summary IS NULL OR length(summary) <= 600),++ CONSTRAINT design_element_listings_official CHECK (official = false OR author_subject IS NULL)++);++++CREATE INDEX IF NOT EXISTS design_element_listings_published++ ON design_element_listings (status, created_at DESC) WHERE status = 'published';++CREATE INDEX IF NOT EXISTS design_element_listings_author++ ON design_element_listings (author_subject, created_at DESC);++++CREATE TABLE IF NOT EXISTS design_element_installs (++ id text COLLATE "C" PRIMARY KEY,++ listing_id text COLLATE "C" NOT NULL REFERENCES design_element_listings(id) ON DELETE CASCADE,++ subject text NOT NULL,++ channel text,++ library jsonb, -- what Live answered, or its plain error++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT design_element_installs_once UNIQUE (listing_id, subject)++);++CREATE INDEX IF NOT EXISTS design_element_installs_subject ON design_element_installs (subject, created_at DESC);++++CREATE TABLE IF NOT EXISTS design_element_ratings (++ id text COLLATE "C" PRIMARY KEY,++ listing_id text COLLATE "C" NOT NULL REFERENCES design_element_listings(id) ON DELETE CASCADE,++ subject text NOT NULL,++ stars integer NOT NULL,++ note text,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT design_element_ratings_once UNIQUE (listing_id, subject),++ CONSTRAINT design_element_ratings_stars CHECK (stars BETWEEN 1 AND 5),++ CONSTRAINT design_element_ratings_note CHECK (note IS NULL OR length(note) <= 400)++);++++CREATE TABLE IF NOT EXISTS design_element_reports (++ id text COLLATE "C" PRIMARY KEY,++ listing_id text COLLATE "C" NOT NULL REFERENCES design_element_listings(id) ON DELETE CASCADE,++ subject text,++ reason text COLLATE "C" NOT NULL,++ detail text,++ status text COLLATE "C" NOT NULL DEFAULT 'pending',++ reviewer_note text,++ reviewed_by text,++ reviewed_at timestamptz,++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT design_element_reports_reason CHECK (reason IN ('unsafe', 'broken', 'spam', 'other')),++ CONSTRAINT design_element_reports_status CHECK (status IN ('pending', 'accepted', 'rejected')),++ CONSTRAINT design_element_reports_detail CHECK (detail IS NULL OR length(detail) <= 1000)++);++CREATE INDEX IF NOT EXISTS design_element_reports_pending ON design_element_reports (status, created_at DESC);@@@ -967,3 +967,78 @@ html[data-ov-motion=off] .ar-vote-pair[data-pick] .ar-pickable{transform: none}.ar-kind-t b { grid-area: b; overflow-wrap: normal; hyphens: manual; }.ar-kind-t small { grid-area: s; }}++++/* Elements (2026-11-11): the look preview, the sample run and the generated sliders. Plain, 390 px first. */++.dv-frame--element { display: grid; place-items: center; min-height: 220px; background: var(--bg-primary, #0b0f17); }++.dv-el-look { display: grid; place-items: center; padding: 18px; }++.dv-elx { display: inline-grid; place-items: center; gap: 4px; min-width: 128px; max-width: 100%; padding: 18px 24px; border-radius: var(--el-radius, 999px); background: var(--el-tint); color: #fff; font-weight: 600; box-sizing: border-box; }++.dv-elx small { font-weight: 400; opacity: .8; font-size: .72rem; }++.dv-elx-label { font-size: 1.05rem; overflow-wrap: anywhere; }++.dv-elx-number { font-size: 1.8rem; }++.dv-elx-number small { font-size: .8rem; margin-left: 4px; }++.dv-elx-bar { display: block; width: 120px; height: 10px; border-radius: 99px; background: rgba(255,255,255,.25); overflow: hidden; }++.dv-elx-bar span { display: block; height: 100%; background: #fff; }++.dv-elx-switch { display: block; width: 34px; height: 20px; border-radius: 99px; background: rgba(0,0,0,.35); position: relative; }++.dv-elx-switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: #fff; }++.dv-elx-slider { display: block; width: 120px; height: 6px; border-radius: 99px; background: rgba(255,255,255,.3); }++.dv-elx-wheel, .dv-elx-pad { font-size: 1.8rem; }++.dv-elx-ticker { letter-spacing: .3em; }++.dv-elx-alert { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #b42318; font-weight: 800; }++++.el-page { max-width: 1080px; margin: 0 auto; padding: 16px; }++.el-head h1 { margin: 8px 0 4px; }++.el-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap: 20px; margin: 16px 0; align-items: start; }++.el-look-panel { display: grid; gap: 8px; justify-items: center; border: 1px solid var(--border, #1f2d47); border-radius: 16px; padding: 16px; background: var(--bg-secondary, #101828); }++.el-look-panel canvas { width: 100%; max-width: 320px; height: auto; border-radius: 12px; border: 1px solid var(--border, #1f2d47); }++.el-run h2 { margin-top: 0; }++.el-events { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; max-height: 420px; overflow: auto; }++.el-event { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; gap: 8px; align-items: start; padding: 8px 10px; border: 1px solid var(--border, #1f2d47); border-radius: 10px; background: var(--bg-secondary, #101828); font-size: .92rem; }++.el-event-icon { text-align: center; }++.el-event small { color: var(--text-muted, #7386a3); }++.el-event--refused { border-color: #6b2b2b; }++.el-event--refused .el-event-icon { color: #f97066; }++.el-event--robot { border-color: #2d4d6b; }++.el-event--ai { border-color: #5b4b6b; }++.el-price { margin-top: 12px; padding: 12px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-secondary, #101828); }++.el-tweaks { margin: 16px 0; }++.el-tweaks fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 12px; display: grid; gap: 10px; }++.el-tweak { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto; gap: 8px; align-items: center; }++.el-tweak input[type=range] { width: 100%; min-height: 44px; }++.el-tweak input[type=text] { min-height: 44px; width: 100%; box-sizing: border-box; }++.el-tweak--check { display: flex; gap: 8px; align-items: center; }++.el-handoffs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }++.el-handoffs form { display: inline-block; }++.el-codes textarea { width: 100%; box-sizing: border-box; font-family: ui-monospace, monospace; font-size: .82rem; }++@media (max-width: 760px) { .el-grid { grid-template-columns: minmax(0, 1fr); } .el-tweak { grid-template-columns: minmax(0, 1fr); } }++++/* Elements and packs gallery (2026-11-11): the list, one listing and the staff queue. Plain, 390 px first. */++.el-gallery { max-width: 1080px; }++.el-g-search { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 8px 0 14px; }++.el-g-search input, .el-g-search select { min-height: 44px; min-width: 0; }++.el-g-search input { flex: 1 1 220px; }++.el-g-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 12px; margin: 0; padding: 0; list-style: none; }++.el-g-card { border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-secondary, #101828); overflow: hidden; }++.el-g-card a { display: grid; grid-template-rows: auto 1fr; color: inherit; text-decoration: none; height: 100%; }++.el-g-look { display: grid; place-items: center; min-height: 150px; padding: 12px; background: var(--bg-primary, #0b0f17); }++.el-g-text { display: grid; gap: 4px; padding: 12px; }++.el-g-title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }++.el-g-text small { color: var(--text-muted, #7386a3); }++.el-g-summary { color: var(--text-muted, #7386a3); font-size: .9rem; }++.el-g-detail { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr); gap: 20px; align-items: start; }++.el-g-preview { display: grid; place-items: center; min-height: 280px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-primary, #0b0f17); padding: 16px; }++.el-g-side { min-width: 0; display: grid; gap: 8px; }++.el-g-side h2 { margin: 14px 0 2px; font-size: 1.05rem; }++.el-g-side input, .el-g-side select, .el-g-side textarea { width: 100%; min-width: 0; min-height: 44px; box-sizing: border-box; }++.el-g-side fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 12px; display: grid; gap: 8px; padding: 12px; }++.el-g-items { margin: 0; padding-left: 18px; }++.el-g-reports { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }++.el-g-report-row { border: 1px solid var(--border, #1f2d47); border-radius: 12px; padding: 12px; }++.el-g-report-row form { display: flex; flex-wrap: wrap; gap: 8px; }++.dv-prose { line-height: 1.5; }++@media (max-width: 760px) { .el-g-detail { grid-template-columns: minmax(0, 1fr); } .el-g-preview { min-height: 200px; } }++++/* An element take is a look, not a document frame: one compact card, and no device/theme toolbar over it. */++.dv-canvas[data-kind="element"] .dv-rail { justify-content: center; }++.dv-canvas[data-kind="element"] .dv-card { flex: 0 0 min(100%, 420px); min-width: 0; }++.dv-canvas[data-kind="element"] .dv-frame--element { height: auto; min-height: 170px; display: grid; place-items: center; }++.dv-canvas[data-kind="element"] .dv-toolbar { display: none; }nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++/**++ * The element preview's robot: OpenVibe.Bot's own arena (public/js/sim-world.js, copied) with the element's robot++ * actions played on a local rover. It is a picture, not a device: no command is sent anywhere, and the rover only++ * moves when the element's recipe says a robot action would run.++ */++(function () {++ var canvas = document.getElementById('el-sim');++ var world = window.OVSimWorld;++ if (!canvas || !world) return;++ var ctx = canvas.getContext('2d');++ var data = {};++ try { data = JSON.parse(document.getElementById('el-sim-data').textContent || '{}'); } catch (e) { return; }++ var actions = (data.actions || []).filter(function (a) { return a && a.kind === 'robot'; });++ var pose = { x: world.START.x, y: world.START.y, heading: world.START.heading };++ var headlights = false;++ var label = document.getElementById('el-sim-state');++ var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;++++ function scale() {++ var pad = 10;++ var w = world.COLS * world.CELL;++ var h = world.ROWS * world.CELL;++ var s = Math.min((canvas.width - pad * 2) / w, (canvas.height - pad * 2) / h);++ return { s: s, x: (canvas.width - w * s) / 2, y: (canvas.height - h * s) / 2 };++ }++ function draw() {++ var z = scale();++ ctx.clearRect(0, 0, canvas.width, canvas.height);++ for (var cy = 0; cy < world.ROWS; cy += 1) {++ for (var cx = 0; cx < world.COLS; cx += 1) {++ var cell = world.MAP[cy][cx];++ var floor = world.floorAt(cx * world.CELL + world.CELL / 2, cy * world.CELL + world.CELL / 2);++ ctx.fillStyle = cell === '#' ? '#2a3446' : cell === 'r' ? '#b06a62' : cell === 'b' ? '#5c7fbf' : cell === 'g' ? '#5f9c78' : cell === 'y' ? '#b3a35c'++ : floor === 'line' ? '#d9c94e' : floor === 'tile2' ? '#151d2a' : '#101722';++ ctx.fillRect(z.x + cx * world.CELL * z.s, z.y + cy * world.CELL * z.s, world.CELL * z.s + 1, world.CELL * z.s + 1);++ }++ }++ var px = z.x + pose.x * z.s;++ var py = z.y + pose.y * z.s;++ if (headlights) {++ ctx.beginPath();++ ctx.fillStyle = 'rgba(255, 226, 130, .28)';++ ctx.arc(px, py, 24, 0, Math.PI * 2);++ ctx.fill();++ }++ ctx.save();++ ctx.translate(px, py);++ ctx.rotate((pose.heading * Math.PI) / 180);++ ctx.beginPath();++ ctx.fillStyle = '#e7edf7';++ ctx.moveTo(0, -8);++ ctx.lineTo(6, 7);++ ctx.lineTo(-6, 7);++ ctx.closePath();++ ctx.fill();++ ctx.restore();++ }++++ function say(text) { if (label) label.textContent = text; }++++ function stepFor(action, seconds) {++ var drive = { throttle: 0, steer: 0 };++ var direction = action.direction || 'forward';++ if (direction === 'forward') drive.throttle = 1;++ if (direction === 'back') drive.throttle = -1;++ if (direction === 'left') drive.steer = -1;++ if (direction === 'right') drive.steer = 1;++ if (direction === 'spin_left') drive.steer = -1;++ if (direction === 'spin_right') drive.steer = 1;++ return world.step(pose, drive, seconds);++ }++++ function play(action, done) {++ var seconds = Math.min(2.4, Math.max(0.4, (Number(action.robot && action.robot.ms) || 1000) / 1000));++ var left = reduced ? 0 : seconds;++ var last = Date.now();++ say(action.text);++ (function frame() {++ var now = Date.now();++ var dt = Math.min(0.05, (now - last) / 1000);++ last = now;++ if (left > 0) {++ var r = stepFor(action.robot || action, dt);++ pose = r.pose;++ left -= dt;++ }++ draw();++ if (left > 0) requestAnimationFrame(frame);++ else setTimeout(done, reduced ? 0 : 500);++ })();++ }++++ var i = 0;++ function next() {++ if (i >= actions.length) { say('Simulated rover, ready.'); return; }++ var action = actions[i++];++ if (action.robot && (action.robot.on === true || action.robot.on === false)) {++ headlights = action.robot.on;++ say(action.text);++ draw();++ setTimeout(next, reduced ? 0 : 700);++ return;++ }++ if (action.robot && action.robot.kind === 'robot.drive_pulse') { play(action, next); return; }++ say(action.text);++ draw();++ setTimeout(next, reduced ? 0 : 700);++ }++++ draw();++ setTimeout(next, reduced ? 0 : 400);++})();nnew file mode 100644@@@ -0,0 +1,156 @@++'use strict';++++/**++ * The simulated rover's world (sim.rover), copied from OpenVibe.Bot's public/sim-world.js so Design's element++ * preview draws the same arena Bot's simulator and /try page use. Bot stays the authority; this copy never talks to++ * a robot. No DOM, no Node API: it loads as a CommonJS module on the server and as `window.OVSimWorld` in a page.++ *++ * Units are metres, seconds and degrees, with +y "up" a top-down view and +x to its right. Heading 0 faces +y and++ * 90 faces +x, so a positive steer turns right (the simulator's convention since Bot#20): forward at full throttle is++ * 0.5 m/s and a full steer turns 90 °/s, as before. MAP's text rows run from y = 0 at the top of the text.++ *++ * step(pose, drive, dt) → { pose, bumped } one kinematic step with collisions (a bumper stops the move)++ * distanceCm(pose) → the ultrasonic reading ahead, in cm (≤ 400), from a 12° cone++ * line(pose) → { left, middle, right }: whether each line sensor is over the tape++ * floorAt(x, y) → 'line' | 'tile' | 'tile2'++ * cast(x, y, angle, max) → the cells a ray crosses, nearest first ({ dist, cell, side, u })++ * solid(cx, cy), cellAt(cx, cy), MAP, CELL, COLS, ROWS, START, ROVER_RADIUS, CAMERA_HEIGHT, heights++ */++(function (root, factory) {++ if (typeof module === 'object' && module.exports) module.exports = factory();++ else root.OVSimWorld = factory();++}(typeof self !== 'undefined' ? self : this, () => {++ const CELL = 0.5;++ // '#' arena wall, 'r' 'b' 'g' 'y' crates, '.' floor. Row r covers y ∈ [r·CELL, (r+1)·CELL); column c covers x.++ const MAP = [++ '################',++ '#..............#',++ '#..r.......b...#',++ '#..............#',++ '#.....##.......#',++ '#.....##....g..#',++ '#..............#',++ '#..y...........#',++ '#..........b...#',++ '#......r.......#',++ '#..............#',++ '################',++ ];++ const COLS = MAP[0].length;++ const ROWS = MAP.length;++ const ROVER_RADIUS = 0.14;++ const CAMERA_HEIGHT = 0.12;++ const SPEED = 0.5; // m/s at full throttle++ const TURN = 90; // °/s at full steer++ /** How tall each solid is, in metres: the arena wall stands above the rover's camera, crates are lower. */++ const heights = { '#': 0.42, r: 0.26, b: 0.3, g: 0.22, y: 0.26 };++ // Parked on the tape at the near left corner, facing along it (+y).++ const START = Object.freeze({ x: 1.0, y: 1.5, heading: 0 });++++ // The tape loop: a rectangle with rounded corners, 5 cm wide, around the crates.++ const LOOP = { x0: 1.0, y0: 0.75, x1: 7.0, y1: 5.25, r: 0.45, half: 0.025 };++++ const cellAt = (cx, cy) => (cy < 0 || cy >= ROWS || cx < 0 || cx >= COLS ? '#' : MAP[cy][cx]);++ const solid = (cx, cy) => cellAt(cx, cy) !== '.';++ const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };++ const rad = (deg) => (deg * Math.PI) / 180;++++ /** Distance from (x, y) to the loop's centre line. */++ function loopDistance(x, y) {++ const { x0, y0, x1, y1, r } = LOOP;++ // Distance to a rounded rectangle's outline: clamp to the inner rectangle, then subtract the radius.++ const ix0 = x0 + r; const iy0 = y0 + r; const ix1 = x1 - r; const iy1 = y1 - r;++ const dx = Math.max(ix0 - x, 0, x - ix1);++ const dy = Math.max(iy0 - y, 0, y - iy1);++ if (dx > 0 || dy > 0) return Math.abs(Math.hypot(dx, dy) - r);++ // Inside the inner rectangle: the nearest straight edge.++ return Math.min(x - ix0, ix1 - x, y - iy0, iy1 - y) + r;++ }++ function floorAt(x, y) {++ if (loopDistance(x, y) <= LOOP.half) return 'line';++ return (Math.floor(x / CELL) + Math.floor(y / CELL)) % 2 ? 'tile2' : 'tile';++ }++++ /** Does a rover of radius `r` at (x, y) touch a solid cell? */++ function blocked(x, y, r = ROVER_RADIUS) {++ for (let cy = Math.floor((y - r) / CELL); cy <= Math.floor((y + r) / CELL); cy++) {++ for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++) {++ if (!solid(cx, cy)) continue;++ const nx = Math.max(cx * CELL, Math.min(x, (cx + 1) * CELL));++ const ny = Math.max(cy * CELL, Math.min(y, (cy + 1) * CELL));++ if ((x - nx) ** 2 + (y - ny) ** 2 < r * r) return true;++ }++ }++ return false;++ }++++ /**++ * One kinematic step: forward speed from throttle (or y), turn rate from steer (or rotation), sideways from x++ * (a holonomic drive). Each axis of the move is tried on its own, so the rover slides along a wall; a move a++ * solid stops is `bumped`.++ */++ function step(pose, drive = {}, dt = 0) {++ const speed = num(drive.throttle != null ? drive.throttle : drive.y);++ const turn = num(drive.steer != null ? drive.steer : drive.rotation);++ const strafe = num(drive.x);++ const t = Math.max(0, Math.min(1, dt));++ const heading = (((num(pose.heading) + turn * TURN * t) % 360) + 360) % 360;++ const a = rad(heading);++ const dx = (speed * Math.sin(a) + strafe * Math.cos(a)) * SPEED * t;++ const dy = (speed * Math.cos(a) - strafe * Math.sin(a)) * SPEED * t;++ let x = num(pose.x); let y = num(pose.y);++ let bumped = false;++ if (dx) { if (blocked(x + dx, y)) bumped = true; else x += dx; }++ if (dy) { if (blocked(x, y + dy)) bumped = true; else y += dy; }++ return { pose: { x, y, heading }, bumped };++ }++++ /**++ * Every cell a ray from (x, y) at `angle` (degrees, the heading convention) crosses until an arena wall or++ * `max` metres, nearest first: { dist (along the ray), cell, side ('x' | 'y'), u (0..1 across the face) }.++ */++ function cast(x, y, angle, max = 12) {++ const a = rad(angle);++ const rx = Math.sin(a); const ry = Math.cos(a);++ let cx = Math.floor(x / CELL); let cy = Math.floor(y / CELL);++ const stepX = rx > 0 ? 1 : -1; const stepY = ry > 0 ? 1 : -1;++ const dX = rx ? Math.abs(CELL / rx) : Infinity; const dY = ry ? Math.abs(CELL / ry) : Infinity;++ let tX = rx ? ((rx > 0 ? (cx + 1) * CELL - x : x - cx * CELL) / Math.abs(rx)) : Infinity;++ let tY = ry ? ((ry > 0 ? (cy + 1) * CELL - y : y - cy * CELL) / Math.abs(ry)) : Infinity;++ const hits = [];++ for (let i = 0; i < 64; i++) {++ let dist; let side;++ if (tX < tY) { dist = tX; tX += dX; cx += stepX; side = 'x'; } else { dist = tY; tY += dY; cy += stepY; side = 'y'; }++ if (dist > max) break;++ const cell = cellAt(cx, cy);++ if (cell === '.') continue;++ const hx = x + rx * dist; const hy = y + ry * dist;++ const u = side === 'x' ? (hy / CELL) % 1 : (hx / CELL) % 1;++ hits.push({ dist, cell, side, u, cx, cy });++ if (cell === '#') break;++ }++ return hits;++ }++++ /** The ultrasonic sensor 0.1 m ahead of the centre: the nearest solid inside a 12° cone, in whole cm (≤ 400). */++ function distanceCm(pose) {++ const a = rad(pose.heading);++ const sx = pose.x + Math.sin(a) * 0.1; const sy = pose.y + Math.cos(a) * 0.1;++ let best = 4;++ for (const off of [-6, -3, 0, 3, 6]) {++ const h = cast(sx, sy, pose.heading + off, 4)[0];++ if (h && h.dist < best) best = h.dist;++ }++ return Math.round(best * 100);++ }++++ /** Three line sensors 0.12 m ahead, 4 cm apart: whether each is over the tape. */++ function line(pose) {++ const a = rad(pose.heading);++ const fx = pose.x + Math.sin(a) * 0.12; const fy = pose.y + Math.cos(a) * 0.12;++ const at = (side) => floorAt(fx + Math.cos(a) * side, fy - Math.sin(a) * side) === 'line';++ return { left: at(-0.04), middle: at(0), right: at(0.04) };++ }++++ return { MAP, CELL, COLS, ROWS, START, ROVER_RADIUS, CAMERA_HEIGHT, SPEED, TURN, LOOP, heights, cellAt, solid, blocked, floorAt, step, cast, distanceCm, line };++}));@@@ -40,6 +40,8 @@ const { createMcpRoutes } = require('./http/mcp');const { createArenaApi } = require('./http/arena-api');const { createDesignApi } = require('./http/design-api');const { createDesigns } = require('./designs');++const { createElementHandoff } = require('./elements/handoff');++const { createGallery } = require('./elements/gallery');const { createDeepDiveAi } = require('./deepdive/ai');const { createDeepDiveStore } = require('./deepdive/store');const { createDeepDiveService } = require('./deepdive/service');@@@ -122,6 +124,10 @@ async function createApp(opts = {}) {const createOptions = opts.createOptions || createCreateOptions({ config, ai, catalog, fetchImpl, log, now });// Publishing a design on openvibe.space (open item D8).const publisher = opts.publisher || require('./publish').createPublisher({ config, fetchImpl, log });++ // Elements stage 2: add a made element to Live as the person; the gallery itself is here on Design.++ const elementHandoff = opts.elementHandoff || createElementHandoff({ config, fetchImpl, log });++ // Elements and packs: the free gallery beside /create (owner correction: it lives here, not on Exchange).++ const gallery = opts.gallery || createGallery({ s, now, log });const accountData = accountDataLib.create({ db: s.db, log });const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });const fonts = opts.fonts || createFontCatalog({ db: s.db, fetchImpl, now, log });@@@ -135,7 +141,7 @@ async function createApp(opts = {}) {: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });const ctx = {-- config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts, aiStore, maker, registry, createOptions, balance, deepDive, deepDiveStore, deepDiveAi, proposals, rulePr,++ config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, elementHandoff, gallery, log, picks, fonts, aiStore, maker, registry, createOptions, balance, deepDive, deepDiveStore, deepDiveAi, proposals, rulePr,random: opts.random || Math.random,};@@@ -307,6 +313,7 @@ async function createApp(opts = {}) {}// ── API: the Arena, the glyphs (with their pages), then the theme proxy ──app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));++ app.use('/api/v1/gallery', require('./http/gallery-api').createGalleryApi(ctx));app.use('/api', express.json({ limit: '64kb' }));app.use('/api', createDeepDiveApi(ctx));app.use('/api/create', createCreateApi(ctx));@@@ -156,6 +156,14 @@ function load(env = process.env) {chatEditsPerDesign: Math.max(1, int(env.DESIGN_CHAT_EDITS_PER_DESIGN, 5)),},++ // OpenVibe.Live's element library (OpenVibe Elements stage 2): the person's own token adds the element++ // to their channel; Design never holds a Live token of its own for this.++ live: {++ url: trim(env.OV_LIVE_URL || 'https://openvibe.live'),++ elementsPath: env.OV_LIVE_ELEMENTS_PATH || '/api/me/elements',++ timeoutMs: Math.max(1000, int(env.DESIGN_LIVE_TIMEOUT_MS, 15_000)),++ },++// The typefaces and vectors a person makes with the model (plan R1 item 8): how many model runs// one person may make a day. A typeface is several runs (the letters are drawn a batch at a time),// so the cap counts runs, not pages.nnew file mode 100644@@@ -0,0 +1,484 @@++'use strict';++++/**++ * Elements in Design /create (OpenVibe Elements stage 2, 2026-11-11).++ *++ * An element is one ui.element@1 document: a look drawn from Design's control vocabulary and the theme's++ * tokens, a recipe from the fixed trigger and action catalogs, and the capabilities its actions need. The AI++ * writes the look and the recipe together; the SDK validates the document, an invalid take is asked once more++ * with the reasons, and a second refusal is said plainly.++ *++ * The preview never calls a robot, Live, Chat or AI's models. It runs the recipe against sample data (people of++ * each role pressing, a fake chat, and the robot actions drawn in OpenVibe.Bot's own arena) and lists what++ * would happen and what was refused, with the reason.++ */++++const { vocabulary: designVocabulary } = require('../components/registry');++++// The control components an element's look may name. Each is data: the component page draws it, the contract++// only checks the shape. `props` are the values the generated sliders move.++const COMPONENTS = Object.freeze([++ { name: 'button', title: 'Button', summary: 'One press action with a label and an icon.', props: { size: ['small', 'medium', 'large'] } },++ { name: 'toggle', title: 'Toggle', summary: 'A control that is on or off.', props: { on: [true, false] } },++ { name: 'slider', title: 'Slider', summary: 'A value between a minimum and a maximum.', props: { min: 0, max: 100, step: 1 } },++ { name: 'meter', title: 'Meter', summary: 'A readout of a value in a range: battery, hype, latency.', props: { value: 0, max: 100 } },++ { name: 'counter', title: 'Counter', summary: 'A count that goes up and down.', props: { start: 0 } },++ { name: 'readout', title: 'Readout', summary: 'One number with its unit.', props: { unit: '' } },++ { name: 'wheel', title: 'Wheel', summary: 'A spinning list whose result is the winner.', props: {} },++ { name: 'sound-pad', title: 'Sound pad', summary: 'A pad that plays one of the streamer\'s sounds.', props: {} },++ { name: 'ticker', title: 'Ticker', summary: 'A scrolling line of text.', props: {} },++ { name: 'alert', title: 'Alert', summary: 'A short call-out on the stream.', props: {} },++]);++const COMPONENT_NAMES = COMPONENTS.map((c) => c.name);++const COMPONENT = new Map(COMPONENTS.map((c) => [c.name, c]));++++const TRIGGERS = {++ press: 'The element itself was pressed.',++ chat_command: 'A chat command, e.g. !dance.',++ chat_text: 'Chat says one of the words.',++ hold: 'The element is held down.',++ double_press: 'The element is pressed twice.',++ key: 'A key is pressed.',++ gamepad: 'A gamepad button is pressed.',++ follow: 'A new follower.',++ tip: 'A tip.',++ ai_moment: 'An AI Moment.',++ rundown_topic: 'A Rundown topic.',++ timer: 'A timer.',++ schedule: 'A schedule.',++ robot_telemetry: 'A robot sensor crosses a value.',++ robot_event: 'A robot event.',++ stream_start: 'The stream starts.',++ stream_end: 'The stream ends.',++ viewer_count: 'The viewer count crosses a number.',++ element_event: 'Another element fires.',++};++++const ACTIONS = {++ 'robot.drive_pulse': 'Drive the robot for a moment, then stop (direction, ms ≤ 3000).',++ 'robot.head_preset': 'Take the robot\'s head to a preset (preset).',++ 'robot.headlight': 'Turn the robot\'s headlight on or off (on, brightness, colour).',++ 'robot.say': 'Make the robot say a line (text, voice).',++ 'robot.macro': 'Replay a recorded robot macro (stage 4: the preview says it is not supported yet).',++ 'chat.message': 'Send a chat message as the channel bot (text).',++ 'chat.pin': 'Pin a chat message (source: trigger | latest).',++ 'chat.poll': 'Start a poll (question, options).',++ 'screen.alert': 'Show an alert on stream (title, text).',++ 'screen.animation': 'Play an animation on screen (name).',++ 'screen.confetti': 'Throw confetti (intensity).',++ 'screen.sound': 'Play one of the streamer\'s sounds (sound, volume).',++ 'screen.tts': 'Speak a line on stream (text, voice).',++ 'screen.counter': 'Move one of the element\'s counters (key, by).',++ 'screen.state': 'Set one of the element\'s state values (key, value).',++ 'screen.visibility': 'Show or hide another element (element, visible).',++ 'screen.scene': 'Switch the layout to a scene (scene).',++ 'stream.clip': 'Clip the last seconds (seconds ≤ 30).',++ 'stream.moment': 'Mark a moment (label).',++ 'stream.title': 'Change the stream title (owner only).',++ 'stream.category': 'Change the stream category (owner only).',++ 'ai.ask': 'Ask an AI model through Exchange (prompt, model, max_cost_usd). THE PREVIEW NEVER RUNS IT.',++ 'actor.task': 'Hand a task to an Actor agent (task).',++ 'webhook.call': 'Call one of the streamer\'s own endpoints by name (endpoint).',++ 'money.tip_goal': 'Update a tip goal\'s display; no money moves without Billing (goal).',++};++const PRODUCT = {++ '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', 'ai.ask': 'ai', 'actor.task': 'actor', 'webhook.call': 'live', 'money.tip_goal': 'billing',++};++++const fail = (message, code = 'element.invalid') => Object.assign(new Error(message), { status: 422, code });++const textOf = (v) => (typeof v === 'string' ? v.trim() : '');++++/** The theme's token names, without the leading `--`: tint is a name the theme turns into a colour. */++function themeTokens(variables = {}) {++ return Object.keys(variables)++ .filter((name) => /^--[a-z][a-z0-9-]{1,40}$/.test(name))++ .map((name) => name.slice(2));++}++++/** The system prompt for an element: JSON only, the catalogs, the theme's token names and the control set. */++function elementSystemPrompt() {++ return [++ 'You are OpenVibe Elements\' designer. You write ONE ui.element@1 element as JSON: a small control for a stream, with a look and a recipe.',++ 'Return only a JSON object. No prose, no code fences, no HTML.',++ 'The object is: {name, description, look, recipe, capabilities, initial_state?}. The author and sharing are filled by OpenVibe.',++ 'look is {component, label, icon?, props?, style?}. component is one of: ' + COMPONENT_NAMES.join(', ') + '.',++ 'label is 1–40 characters, the words on the control. icon is a lower-case glyph name.',++ 'props holds the component\'s own values (text, number or true/false only). style is {tint, shape, opacity, show_label}:',++ 'tint is a theme token NAME without -- (for example accent or bg-card), never a colour; shape is round, pill or square; opacity 0.4–1.',++ 'recipe is {triggers, conditions?, actions}. triggers use the kinds and fields below; actions use the kinds and fields below.',++ 'Every action must appear in capabilities as its kind. A recipe uses only the catalog: never invent a trigger or action.',++ 'Use this catalog:',++ Object.entries(TRIGGERS).map(([k, v]) => `- trigger ${k}: ${v}`).join('\n'),++ Object.entries(ACTIONS).map(([k, v]) => `- action ${k} (product ${PRODUCT[k]}): ${v}`).join('\n'),++ 'conditions: {role, cost:{points}, cooldown:{per_person_seconds, overall_seconds}, daily_cap:{per_person, overall}, live_only, robot_turn_only, chance:{one_in}}.',++ 'role is everyone, follower, subscriber, vip, operator or owner. Money never moves: only channel points.',++ 'The element carries no URL, no HTML and no script. It never adds a power: the product still checks the presser and the limits.',++ 'The person\'s words decide the recipe. "once a minute" is cooldown overall_seconds 60; "costs 100 points" is cost.points 100; "the robot spins" is robot.drive_pulse with direction spin_left or spin_right.',++ 'For AI, use ai.ask with a short prompt and, when known, a model and a max_cost_usd; the price is looked up live.',++ 'If the person asks for something the catalog cannot do, choose the closest real action and say what it does in the description.',++ 'OpenVibe design vocabulary (for the look\'s wording, not component names):',++ designVocabulary().slice(0, 4000),++ ].join('\n');++}++++/** The person's words, the theme and the frame, as one user message. */++function elementPrompt({ prompt, variables = {}, component = null }) {++ const tokens = themeTokens(variables);++ return [++ `Make this element: ${String(prompt || '').trim()}`,++ component ? `Use the component "${component}".` : 'Choose the component that fits the job.',++ tokens.length ? `The theme's token names: ${tokens.join(', ')}. Use ONLY these as the look's tint.` : 'Use tint accent.',++ 'Return the JSON object only.',++ ].join('\n');++}++++/** The first JSON object in a model's answer: fences and a sentence before it are allowed; nothing else is. */++function parseElement(answer) {++ let text = String(answer || '').trim().replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');++ if (!text) throw fail('The AI returned nothing.');++ const start = text.indexOf('{');++ const end = text.lastIndexOf('}');++ if (start < 0 || end <= start) throw fail('The AI did not return a JSON object.');++ let doc;++ try { doc = JSON.parse(text.slice(start, end + 1)); } catch (err) { throw fail(`The AI's JSON could not be read: ${String(err.message).slice(0, 120)}.`); }++ if (doc && doc.element && typeof doc.element === 'object' && !Array.isArray(doc.element)) doc = doc.element;++ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw fail('The AI did not return one element object.');++ return doc;++}++++let sdk = null;++try { sdk = require('openvibe-sdk/elements'); } catch { /* the SDK release that carries elements is not pinned yet */ }++const contracts = require('openvibe-contracts');++const HAS_CONTRACT = (() => { try { return Boolean(contracts.schema('ui.element@1')); } catch { return false; } })();++++const reasonsOf = (r) => {++ if (!r) return ['the validator gave no reason'];++ if (Array.isArray(r)) return r.map(String);++ if (Array.isArray(r.reasons)) return r.reasons.map(String);++ if (Array.isArray(r.errors)) return r.errors.map((e) => (e && (e.message || e.detail)) || String(e));++ if (r.message) return [String(r.message)];++ return ['the validator gave no reason'];++};++++/**++ * Validate one element. The SDK's `elements.validate` is the authority when this Design's pinned SDK has it; the++ * contracts schema is next; the local shape check keeps a take from being accepted before either is released.++ * The Design-only rules (a component in the control set, a tint that is a theme token) are added on top.++ */++function validateElement(doc) {++ const reasons = [];++ if (sdk && typeof sdk.validate === 'function') {++ let out;++ try { out = sdk.validate(doc); } catch (err) { out = { valid: false, errors: [{ message: err.message }] }; }++ if (!(out === true || (out && (out.ok === true || out.valid === true)))) reasons.push(...reasonsOf(out));++ } else if (HAS_CONTRACT) {++ const out = contracts.validate('ui.element@1', doc);++ if (!out.valid) reasons.push(...(out.errors || []).map((e) => `${e.path === '/' ? '' : `${e.path} `}${String(e.message).replace(/^must /, '')}`));++ } else {++ reasons.push(...localReasons(doc));++ }++ const look = doc && doc.look;++ if (look && !COMPONENT.has(look.component)) reasons.push(`look.component must be one of ${COMPONENT_NAMES.join(', ')}.`);++ if (look && look.style && look.style.tint && !/^[a-z][a-z0-9-]{0,39}$/.test(look.style.tint)) reasons.push('look.style.tint must be a theme token name, never a colour.');++ return { ok: reasons.length === 0, reasons: [...new Set(reasons)].slice(0, 8) };++}++++/** The smallest shape check, used only before the SDK or the contracts carry the schema. */++function localReasons(doc) {++ const out = [];++ if (!doc || typeof doc !== 'object') return ['the answer is not an object'];++ for (const key of ['name', 'author', 'sharing', 'look', 'recipe', 'capabilities']) if (doc[key] == null) out.push(`${key} is required`);++ if (doc.look && typeof doc.look !== 'object') out.push('look must be an object');++ if (doc.recipe) {++ if (!Array.isArray(doc.recipe.triggers) || !doc.recipe.triggers.length) out.push('recipe.triggers needs at least one trigger');++ if (!Array.isArray(doc.recipe.actions) || !doc.recipe.actions.length) out.push('recipe.actions needs at least one action');++ const actions = Array.isArray(doc.recipe.actions) ? doc.recipe.actions : [];++ const declared = new Set(Array.isArray(doc.capabilities) ? doc.capabilities : []);++ for (const a of actions) {++ if (!a || !ACTIONS[a.kind]) out.push(`action ${a && a.kind} is not in the catalog`);++ else if (a.product !== PRODUCT[a.kind]) out.push(`${a.kind} is authorised by ${PRODUCT[a.kind]}`);++ else if (!declared.has(a.kind)) out.push(`${a.kind} is not in capabilities`);++ }++ for (const t of Array.isArray(doc.recipe.triggers) ? doc.recipe.triggers : []) if (!t || !TRIGGERS[t.kind]) out.push(`trigger ${t && t.kind} is not in the catalog`);++ }++ return out;++}++++/** Add what Design owns (the author and the private sharing) and keep the model's words. */++function prepareElement(doc, { authorName = 'A maker' } = {}) {++ const out = JSON.parse(JSON.stringify(doc));++ delete out.element;++ out.author = { name: authorName };++ out.sharing = 'private';++ if (!out.description) out.description = '';++ return out;++}++++/** The tweak sliders an element gets: size, tint and label, drawn from its own look and the theme. */++function tweaksFor(doc, variables = {}) {++ const tokens = themeTokens(variables);++ const look = doc && doc.look ? doc.look : {};++ const props = look.props || {};++ const sizes = (COMPONENT.get(look.component) || {}).props && COMPONENT.get(look.component).props.size;++ const tweaks = [];++ if (Array.isArray(sizes) && sizes.length) {++ const at = Math.max(0, sizes.indexOf(props.size));++ tweaks.push({ name: 'size', label: 'Size', kind: 'range', min: 0, max: sizes.length - 1, step: 1, value: at, options: sizes });++ }++ if (tokens.length) {++ const current = (look.style && look.style.tint) || 'accent';++ const choices = (tokens.includes(current) ? tokens : [current, ...tokens]).slice(0, 24);++ tweaks.push({ name: 'tint', label: 'Tint', kind: 'range', min: 0, max: choices.length - 1, step: 1, value: Math.max(0, choices.indexOf(current)), options: choices });++ }++ tweaks.push({ name: 'label', label: 'Label', kind: 'text', value: look.label || '', maxlength: 40 });++ tweaks.push({ name: 'show_label', label: 'Show label', kind: 'checkbox', value: !(look.style && look.style.show_label === false) });++ return tweaks;++}++++/** Apply the sliders' values to a copy of the element. Values are indices or text, never a colour or markup. */++function applyTweaks(doc, values = {}, variables = {}) {++ const out = JSON.parse(JSON.stringify(doc));++ out.look = out.look && typeof out.look === 'object' ? out.look : {};++ const look = out.look;++ look.props = look.props && typeof look.props === 'object' ? look.props : {};++ look.style = look.style && typeof look.style === 'object' ? look.style : {};++ const sizes = (COMPONENT.get(look.component) || {}).props && COMPONENT.get(look.component).props.size;++ if (values.size != null && Array.isArray(sizes)) {++ const at = Number(values.size);++ if (!Number.isInteger(at) || at < 0 || at >= sizes.length) throw fail('Choose a size from the list.');++ look.props.size = sizes[at];++ }++ const tokens = themeTokens(variables);++ if (values.tint != null && tokens.length) {++ const current = look.style.tint || 'accent';++ const choices = tokens.includes(current) ? tokens : [current, ...tokens];++ const at = Number(values.tint);++ if (!Number.isInteger(at) || at < 0 || at >= Math.min(24, choices.length)) throw fail('Choose a tint from the theme.');++ look.style.tint = choices[at];++ }++ if (values.label != null) {++ const label = textOf(values.label);++ if (!label || label.length > 40) throw fail('The label must be 1 to 40 characters.');++ if (/https?:\/\//i.test(label) || /<[a-z!/]/i.test(label)) throw fail('The label cannot carry a link or markup.');++ look.label = label;++ }++ if (values.show_label != null) look.style.show_label = values.show_label === true || values.show_label === 'on' || values.show_label === '1';++ return out;++}++++/** The look as plain markup on the page's tokens. The real component is drawn on the live surface. */++function renderLook(doc) {++ const look = (doc && doc.look) || {};++ const style = look.style || {};++ const tint = `var(--${String(style.tint || 'accent').replace(/[^a-z0-9-]/g, '')}, var(--accent))`;++ const shape = style.shape === 'round' ? '50%' : style.shape === 'square' ? '8px' : '999px';++ const opacity = style.opacity != null ? Math.max(0.4, Math.min(1, Number(style.opacity))) : 1;++ const label = style.show_label === false ? '' : String(look.label || '');++ const component = String(look.component || 'button');++ const props = look.props || {};++ let inner = '';++ switch (component) {++ case 'meter': {++ const max = Math.max(1, Number(props.max) || 100);++ const pct = Math.max(0, Math.min(100, Math.round((Number(props.value) || 0) / max * 100)));++ inner = `<span class="dv-elx-bar"><span style="width:${pct}%"></span></span>`;++ break;++ }++ case 'counter': inner = `<span class="dv-elx-number">${Number(props.start) || 0}</span>`; break;++ case 'readout': inner = `<span class="dv-elx-number">0<small>${String(props.unit || '')}</small></span>`; break;++ case 'toggle': inner = `<span class="dv-elx-switch" aria-hidden="true"></span>`; break;++ case 'slider': inner = `<span class="dv-elx-slider" aria-hidden="true"></span>`; break;++ case 'wheel': inner = '<span class="dv-elx-wheel" aria-hidden="true">✦</span>'; break;++ case 'ticker': inner = '<span class="dv-elx-ticker" aria-hidden="true">· · ·</span>'; break;++ case 'alert': inner = '<span class="dv-elx-alert" aria-hidden="true">!</span>'; break;++ case 'sound-pad': inner = '<span class="dv-elx-pad" aria-hidden="true">♪</span>'; break;++ default: inner = '';++ }++ return `<span class="dv-elx" data-component="${component}" style="--el-tint:${tint};--el-radius:${shape};opacity:${opacity}">${inner}${label ? `<span class="dv-elx-label">${esc(label)}</span>` : ''}<small>${component}</small></span>`;++}++++function esc(value) {++ return String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++}++++// ── The sample run (never a robot, Live, Chat or AI) ──────────────────────++const ROLE_RANK = { everyone: 0, follower: 1, subscriber: 2, vip: 3, operator: 4, owner: 5 };++const SAMPLE_PEOPLE = [++ { name: 'Ada', role: 'everyone', points: 250 },++ { name: 'Ben', role: 'follower', points: 250 },++ { name: 'Cy', role: 'subscriber', points: 250 },++ { name: 'Dee', role: 'vip', points: 250 },++ { name: 'Eve', role: 'operator', points: 250 },++ { name: 'The owner', role: 'owner', points: 250 },++];++++const effectOf = (action) => {++ switch (action.kind) {++ case 'robot.drive_pulse': return { kind: 'robot', text: `The robot drives ${String(action.direction || 'forward').replace(/_/g, ' ')} for ${(Number(action.ms) || 0) / 1000}s (simulated).`, robot: action };++ case 'robot.head_preset': return { kind: 'robot', text: `The robot's head goes to "${action.preset}" (simulated).` };++ case 'robot.headlight': return { kind: 'robot', text: `The robot's headlight turns ${action.on ? 'on' : 'off'} (simulated).`, headlight: Boolean(action.on) };++ case 'robot.say': return { kind: 'robot', text: `The robot says: "${action.text}" (simulated).`, say: action.text };++ case 'robot.macro': return { kind: 'robot', text: `The macro "${action.macro}" is not supported yet; the preview leaves it for stage 4.`, unsupported: true };++ case 'chat.message': return { kind: 'chat', text: `Chat, as the channel bot: "${action.text}".` };++ case 'chat.pin': return { kind: 'chat', text: `Pins the ${action.source === 'latest' ? 'newest message' : 'message that fired it'}.` };++ case 'chat.poll': return { kind: 'chat', text: `Starts a poll: "${action.question}" (${(action.options || []).join(' / ')}).` };++ case 'screen.alert': return { kind: 'screen', text: `Alert on stream: "${action.title}"${action.text ? ` — ${action.text}` : ''}.` };++ case 'screen.animation': return { kind: 'screen', text: `Plays the animation "${action.name}".` };++ case 'screen.confetti': return { kind: 'screen', text: `Throws confetti (${action.intensity || 'medium'}).` };++ case 'screen.sound': return { kind: 'screen', text: `Plays the sound "${action.sound}".` };++ case 'screen.tts': return { kind: 'screen', text: `Speaks on stream: "${action.text}".` };++ case 'screen.counter': return { kind: 'state', text: `Counter ${action.key} moves by ${action.by}.`, state: { key: action.key, by: action.by } };++ case 'screen.state': return { kind: 'state', text: `State ${action.key} becomes ${JSON.stringify(action.value)}.`, state: { key: action.key, value: action.value } };++ case 'screen.visibility': return { kind: 'screen', text: `${action.visible ? 'Shows' : 'Hides'} element ${action.element}.` };++ case 'screen.scene': return { kind: 'screen', text: `Switches to the scene "${action.scene}".` };++ case 'stream.clip': return { kind: 'stream', text: `Clips the last ${action.seconds || 30} seconds.` };++ case 'stream.moment': return { kind: 'stream', text: `Marks the moment${action.label ? ` "${action.label}"` : ''}.` };++ case 'stream.title': return { kind: 'stream', text: `Changes the stream title (owner only).` };++ case 'stream.category': return { kind: 'stream', text: `Changes the stream category (owner only).` };++ case 'ai.ask': return { kind: 'ai', text: `Would ask ${action.model || 'the routed model'} on Exchange: "${action.prompt}". The preview never runs it.`, ai: action };++ case 'actor.task': return { kind: 'ai', text: 'Would hand a task to an Actor agent. The preview never runs it.' };++ case 'webhook.call': return { kind: 'stream', text: `Would call the streamer's endpoint "${action.endpoint}". The preview never sends it.` };++ case 'money.tip_goal': return { kind: 'money', text: `Updates the tip goal "${action.goal}"'s display; no money moves.` };++ default: return { kind: 'unknown', text: `The action ${action.kind} is not one the preview knows.` };++ }++};++++/**++ * Run one element on sample data. Every person of each role presses once, a fake chat says the command (when the++ * trigger is a chat command) and the role, cooldown, points, chance and daily-cap conditions are applied by the++ * same rules the host will. The robot actions are only drawn, never sent.++ */++function simulateElement(doc, { price = null } = {}) {++ const recipe = (doc && doc.recipe) || {};++ const conditions = recipe.conditions || {};++ const role = conditions.role || 'everyone';++ const required = ROLE_RANK[role] == null ? 0 : ROLE_RANK[role];++ const points = conditions.cost && Number(conditions.cost.points) > 0 ? Number(conditions.cost.points) : 0;++ const cool = conditions.cooldown || {};++ const overall = Number(cool.overall_seconds) || 0;++ const perPerson = Number(cool.per_person_seconds) || 0;++ const oneIn = conditions.chance && Number(conditions.chance.one_in) > 1 ? Number(conditions.chance.one_in) : 0;++ const events = [];++ let at = 0;++ let lastBy = null;++ let lastOverall = null;++ let state = { ...(doc.initial_state || {}) };++ let headlights = false;++ let robot = null;++ const push = (kind, text, extra = {}) => { at += 1; events.push({ at, kind, text, ...extra }); };++++ const allowed = (person) => {++ if (ROLE_RANK[person.role] < required) {++ push('refused', `${person.name} (${person.role}) cannot: this element is for ${role}s.`);++ return false;++ }++ if (lastOverall != null && overall && at - lastOverall < overall) {++ push('refused', `${person.name} is refused: the overall cooldown is ${overall}s and only ${at - lastOverall}s have passed.`);++ return false;++ }++ if (lastBy === person.name && perPerson) {++ push('refused', `${person.name} is refused: their own cooldown is ${perPerson}s.`);++ return false;++ }++ if (points && person.points < points) {++ push('refused', `${person.name} cannot afford ${points} points.`);++ return false;++ }++ if (oneIn) {++ // A real host draws the lot; the sample run is deterministic and says which side it landed on.++ push('chance', `A 1-in-${oneIn} draw: the sample lands on "go" (a real run may refuse and cost nothing).`);++ }++ return true;++ };++++ const fire = (person, trigger) => {++ if (!allowed(person)) return;++ lastBy = person.name;++ lastOverall = at;++ if (points) push('cost', `${person.name} spends ${points} channel points (${person.points} → ${person.points - points}).`, { person: person.name });++ for (const action of recipe.actions || []) {++ const effect = effectOf(action);++ if (effect.state) {++ if (effect.state.by != null) state[effect.state.key] = (Number(state[effect.state.key]) || 0) + effect.state.by;++ else state[effect.state.key] = effect.state.value;++ }++ if (effect.headlight != null) headlights = effect.headlight;++ if (effect.robot && action.kind === 'robot.drive_pulse') robot = { direction: action.direction, ms: Number(action.ms) || 0 };++ push(effect.kind, effect.text, { person: person.name, trigger, ...(effect.robot ? { robot: effect.robot } : {}), ...(effect.headlight != null ? { headlight: effect.headlight } : {}), ...(effect.say ? { say: effect.say } : {}), ...(effect.unsupported ? { unsupported: true } : {}) });++ }++ };++++ const command = (recipe.triggers || []).find((t) => t.kind === 'chat_command');++ if (command) {++ const lines = [`${command.command}`, 'hello!', `${command.command}`];++ for (const [i, line] of lines.entries()) {++ at += 5;++ const person = SAMPLE_PEOPLE[(i + 2) % SAMPLE_PEOPLE.length];++ events.push({ at, kind: 'chat', person: person.name, text: `Chat: ${person.name}: ${line}` });++ if (line === command.command) fire(person, 'chat_command');++ }++ } else {++ for (const person of SAMPLE_PEOPLE) {++ at += 2;++ events.push({ at, kind: 'press', person: person.name, text: `${person.name} (${person.role}) presses.` });++ fire(person, (recipe.triggers || [])[0] ? recipe.triggers[0].kind : 'press');++ }++ }++++ return {++ role, points, cooldown: { per_person: perPerson, overall }, chance: oneIn,++ people: SAMPLE_PEOPLE.map((p) => ({ name: p.name, role: p.role })),++ events, state, headlights, robot,++ price,++ note: 'Sample data only: no robot, no Live channel, no chat and no AI call. The robot picture is drawn in Bot\'s simulated arena.',++ };++}++++/** The price line for an element: the AI it would call, from Exchange's live catalogue, and its own budget cap. */++function aiCallsOf(doc) {++ return ((doc && doc.recipe && doc.recipe.actions) || []).filter((a) => a && a.kind === 'ai.ask');++}++++/**++ * Price the element's AI calls from the live catalogue. Returns one row per action: the model, its in/out prices++ * per million tokens, and the action's own cap. Nothing here is estimated beyond the catalogue's own numbers.++ */++async function priceOf(elementDoc, ai) {++ const calls = aiCallsOf(elementDoc);++ if (!calls.length) return { calls: [], text: 'No AI it calls: it runs from the recipe alone.' };++ let models = [];++ try { models = (await ai.exchangeCatalog({ kind: 'chat' })).models || []; } catch { /* the page says unavailable */ }++ const rows = calls.map((call) => {++ const wanted = String(call.model || '');++ const found = models.find((m) => m.slug === wanted || m.id === wanted || m.canonical === wanted)++ || models.find((m) => wanted && String(m.name || '').toLowerCase().includes(wanted.toLowerCase()))++ || models.find((m) => (m.offers || []).some((o) => o.price));++ const offers = (found && found.offers) || [];++ const priced = offers.filter((o) => o.price && Number.isFinite(Number(o.price.in)) && Number.isFinite(Number(o.price.out)));++ priced.sort((a, b) => (Number(a.price.in) + Number(a.price.out)) - (Number(b.price.in) + Number(b.price.out)));++ const offer = priced[0] || offers[0] || null;++ return {++ prompt: call.prompt, model: found ? (found.name || found.slug) : (wanted || 'Router choice'),++ price_in: offer && offer.price ? Number(offer.price.in) : null,++ price_out: offer && offer.price ? Number(offer.price.out) : null,++ max_cost_usd: call.max_cost_usd == null ? null : Number(call.max_cost_usd),++ available: Boolean(found && offer),++ };++ });++ const known = rows.filter((r) => r.price_in != null);++ const text = known.length++ ? rows.map((r) => `${r.model}: $${r.price_in}/M in · $${r.price_out}/M out${r.max_cost_usd != null ? ` · at most $${r.max_cost_usd} a press` : ''}`).join(' · ')++ : 'The AI it calls is not on the live catalogue right now; the price is unknown until it is.';++ return { calls: rows, text };++}++++module.exports = {++ COMPONENTS, COMPONENT_NAMES, COMPONENT, TRIGGERS, ACTIONS, PRODUCT,++ elementSystemPrompt, elementPrompt, parseElement, validateElement, prepareElement,++ tweaksFor, applyTweaks, renderLook, simulateElement, priceOf, aiCallsOf, themeTokens,++};@@@ -51,6 +51,10 @@ const KINDS = [id: 'mark', name: 'Mark', hint: 'A logo or small brand mark sheet', frames: ['mark'],instruction: 'Build one brand mark sheet: the mark, a wordmark, a small palette and two or three lockups. The mark must stay recognisable at 16 px and be drawn with inline SVG, not a remote image.',},++ {++ id: 'element', name: 'Element', hint: 'A control or stream widget with a recipe', frames: ['phone', 'desktop'],++ instruction: 'Write one ui.element@1 element as JSON: its look (a control component and the theme tokens) and its recipe (triggers, conditions and actions from the fixed catalogs). Return the JSON object only.',++ },];const BY_ID = new Map(KINDS.map((kind) => [kind.id, kind]));@@@ -93,6 +97,7 @@ function categoryFor(kindId) {case 'email': return 'email';case 'social-card': return 'social';case 'mark': return 'mark';++ case 'element': return 'component';default: return 'landing';}}@@@ -7,6 +7,7 @@ const { vocabulary } = require('./components/registry');const { kindOf, instructionFor, categoryFor } = require('./create/kinds');const { makeThumbnail } = require('./create/thumbnail');const { publicStart, startPrompt } = require('./create/start');++const elements = require('./create/element');const random = () => crypto.randomBytes(24).toString('base64url');const hash = (key) => crypto.createHash('sha256').update(key).digest();@@@ -111,6 +112,9 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',createdAt: d.created_at, updatedAt: d.updated_at,published: d.space_url ? { url: d.space_url, at: d.published_at || null } : null,++ elementChannel: d.element_channel || null,++ elementListing: d.element_listing || null,++ elementRemixOf: d.element_remix_of || null,};};function allowed(d, owner, key) {@@@ -217,7 +221,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolmodel: slot.model, model_id: slot.model_id || null, name: slot.name || null,price_in: slot.price_in == null ? null : Number(slot.price_in), price_out: slot.price_out == null ? null : Number(slot.price_out),source: slot.source || null, status: 'pending', html: '', ms: 0, costUsd: 0, error: null,-- paid_by: null, prefer: slot.prefer || null, pin: slot.pin === true }));++ paid_by: null, prefer: slot.prefer || null, pin: slot.pin === true,++ element: null, element_error: null, element_price: null, tweaks: null }));const id = s.newId('dsn'), editKey = owner ? null : random();// "Research first": start the dive now, on the person's behalf, and point the design at it. The takes// wait for the report in run(); an attached dive is not run or charged again.@@@ -257,6 +262,42 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolawait db.exec('UPDATE designs SET variants=jsonb_set(variants, ARRAY[$2::text], $3::jsonb, false), updated_at=$4::timestamptz WHERE id=$1',[id, String(index), JSON.stringify(variant), s.iso()]);}++ /**++ * One Element take: the AI writes the look and the recipe together as JSON. The SDK (or, before the release++ * that carries it, the contracts schema) validates it; one refusal is sent back with its reasons and asked++ * once more. A second refusal marks the take failed with those reasons in plain words.++ */++ async function runElement(d, running, index, theme) {++ const variables = theme.variables || {};++ const base = elements.elementPrompt({ prompt: d.prompt, variables });++ const system = elements.elementSystemPrompt();++ let lastReasons = [];++ for (let attempt = 0; attempt < 2; attempt += 1) {++ const prompt = attempt === 0 ? base++ : `${base}\n\nYour previous answer was refused for these reasons:\n- ${lastReasons.join('\n- ')}\nReturn the whole JSON object again with every one of them fixed.`;++ const out = await ai.text({++ prompt, system, maxTokens: 3000,++ target: { service: 'design', type: 'element_output', id: `${d.id}_${index}_${attempt}`.slice(0, 128) },++ prefer: running.prefer || null, person: d.owner || null,++ });++ let doc;++ try { doc = elements.prepareElement(elements.parseElement(out.text), { authorName: 'A maker' }); }++ catch (err) { lastReasons = [err.message]; continue; }++ delete doc.id;++ delete doc.revision;++ const check = elements.validateElement(doc);++ if (!check.ok) { lastReasons = check.reasons; continue; }++ let price = null;++ try { price = await elements.priceOf(doc, ai); } catch (err) { log.warn('[OpenVibe.Design] element price:', err.message); }++ return {++ ...running, status: 'done', html: JSON.stringify(doc), element: doc, element_error: null, element_price: price,++ tweaks: elements.tweaksFor(doc, variables), provider: out.provider || running.provider, model: out.model || running.model,++ ms: out.ms || 0, costUsd: Number(out.cost_usd || 0), error: null, paid_by: out.paid_by || null, prefer: out.prefer || running.prefer || null,++ };++ }++ throw fail(422, 'design.element_invalid', `The AI's element was refused: ${lastReasons.join('; ')}`);++ }++async function run(id) {const d = await db.maybe('SELECT * FROM designs WHERE id=$1', [id]);if (!d) return;@@@ -266,6 +307,11 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolawait updateVariant(id, index, running);try {const t = await lookTheme(d);++ if (d.kind === 'element') {++ const done = await runElement(d, running, index, t);++ await updateVariant(id, index, done);++ return;++ }// The dive's report is part of the brief for every take. A dive that is still running or failed// does not hold the design forever: the takes are drawn without it and the canvas says so.let report = null;@@@ -439,6 +485,15 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol// `pin` is how the run was asked to route, not part of the frame; `prefer` is the request before the run// and the run's explain (with `honoured`) after it.const { pin, ...v } = full;++ if (d.kind === 'element') {++ // An element is stored as a JSON document, not a document to wrap. Its look is drawn from the++ // component and the theme's tokens; the sliders are generated from its own look.++ const doc = full.element || null;++ return { ...v, html: '', element: doc, element_error: full.element_error || null,++ element_price: full.element_price || null, look: doc ? elements.renderLook(doc) : null,++ tweaks: doc ? elements.tweaksFor(doc, selected.variables || {}) : null,++ currentSeq: p && index === Number(p.variant_index) && current ? Number(current.seq) : null };++ }if (p && index === Number(p.variant_index) && current) {return { ...v, html: wrap(current.html, selected.variables || {}), currentSeq: Number(current.seq), edited: current.kind !== 'generated' };}@@@ -498,6 +553,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolif (!instruction || instruction.length > CHAT_INSTRUCTION_MAX)throw fail(422, 'design.invalid_instruction', `Write an instruction up to ${CHAT_INSTRUCTION_MAX} characters.`);const { d, p, selected, variant } = await currentForEdit(id, owner, key, viewer);++ if (d.kind === 'element') throw fail(409, 'design.element_no_chat', 'An element is changed with its sliders, not a chat edit.');const used = await db.maybe('UPDATE design_picks SET chat_edits=chat_edits+1, updated_at=$2 WHERE design_id=$1 AND chat_edits < $3 RETURNING chat_edits',[d.id, s.iso(), chatEditLimit]);if (!used) throw fail(429, 'design.edit_limit', `This design has used its ${chatEditLimit} AI edits. Click edits and undo still work.`);@@@ -523,6 +579,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolasync function clickEdit({ id, path, text, color, spacing, align, owner, key, subject = null, viewer = null }) {const { d, p, selected, variant } = await currentForEdit(id, owner, key, viewer);++ if (d.kind === 'element') throw fail(409, 'design.element_no_click', 'An element is changed with its sliders, not a click edit.');const current = await versionRow(id, p.current_seq);let change;try {@@@ -639,6 +696,87 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolconst pending = await db.many("SELECT id FROM designs WHERE variants @> '[{\"status\":\"pending\"}]'::jsonb OR variants @> '[{\"status\":\"running\"}]'::jsonb ORDER BY created_at LIMIT 20");for (const d of pending) run(d.id).catch(err => log.warn('[OpenVibe.Design] resume:', err.message));}++ /** The picked element of an element design, through the same access check every read uses. */++ async function elementDraft({ id, owner, key, viewer = null }) {++ const { d, p, selected, variant } = await currentForEdit(id, owner, key, viewer);++ if (d.kind !== 'element') throw fail(409, 'design.not_element', 'This design is not an element.');++ if (!variant || !variant.element) throw fail(409, 'design.element_not_ready', 'The element is not ready yet.');++ return { d, p, selected, variant, element: variant.element };++ }++++ /** Move the generated tweak sliders: size, tint and label, applied to the look only. */++ async function tweakElement({ id, values = {}, owner, key, viewer = null }) {++ const { d, p, selected, variant } = await elementDraft({ id, owner, key, viewer });++ let doc;++ try { doc = elements.applyTweaks(variant.element, values || {}, selected.variables || {}); }++ catch (err) { throw fail(err.status || 422, err.code || 'design.invalid_tweak', err.detail || err.message); }++ const check = elements.validateElement(doc);++ if (!check.ok) throw fail(422, 'design.element_invalid', `The change was refused: ${check.reasons.join('; ')}`);++ let price = variant.element_price || null;++ try { price = await elements.priceOf(doc, ai); } catch { /* the price line says unknown */ }++ await updateVariant(d.id, Number(p.variant_index), { ...variant, element: doc, html: JSON.stringify(doc),++ element_price: price, tweaks: elements.tweaksFor(doc, selected.variables || {}) });++ return read(id, owner, key, viewer);++ }++++ /** The element to hand to Live or Exchange: the picked document with the maker's own display name. */++ async function elementOf({ id, owner, key, viewer = null, authorName = null, sharing = 'gallery' }) {++ const { d, variant } = await elementDraft({ id, owner, key, viewer });++ const doc = JSON.parse(JSON.stringify(variant.element));++ if (authorName) doc.author = { name: String(authorName).slice(0, 80) };++ doc.sharing = sharing;++ return { design: publicRow(d), element: doc };++ }++++ /** Run the picked element on sample data: fake presses of every role, a fake chat, and no robot or AI call. */++ async function simulate(id, { owner, key, viewer = null } = {}) {++ const { d, variant } = await elementDraft({ id, owner, key, viewer });++ let price = variant.element_price || null;++ try { price = await elements.priceOf(variant.element, ai); } catch { /* said as unknown */ }++ return { designId: d.id, prompt: d.prompt, element: variant.element, price, ...elements.simulateElement(variant.element, { price }) };++ }++++ /**++ * A remix opens as an editable copy: a Design of kind Element whose one take is the gallery listing's++ * document, credited to the original. It runs no AI and spends nothing; the person tweaks and publishes.++ */++ async function importElement({ element, owner, listing, remixOf = null } = {}) {++ if (!owner) throw fail(401, 'auth.required', 'Sign in to remix an element.');++ if (!element || typeof element !== 'object') throw fail(422, 'design.invalid_element', 'The listing does not carry an element document.');++ const selected = await theme(null);++ const variables = selected.variables || {};++ const id = s.newId('dsn');++ const doc = JSON.parse(JSON.stringify(element));++ if (doc.name && !/\(remix\)$/i.test(doc.name)) doc.name = `${doc.name} (remix)`;++ const variant = {++ id: s.newId('dva'), slot: 0, provider: 'gallery', model: (listing && listing.author && listing.author.name) || 'gallery',++ model_id: null, name: null, price_in: null, price_out: null, source: 'gallery', status: 'done', html: JSON.stringify(doc),++ ms: 0, costUsd: 0, error: null, paid_by: null, prefer: null, pin: false,++ element: doc, element_error: null, element_price: null, tweaks: elements.tweaksFor(doc, variables),++ };++ const prompt = `Remix of ${listing.name}`;++ const thumbnail = makeThumbnail({ kind: 'element', kindName: 'Element', prompt, model: variant.model, variables });++ await db.exec(`INSERT INTO designs(id,prompt,kind,theme_id,variants,owner,guest_hash,guest_ip,created_at,updated_at,++ frame,look,look_variables,models,start,source_design_id,estimate,thumbnail_svg,element_remix_of)++ VALUES($1,$2,$3,$4,$5::jsonb,$6,NULL,NULL,$7::timestamptz,$7::timestamptz,$8,$9,$10::jsonb,NULL,$11::jsonb,NULL,NULL,$12,$13)`,++ [id, prompt, 'element', selected.id, JSON.stringify([variant]), owner, s.iso(), 'phone', selected.id,++ JSON.stringify(variables), JSON.stringify({ from_listing: listing.id, by: listing.author && listing.author.name || null }),++ thumbnail, remixOf || listing.id]);++ await db.exec(`INSERT INTO design_picks(design_id,variant_index,variant_id,created_at,updated_at)++ VALUES($1,0,$2,$3::timestamptz,$3::timestamptz) ON CONFLICT (design_id) DO NOTHING`, [id, variant.id, s.iso()]);++ if (sharing) await sharing.register(id, owner).catch((err) => log.warn('[OpenVibe.Design] share row not registered:', err.message));++ return { design: { id, prompt, kind: 'element' }, editUrl: `/d/${id}` };++ }++++ /** Remember a hand-off's outcome beside the element, so the canvas can say what happened after a reload. */++ async function rememberHandoff({ id, owner, key, viewer = null, kind, value }) {++ if (!['channel', 'listing'].includes(kind)) throw fail(422, 'design.invalid_handoff', 'Choose channel or listing.');++ await row(id, owner, key, viewer, true);++ const column = kind === 'channel' ? 'element_channel' : 'element_listing';++ await db.exec(`UPDATE designs SET ${column}=$2::jsonb, updated_at=$3::timestamptz WHERE id=$1`, [id, JSON.stringify(value || null), s.iso()]);++ return read(id, owner, key, viewer);++ }++/*** Publish the working frame on openvibe.space (publish.js): its owner only (a guest's private link cannot), and* only once a frame is picked. The site is remembered, so publishing again updates the same address.@@@ -646,6 +784,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolasync function publish({ id, owner, name = '', publisher, viewer = null }) {if (!owner) throw fail(401, 'auth.required', 'Sign in to publish a design on openvibe.space.');const d = await row(id, owner, null, viewer, true);++ if (d.kind === 'element') throw fail(409, 'design.element_not_site', 'An element is added to your Live channel or published to the Elements and packs gallery, not to a Space site.');if (d.owner !== owner) throw fail(403, 'design.forbidden', 'Only the person who made this design can publish it.');const data = await read(id, owner, null);const index = data.pick ? Number(data.pick.variantIndex) : -1;@@@ -655,7 +794,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolreturn out;}-- return { create, read, setTheme, recent, thumbnail, resume, theme, pick, chatEdit, clickEdit, versions, undo, redo, chatEditLimit, publish };++ return { create, read, setTheme, recent, thumbnail, resume, theme, pick, chatEdit, clickEdit, versions, undo, redo, chatEditLimit, publish,++ elementDraft, tweakElement, elementOf, simulate, rememberHandoff, importElement };}module.exports = { createDesigns, wrap, tokenCss, themeCss, themePrompt, editPrompt };nnew file mode 100644@@@ -0,0 +1,302 @@++'use strict';++++/**++ * The Elements and packs gallery beside Design /create (owner correction, 2026-11-11: the gallery lives on++ * OpenVibe.Design; Exchange stays the AI model catalogue and is read only for live AI prices).++ *++ * A listing is a validated ui.element@1 document or a ui.element-pack@1 pack, free, plus who made it and the++ * original it remixes. Installs add the element to the person's Live channel; ratings come only from people who++ * installed it; reports are rows for staff. Nothing here runs an element, calls a robot, or carries a price.++ */++const { isExplicit } = require('openvibe-sdk/no-looks');++const { COMPONENTS, COMPONENT_NAMES, COMPONENT, validateElement } = require('../create/element');++++const MAX_DOC_BYTES = 262144;++const MAX_ITEMS = 24;++const MAX_NAME = 120;++const MAX_SUMMARY = 600;++const TEXT = /https?:\/\/|<[a-z!/]/i;++++function bad(message, code = 'gallery.invalid', status = 422) {++ const err = new Error(message);++ err.code = code;++ err.status = status;++ return err;++}++++const plain = (v) => (typeof v === 'string' ? v.trim() : '');++const stringsIn = (value, out = []) => {++ if (typeof value === 'string') out.push(value);++ else if (Array.isArray(value)) value.forEach((v) => stringsIn(v, out));++ else if (value && typeof value === 'object') Object.values(value).forEach((v) => stringsIn(v, out));++ return out;++};++++/** Content checks on top of the element runtime's validation: free, no URL or markup, no explicit text. */++function contentReasons(doc, path = 'element') {++ const reasons = [];++ const name = plain(doc.name);++ const summary = plain(doc.description);++ if (name.length < 1 || name.length > 80) reasons.push(`${path} needs a name of 1 to 80 characters.`);++ if (summary.length > 500) reasons.push(`${path}'s description is longer than 500 characters.`);++ if (isExplicit(name, summary)) reasons.push(`${path} carries explicit adult text.`);++ const look = doc.look || {};++ if (!COMPONENT.has(look.component)) reasons.push(`${path}'s look uses a component that is not in the gallery's control set (${COMPONENT_NAMES.join(', ')}).`);++ if (look.label && (String(look.label).length > 40 || TEXT.test(String(look.label)))) reasons.push(`${path}'s label is longer than 40 characters or carries a link.`);++ if (look.style && look.style.tint && !/^[a-z][a-z0-9-]{0,39}$/.test(String(look.style.tint))) reasons.push(`${path}'s tint must be a theme token, never a colour.`);++ for (const value of Object.values(look.props || {})) {++ if (typeof value !== 'string') continue;++ if (TEXT.test(value)) reasons.push(`${path}'s look props carry a URL or markup.`);++ if (/(?:^|[\s"';:(])(?:#[0-9a-f]{3,8}|rgba?\s*\(|hsla?\s*\()/i.test(value)) reasons.push(`${path}'s look props carry a literal colour; use the theme's tokens.`);++ }++ for (const text of stringsIn(doc.recipe)) {++ if (text.length > 2000) reasons.push(`${path}'s recipe has a value longer than 2000 characters.`);++ if (TEXT.test(text)) reasons.push(`${path}'s recipe carries a URL or markup.`);++ }++ if (doc.price != null || doc.price_cents != null || doc.price_usd != null) {++ const n = Number(doc.price_cents != null ? doc.price_cents : doc.price_usd != null ? doc.price_usd : doc.price);++ if (Number.isFinite(n) && n > 0) reasons.push('Elements are free: a listing cannot carry a price.');++ }++ return reasons;++}++++/** One element document (a draft or a stored one) as a listing: the runtime validates it, then the content. */++function validateElementListing(doc, path = 'element') {++ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw bad(`${path} must be an object.`);++ const runtime = validateElement(doc);++ const reasons = [...(runtime.ok ? [] : runtime.reasons), ...contentReasons(doc, path)];++ if (reasons.length) throw bad(`The element is not valid: ${[...new Set(reasons)].slice(0, 6).join(' ')}`);++ return { kind: 'element', name: plain(doc.name), summary: plain(doc.description), look: doc.look, doc };++}++++/** A pack: a named list of validated element documents. */++function validatePack(doc) {++ if (!Array.isArray(doc.items) || doc.items.length === 0) throw bad('A pack needs at least one element.');++ if (doc.items.length > MAX_ITEMS) throw bad(`A pack has at most ${MAX_ITEMS} elements.`);++ const name = plain(doc.name);++ const summary = plain(doc.description);++ if (name.length < 2 || name.length > MAX_NAME) throw bad(`A pack's name must be 2 to ${MAX_NAME} characters.`);++ if (summary.length > MAX_SUMMARY) throw bad(`A pack's description is longer than ${MAX_SUMMARY} characters.`);++ if (isExplicit(name, summary)) throw bad('A pack carries explicit adult text.');++ const names = new Set();++ for (const [i, item] of doc.items.entries()) {++ const got = validateElementListing(item, `pack item ${i + 1}`);++ if (names.has(got.name.toLowerCase())) throw bad(`A pack cannot hold two elements with the same name: ${got.name}.`);++ names.add(got.name.toLowerCase());++ }++ return { kind: 'pack', name, summary, look: doc.items[0].look, doc };++}++++/** Check one listing document. `ui.element-pack@1` is a pack; anything else is one element. */++function validateDocument(doc) {++ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw bad('The listing has no element document.');++ let json;++ try { json = JSON.stringify(doc); } catch { throw bad('The document is not valid JSON.'); }++ if (Buffer.byteLength(json, 'utf8') > MAX_DOC_BYTES) throw bad(`The document is larger than ${Math.round(MAX_DOC_BYTES / 1024)} KB.`);++ const got = doc.pack === 'ui.element-pack@1' ? validatePack(doc) : validateElementListing(doc);++ return { ...got, bytes: Buffer.byteLength(json, 'utf8') };++}++++function slugOf(name) {++ return String(name || '').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '')++ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'element';++}++++function createGallery({ s, now = () => Date.now(), log = console } = {}) {++ const iso = () => new Date(now()).toISOString();++ function wire(row, extra = {}) {++ if (!row) return null;++ return {++ id: row.id, slug: row.slug, kind: row.kind, name: row.name, summary: row.summary,++ document: row.document, look: row.look,++ author: { subject: row.author_subject, name: row.author_name, username: row.author_username },++ source: row.source, source_id: row.source_id, remix_of: row.remix_of, official: row.official === true,++ status: row.status, installs: Number(row.installs) || 0,++ rating: { count: Number(row.rating_count) || 0, average: row.rating_count ? Math.round((Number(row.rating_sum) / Number(row.rating_count)) * 10) / 10 : null },++ created_at: row.created_at, updated_at: row.updated_at, ...extra,++ };++ }++ async function uniqueSlug(name) {++ const base = slugOf(name);++ for (let n = 1; n < 50; n += 1) {++ const slug = n === 1 ? base : `${base}-${n}`;++ if (!(await s.db.maybe('SELECT 1 FROM design_element_listings WHERE slug = $1', [slug]))) return slug;++ }++ return `${base}-${now().toString(36)}`;++ }++ async function get(idOrSlug) {++ const key = String(idOrSlug || '').slice(0, 120);++ if (!key) return null;++ return wire(await s.db.maybe('SELECT * FROM design_element_listings WHERE id = $1 OR slug = $1', [key]));++ }++ async function publish({ subject, authorName, authorUsername, document: doc, source = 'design', sourceId = null, remixOf = null, official = false } = {}) {++ if (!official && !subject) throw bad('Publishing is a person\'s own action: sign in first.', 'gallery.sign_in', 401);++ let document = doc;++ if (remixOf) {++ const original = await get(remixOf);++ if (!original || original.status !== 'published') throw bad('The element you are remixing is not available.', 'gallery.remix_missing', 404);++ if (original.author.subject === subject && subject) throw bad('That is already your element.', 'gallery.remix_own');++ document = JSON.parse(JSON.stringify(original.document));++ const newName = plain(doc && doc.name) || `${original.name} (remix)`;++ document.name = newName;++ if (doc && doc.description) document.description = plain(doc.description);++ source = 'remix';++ sourceId = original.id;++ }++ const author = official ? 'OpenVibe' : (plain(authorName) || (authorUsername ? `@${authorUsername}` : 'A maker'));++ if (!official) {++ // A product hands over a draft: the author and the public sharing are the publisher's, not the model's.++ document = JSON.parse(JSON.stringify(document));++ const fill = (doc) => {++ if (!doc.author || !plain(doc.author.name)) doc.author = { name: author };++ if (!doc.sharing) doc.sharing = 'gallery';++ };++ if (document.pack === 'ui.element-pack@1') (document.items || []).forEach((item) => { if (item && typeof item === 'object') fill(item); });++ else fill(document);++ }++ const checked = validateDocument(document);++ const id = s.newId('del');++ const row = await s.db.maybe(++ `INSERT INTO design_element_listings++ (id, slug, kind, name, summary, document, look, author_subject, author_name, author_username,++ source, source_id, remix_of, official, status, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10, $11, $12, $13, $14, 'published', $15::timestamptz, $15::timestamptz)++ RETURNING *`,++ [id, await uniqueSlug(checked.name), checked.kind, checked.name, checked.summary || null, JSON.stringify(checked.doc),++ JSON.stringify(checked.look || null), official ? null : subject, author, official ? null : (authorUsername || null),++ source, sourceId, remixOf || null, official, iso()],++ );++ return wire(row);++ }++ async function list({ q = '', kind = null, sort = 'new', limit = 48, offset = 0 } = {}) {++ const where = ["status = 'published'"];++ const args = [];++ const needle = plain(q).slice(0, 120);++ if (needle) {++ args.push(`%${needle.replace(/[%_]/g, (c) => `\\${c}`)}%`);++ where.push(`(name ILIKE $${args.length} ESCAPE '\\' OR COALESCE(summary, '') ILIKE $${args.length} ESCAPE '\\')`);++ }++ if (kind === 'element' || kind === 'pack') { args.push(kind); where.push(`kind = $${args.length}`); }++ const order = sort === 'installs' ? 'installs DESC, created_at DESC'++ : sort === 'rating' ? 'CASE WHEN rating_count > 0 THEN rating_sum::float8 / rating_count ELSE 0 END DESC, rating_count DESC, created_at DESC'++ : 'created_at DESC';++ args.push(Math.max(1, Math.min(96, Number(limit) || 48)), Math.max(0, Number(offset) || 0));++ const rows = await s.db.many(`SELECT * FROM design_element_listings WHERE ${where.join(' AND ')} ORDER BY ${order} LIMIT $${args.length - 1} OFFSET $${args.length}`, args);++ return rows.map((r) => wire(r));++ }++ async function count({ q = '', kind = null } = {}) {++ const where = ["status = 'published'"];++ const args = [];++ const needle = plain(q).slice(0, 120);++ if (needle) {++ args.push(`%${needle.replace(/[%_]/g, (c) => `\\${c}`)}%`);++ where.push(`(name ILIKE $${args.length} ESCAPE '\\' OR COALESCE(summary, '') ILIKE $${args.length} ESCAPE '\\')`);++ }++ if (kind === 'element' || kind === 'pack') { args.push(kind); where.push(`kind = $${args.length}`); }++ const row = await s.db.maybe(`SELECT COUNT(*)::int AS n FROM design_element_listings WHERE ${where.join(' AND ')}`, args);++ return Number(row && row.n) || 0;++ }++ async function installedBy(listingId, subject) {++ if (!subject) return null;++ const row = await s.db.maybe('SELECT * FROM design_element_installs WHERE listing_id = $1 AND subject = $2', [String(listingId), String(subject)]);++ return row ? { channel: row.channel, library: row.library, created_at: row.created_at } : null;++ }++ async function ratingBy(listingId, subject) {++ if (!subject) return null;++ const row = await s.db.maybe('SELECT stars, note, updated_at FROM design_element_ratings WHERE listing_id = $1 AND subject = $2', [String(listingId), String(subject)]);++ return row ? { stars: row.stars, note: row.note, updated_at: row.updated_at } : null;++ }++ async function install({ listingId, subject, channel = null, library = null } = {}) {++ if (!subject) throw bad('Sign in to install an element.', 'gallery.sign_in', 401);++ const row = await get(listingId);++ if (!row || row.status !== 'published') throw bad('That element is not available.', 'gallery.missing', 404);++ const id = s.newId('dei');++ const inserted = await s.db.maybe(++ `INSERT INTO design_element_installs (id, listing_id, subject, channel, library, created_at)++ VALUES ($1, $2, $3, $4, $5::jsonb, $6::timestamptz) ON CONFLICT (listing_id, subject) DO NOTHING RETURNING id`,++ [id, row.id, subject, plain(channel).slice(0, 64) || null, JSON.stringify(library || null), iso()],++ );++ if (inserted) await s.db.maybe('UPDATE design_element_listings SET installs = installs + 1 WHERE id = $1 RETURNING installs', [row.id]);++ const fresh = await get(row.id);++ return { listing: fresh, first: Boolean(inserted) };++ }++ async function rate({ listingId, subject, stars, note = null } = {}) {++ if (!subject) throw bad('Sign in to rate an element.', 'gallery.sign_in', 401);++ const n = Number(stars);++ if (!Number.isInteger(n) || n < 1 || n > 5) throw bad('Choose one to five stars.', 'gallery.rating');++ const row = await get(listingId);++ if (!row || row.status !== 'published') throw bad('That element is not available.', 'gallery.missing', 404);++ if (!(await installedBy(row.id, subject))) throw bad('Only people who installed an element can rate it.', 'gallery.not_installed', 403);++ await s.db.maybe(++ `INSERT INTO design_element_ratings (id, listing_id, subject, stars, note, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $6::timestamptz)++ ON CONFLICT (listing_id, subject) DO UPDATE SET stars = EXCLUDED.stars, note = EXCLUDED.note, updated_at = EXCLUDED.updated_at`,++ [s.newId('der'), row.id, subject, n, plain(note).slice(0, 400) || null, iso()],++ );++ await s.db.maybe(++ `UPDATE design_element_listings SET rating_sum = (SELECT COALESCE(SUM(stars), 0)::int FROM design_element_ratings WHERE listing_id = $1),++ rating_count = (SELECT COUNT(*)::int FROM design_element_ratings WHERE listing_id = $1) WHERE id = $1`, [row.id],++ );++ return get(row.id);++ }++ async function report({ listingId, subject = null, reason = 'other', detail = null } = {}) {++ const row = await get(listingId);++ if (!row || row.status !== 'published') throw bad('That element is not available.', 'gallery.missing', 404);++ const why = String(reason || 'other');++ if (!['unsafe', 'broken', 'spam', 'other'].includes(why)) throw bad('Choose a reason from the list.', 'gallery.reason');++ const text = plain(detail).slice(0, 1000);++ if (text && isExplicit(text)) throw bad('Keep the report to the problem itself.', 'gallery.report_text');++ const saved = await s.db.maybe(++ `INSERT INTO design_element_reports (id, listing_id, subject, reason, detail, status, created_at)++ VALUES ($1, $2, $3, $4, $5, 'pending', $6::timestamptz) RETURNING *`,++ [s.newId('dep'), row.id, subject || null, why, text || null, iso()],++ );++ return { id: saved.id, status: saved.status, created_at: saved.created_at };++ }++ async function reports({ status = 'pending', limit = 100 } = {}) {++ const rows = await s.db.many(++ `SELECT r.*, e.slug, e.name, e.status AS listing_status FROM design_element_reports r++ JOIN design_element_listings e ON e.id = r.listing_id++ WHERE r.status = $1 ORDER BY r.created_at DESC, r.id DESC LIMIT $2`,++ [status, Math.max(1, Math.min(200, Number(limit) || 100))],++ );++ return rows.map((r) => ({ id: r.id, listing_id: r.listing_id, slug: r.slug, name: r.name, listing_status: r.listing_status,++ subject: r.subject, reason: r.reason, detail: r.detail, status: r.status, created_at: r.created_at }));++ }++ async function reviewReport({ id, decision, note = null, reviewer = null } = {}) {++ if (!['accepted', 'rejected'].includes(decision)) throw bad('Choose accepted or rejected.', 'gallery.decision');++ const row = await s.db.maybe(++ `UPDATE design_element_reports SET status = $1, reviewer_note = $2, reviewed_by = $3, reviewed_at = $4::timestamptz++ WHERE id = $5 AND status = 'pending' RETURNING *`,++ [decision, plain(note).slice(0, 500) || null, reviewer || null, iso(), String(id || '')],++ );++ if (!row) return null;++ if (decision === 'accepted') await setStatus({ id: row.listing_id, status: 'hidden', reviewer });++ return { id: row.id, listing_id: row.listing_id, status: row.status };++ }++ async function setStatus({ id, status, reviewer = null } = {}) {++ if (!['published', 'hidden'].includes(status)) throw bad('Choose published or hidden.', 'gallery.status');++ const row = await s.db.maybe('UPDATE design_element_listings SET status = $1, updated_at = $2::timestamptz WHERE id = $3 RETURNING *', [status, iso(), String(id || '')]);++ if (!row) return null;++ if (reviewer) log.log(`[OpenVibe.Design] gallery listing ${row.slug} set ${status} by ${reviewer}`);++ return wire(row);++ }++ async function ensureOfficial({ slug, document: doc }) {++ const existing = await s.db.maybe('SELECT * FROM design_element_listings WHERE slug = $1', [slug]);++ if (existing) return wire(existing);++ const checked = validateDocument(doc);++ const row = await s.db.maybe(++ `INSERT INTO design_element_listings++ (id, slug, kind, name, summary, document, look, author_subject, author_name, author_username,++ source, source_id, remix_of, official, status, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, NULL, 'OpenVibe', NULL, 'official', NULL, NULL, true, 'published', $8::timestamptz, $8::timestamptz)++ ON CONFLICT (slug) DO NOTHING RETURNING *`,++ [s.newId('del'), slugOf(slug), checked.kind, checked.name, checked.summary || null, JSON.stringify(checked.doc),++ JSON.stringify(checked.look || null), iso()],++ );++ return wire(row) || wire(await s.db.maybe('SELECT * FROM design_element_listings WHERE slug = $1', [slugOf(slug)]));++ }++ return { publish, list, count, get, installedBy, ratingBy, install, rate, report, reports, reviewReport, setStatus, ensureOfficial, validateDocument, slugOf };++}++++module.exports = { createGallery, validateDocument, validateElementListing, contentReasons, slugOf, COMPONENTS };nnew file mode 100644@@@ -0,0 +1,56 @@++'use strict';++++/**++ * Adding a made element to the maker's Live channel through Live's own element library. It carries the++ * person's own Network token, never a service token: the action is theirs, and Live checks it as them. The++ * document is already validated and free; nothing here sets a price. Failures are plain text (never a token,++ * never a response body) so the canvas can say what happened. The gallery itself is here on Design.++ */++function createElementHandoff({ config = {}, fetchImpl = globalThis.fetch, log = console } = {}) {++ const live = config.live || {};++++ async function call(name, url, token, body, timeoutMs) {++ if (!token) {++ const err = new Error('Sign in to add an element to your channel or publish it.');++ err.status = 401;++ err.code = 'design.sign_in';++ throw err;++ }++ let res;++ try {++ res = await fetchImpl(url, {++ method: 'POST',++ headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(timeoutMs || 15_000),++ });++ } catch (err) {++ const out = new Error(`${name} could not be reached.`);++ out.status = 502;++ out.code = 'design.handoff_unreachable';++ throw out;++ }++ const doc = await res.json().catch(() => null);++ if (!res.ok) {++ const detail = doc && (doc.detail || doc.message || doc.error);++ const out = new Error(`${name} refused it${detail ? `: ${String(detail).slice(0, 240)}` : ` (${res.status})`}.`);++ out.status = res.status === 401 || res.status === 403 ? res.status : 502;++ out.code = 'design.handoff_refused';++ throw out;++ }++ return doc || {};++ }++++ /** Add one validated element to the person's own Live element library. */++ async function addToChannel({ element, token }) {++ const base = String(live.url || '').replace(/\/+$/, '');++ if (!base) throw Object.assign(new Error('Live is not configured, so the element cannot be added.'), { status: 503, code: 'design.live_unconfigured' });++ const out = await call('OpenVibe.Live', `${base}${live.elementsPath || '/api/me/elements'}`, token, element, live.timeoutMs);++ const id = out.id || (out.element && out.element.id) || null;++ return { id, url: out.url || (id ? `${base}/elements/${encodeURIComponent(id)}` : `${base}/elements`), name: element.name };++ }++++ return { addToChannel };++}++++module.exports = { createElementHandoff };nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++++/**++ * The first official pack (OpenVibe Elements stage 2): the LOOI pack, published by OpenVibe, free.++ *++ * LOOI is the tracked desk robot whose head is a phone. The pack gives its owner a Viewer Deck: head presets,++ * say lines and the headlights. The robot actions are requests through Bot's existing command path; the++ * headlights need a LOOI profile with the headlight action (dsh:looi-headlights-1111d), and a preset the robot's++ * profile does not have is refused by Bot, never invented here. A "look around" macro is stage 4 because the++ * action catalog does not carry robot macros yet.++ *++ * Seeded once on Design's own start, by OpenVibe (author_subject NULL), never for a person. The seed is++ * idempotent: an existing `looi-pack` slug (the official one, or one that took the name) is left alone.++ */++const author = { name: 'OpenVibe' };++const button = (label) => ({ component: 'button', label, props: { size: 'large' }, style: { tint: 'accent', shape: 'pill', opacity: 1, show_label: true } });++++const el = (name, description, look, action) => ({++ name,++ description,++ author,++ sharing: 'gallery',++ look,++ recipe: {++ triggers: [{ kind: 'press' }],++ conditions: { role: 'everyone', cooldown: { per_person_seconds: 5, overall_seconds: 2 } },++ actions: [action],++ },++ capabilities: [action.kind],++});++++function looiPack() {++ return {++ pack: 'ui.element-pack@1',++ name: 'LOOI pack',++ description: 'Head presets, say lines and the headlights for the LOOI desk robot. Free; every action still goes through Bot\'s checks and your own limits.',++ author,++ sharing: 'gallery',++ items: [++ el('Look up', 'Tilt the head up.', button('Look up'), { kind: 'robot.head_preset', product: 'bot', preset: 'up' }),++ el('Look down', 'Tilt the head down.', button('Look down'), { kind: 'robot.head_preset', product: 'bot', preset: 'down' }),++ el('Look level', 'Bring the head back to level.', button('Look level'), { kind: 'robot.head_preset', product: 'bot', preset: 'center' }),++ el('Say hello', 'The robot says hello to chat.', button('Say hello'), { kind: 'robot.say', product: 'bot', text: 'Hello, chat!', voice: 'default' }),++ el('Say nice', 'The robot says something nice.', button('Say nice'), { kind: 'robot.say', product: 'bot', text: 'You are doing great.', voice: 'default' }),++ el('Say bye', 'The robot says goodbye.', button('Say bye'), { kind: 'robot.say', product: 'bot', text: 'See you next stream!', voice: 'default' }),++ el('Headlights on', 'Turn the headlights on.', { component: 'toggle', label: 'Headlights on', icon: 'spark', props: { on: true }, style: { tint: 'accent', shape: 'pill', opacity: 1, show_label: true } }, { kind: 'robot.headlight', product: 'bot', on: true }),++ el('Headlights off', 'Turn the headlights off.', { component: 'toggle', label: 'Headlights off', icon: 'spark', props: { on: false }, style: { tint: 'surface', shape: 'pill', opacity: 1, show_label: true } }, { kind: 'robot.headlight', product: 'bot', on: false }),++ ],++ };++}++++/** Publish the official pack once on this service's start; a failure is logged and never blocks the boot. */++async function seedOfficial(gallery, log = console) {++ try {++ const listing = await gallery.ensureOfficial({ slug: 'looi-pack', document: looiPack() });++ if (listing) log.log(`[OpenVibe.Design] official Elements pack ready: ${listing.slug} (${listing.kind})`);++ return listing;++ } catch (err) {++ log.warn('[OpenVibe.Design] the official LOOI pack was not seeded:', (err && err.message) || err);++ return null;++ }++}++++module.exports = { looiPack, seedOfficial };@@@ -76,6 +76,45 @@ function createDesignApi(ctx) {color: body.color, spacing: body.spacing, align: body.align, owner: owner(req), key: keyOf(req), subject: owner(req), viewer: viewer(req) });return priv(res, 201, result);}));++ // ── Elements (OpenVibe Elements stage 2) ────────────────++ // A design of kind Element keeps a ui.element@1 document instead of an HTML take. These routes move its++ // generated sliders, run it on sample data, add it to the maker's Live channel, and publish it to++ // Exchange's free gallery. In every one the person's own token is the credential.++ const authorNameOf = (req) => (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker';++ routes.get('/:id/element/simulate', safe(async (req, res) => priv(res, 200,++ await ctx.designs.simulate(req.params.id, { owner: owner(req), key: keyOf(req), viewer: viewer(req) }))));++ routes.post('/:id/element/tweak', sameOrigin, safe(async (req, res) => priv(res, 200,++ await ctx.designs.tweakElement({ id: req.params.id, values: req.body || {}, owner: owner(req), key: keyOf(req), viewer: viewer(req) }))));++ routes.post('/:id/element/channel', sameOrigin, safe(async (req, res) => {++ if (!owner(req)) return priv(res, 401, { error: 'design.sign_in', detail: 'Sign in to add an element to your channel.' });++ const { element } = await ctx.designs.elementOf({ id: req.params.id, owner: owner(req), key: keyOf(req), viewer: viewer(req), authorName: authorNameOf(req), sharing: 'private' });++ let channel;++ try {++ channel = await ctx.elementHandoff.addToChannel({ element, token: token(req) });++ channel.at = new Date().toISOString();++ } catch (err) {++ channel = { error: String(err.message || 'Live did not accept the element.').slice(0, 300), at: new Date().toISOString() };++ await ctx.designs.rememberHandoff({ id: req.params.id, owner: owner(req), key: keyOf(req), viewer: viewer(req), kind: 'channel', value: channel });++ return priv(res, err.status || 502, { error: err.code || 'design.handoff_refused', detail: channel.error, channel });++ }++ const data = await ctx.designs.rememberHandoff({ id: req.params.id, owner: owner(req), key: keyOf(req), viewer: viewer(req), kind: 'channel', value: channel });++ return priv(res, 200, { channel, ...data });++ }));++ routes.post('/:id/element/gallery', sameOrigin, safe(async (req, res) => {++ if (!owner(req)) return priv(res, 401, { error: 'design.sign_in', detail: 'Sign in to publish an element to the gallery.' });++ const { design, element } = await ctx.designs.elementOf({ id: req.params.id, owner: owner(req), key: keyOf(req), viewer: viewer(req),++ authorName: (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker', sharing: 'gallery' });++ try {++ const listing = await ctx.gallery.publish({ subject: owner(req), authorName: (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker',++ authorUsername: (req.viewer && req.viewer.username) || null, document: element, source: 'design', sourceId: req.params.id, remixOf: design.elementRemixOf || null });++ const value = { id: listing.id, slug: listing.slug, url: `/gallery/${encodeURIComponent(listing.slug)}`, at: new Date().toISOString() };++ const data = await ctx.designs.rememberHandoff({ id: req.params.id, owner: owner(req), key: keyOf(req), viewer: viewer(req), kind: 'listing', value });++ return priv(res, 201, { listing: value, ...data });++ } catch (err) {++ return priv(res, err.status || 502, { error: err.code || 'design.gallery_refused', detail: String(err.message || 'The gallery did not accept the element.').slice(0, 300) });++ }++ }));++routes.get('/:id/versions', safe(async (req, res) => priv(res, 200,await ctx.designs.versions(req.params.id, owner(req), keyOf(req), viewer(req)))));routes.post('/:id/undo', sameOrigin, safe(async (req, res) => priv(res, 200,@@@ -35,6 +35,7 @@ function publicPages(themes = []) {{ path: '/rules', changefreq: 'weekly', priority: 0.9, title: 'How OpenVibe decides', text: 'Every style, design and organization rule behind OpenVibe, with the day it was decided, why, and what carries it out. Also as data at /api/v1/rules.' },{ path: '/use', changefreq: 'weekly', priority: 0.8, title: 'Use: building with it', text: 'The SDK, the API, the files and the versions, and how to install and call them in your own app.' },{ path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Design', text: 'Browse OpenVibe\'s themes, try them on a preview, edit one with contrast checks, use it, and roll back to any earlier setting.' },++ { path: '/gallery', changefreq: 'daily', priority: 0.8, title: 'Elements and packs', text: 'Free buttons, controls and stream widgets people made with AI: install one on your Live channel, remix it as your own, or try it on sample data first.' },{ path: '/themes', changefreq: 'weekly', priority: 0.9, title: 'Themes', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },{ path: '/system', changefreq: 'weekly', priority: 0.8, title: 'The design system', text: 'Everything OpenVibe is made of in one map: themes, tokens, fonts, icons, the mark and motion; components, the kit and forms; the rules and patterns; apps and developer tools.' },{ path: '/look', changefreq: 'monthly', priority: 0.6, title: 'Your look', text: 'Your theme, light or dark, text size, motion and seasonal themes for every OpenVibe site, in one place.' },nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++++/**++ * The Elements and packs gallery's JSON, for a product that hands a validated element over instead of using++ * the canvas: OpenVibe.Live can publish a streamer's element here with the streamer's own token. The pages use++ * the same gallery store, so a listing made here is on /gallery at once.++ *++ * GET /api/v1/gallery/listings?q=&kind=&sort= public; published only++ * GET /api/v1/gallery/listings/:idOrSlug public; one listing++ * POST /api/v1/gallery/listings a person publishes {document, source, source_id, remix_of?}++ *++ * The person is the credential (their Network token as a Bearer, or their session from this site); no service++ * token publishes for somebody else. The document is validated by the SDK and the gallery's content checks.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { requireSameOrigin } = require('./same-origin');++++const text = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '');++++function createGalleryApi(ctx) {++ const { config, gallery, log } = ctx;++ const r = asyncRouter();++ r.use(express.json({ limit: '320kb' }));++ const fail = (res, status, code, detail, req) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);++ const sameOrigin = requireSameOrigin(config);++++ r.get('/listings', async (req, res) => {++ const q = text(req.query.q, 120);++ const kind = req.query.kind === 'element' || req.query.kind === 'pack' ? req.query.kind : null;++ const sort = ['new', 'installs', 'rating'].includes(String(req.query.sort)) ? String(req.query.sort) : 'new';++ const [listings, count] = await Promise.all([gallery.list({ q, kind, sort, limit: 96 }), gallery.count({ q, kind })]);++ return res.set('Cache-Control', 'public, max-age=30').json({ kind: 'elements', count, listings });++ });++++ r.get('/listings/:id', async (req, res) => {++ const listing = await gallery.get(req.params.id);++ if (!listing) return fail(res, 404, 'gallery.missing', 'No such element or pack.', req);++ if (listing.status !== 'published' && !(signedIn(req) && listing.author.subject === req.viewer.subject)) return fail(res, 404, 'gallery.missing', 'No such element or pack.', req);++ return res.set('Cache-Control', listing.status === 'published' ? 'public, max-age=30' : 'private, no-store').json(listing);++ });++++ r.post('/listings', sameOrigin, async (req, res) => {++ if (!signedIn(req)) return fail(res, 401, 'gallery.sign_in', 'Sign in to publish an element or pack.', req);++ try {++ const body = req.body || {};++ const listing = await gallery.publish({++ subject: req.viewer.subject,++ authorName: req.viewer.displayName || req.viewer.username || 'A maker',++ authorUsername: req.viewer.username || null,++ document: body.document,++ source: body.source === 'live' ? 'live' : 'design',++ sourceId: text(body.source_id, 120) || null,++ remixOf: text(body.remix_of, 120) || null,++ });++ log.log(`[OpenVibe.Design] gallery listing ${listing.slug} (${listing.kind}) by ${req.viewer.subject}`);++ return res.status(201).json(listing);++ } catch (err) {++ const status = Number(err.status) || 500;++ if (status >= 500) log.error('[OpenVibe.Design] gallery:', err && err.message);++ return fail(res, status, (err && err.code) || 'gallery.error', status >= 500 ? 'The listing could not be saved.' : (err.message || 'That was not accepted.'), req);++ }++ });++++ return r;++}++++module.exports = { createGalleryApi };@@@ -30,6 +30,10 @@ const componentsListView = require('../views/components-list');const kitView = require('../views/kit');const themeTokens = require('../tokens');const designView = require('../views/design');++const elementPreviewView = require('../views/element-preview');++const elementLib = require('../create/element');++const galleryView = require('../views/gallery');++const { isStaff } = require('../identity/staff');const patternsView = require('../views/patterns');const brandView = require('../views/brand');const changesView = require('../views/changes');@@@ -190,6 +194,76 @@ function createPageRoutes(ctx) {return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not read the design.');}});++ // ── Elements (OpenVibe Elements stage 2, 2026-11-11) ─────++ // The canvas's element panel and the gallery's "Try it" share one preview view: the look, the recipe on++ // sample data, the price line, the generated sliders and the hand-offs.++ const elementBack = (req, res, query = '') => res.redirect(303, `/d/${encodeURIComponent(req.params.id)}/element/preview${query}`);++ r.get('/d/:id/element/preview', async (req, res) => {++ res.set('Cache-Control', 'private, no-store');++ try {++ const key = req.query.key || '';++ const data = await ctx.designs.read(req.params.id, subjectOf(req), key, req.viewer);++ if (data.design.kind !== 'element') return res.redirect(303, `/d/${encodeURIComponent(req.params.id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`);++ if (!data.pick) return res.redirect(303, `/d/${encodeURIComponent(req.params.id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`);++ const variant = data.variants[Number(data.pick.variantIndex)];++ if (!variant || !variant.element) return page(res, 409, views.notFound(), req.viewer);++ const sim = await ctx.designs.simulate(req.params.id, { owner: subjectOf(req), key, viewer: req.viewer });++ return page(res, 200, elementPreviewView.elementPreview({++ design: data.design, variant, element: variant.element, sim, price: sim.price, editKey: key, viewer: req.viewer, mode: 'canvas',++ notice: req.query.channel === 'ok' ? 'Added to your Live element library.' : req.query.listing === 'ok' ? 'Published to the Elements and packs gallery.' : null,++ error: req.query.channel_error ? String(req.query.channel_error).slice(0, 300) : req.query.listing_error ? String(req.query.listing_error).slice(0, 300) : null,++ }), req.viewer, { cacheControl: 'private, no-store' });++ } catch (err) {++ return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not open the element preview.');++ }++ });++ r.post('/d/:id/element/tweak', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ try {++ await ctx.designs.tweakElement({ id: req.params.id, values: { size: req.body.size, tint: req.body.tint,++ label: req.body.label, show_label: req.body.show_label === '1' }, owner: subjectOf(req), key: req.body.key || '', viewer: req.viewer });++ return elementBack(req, res, req.body.key ? `?key=${encodeURIComponent(req.body.key)}` : '');++ } catch (err) { return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not apply the tweak.'); }++ });++ r.post('/d/:id/element/channel', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/d/${req.params.id}/element/preview`)}`);++ const key = req.body.key || '';++ const suffix = key ? `?key=${encodeURIComponent(key)}&channel=` : '?channel=';++ try {++ const { element } = await ctx.designs.elementOf({ id: req.params.id, owner: subjectOf(req), key, viewer: req.viewer,++ authorName: (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker', sharing: 'private' });++ const channel = await ctx.elementHandoff.addToChannel({ element, token: tokenOf(req) });++ channel.at = new Date().toISOString();++ await ctx.designs.rememberHandoff({ id: req.params.id, owner: subjectOf(req), key, viewer: req.viewer, kind: 'channel', value: channel });++ return elementBack(req, res, suffix + 'ok');++ } catch (err) {++ await ctx.designs.rememberHandoff({ id: req.params.id, owner: subjectOf(req), key, viewer: req.viewer, kind: 'channel',++ value: { error: String(err.message || 'Live did not accept the element.').slice(0, 300), at: new Date().toISOString() } }).catch(() => {});++ return elementBack(req, res, suffix + `error=${encodeURIComponent(String(err.message || 'Could not add it.').slice(0, 300))}`);++ }++ });++ r.post('/d/:id/element/gallery', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/d/${req.params.id}/element/preview`)}`);++ const key = req.body.key || '';++ const suffix = key ? `?key=${encodeURIComponent(key)}&listing=` : '?listing=';++ try {++ const { design, element } = await ctx.designs.elementOf({ id: req.params.id, owner: subjectOf(req), key, viewer: req.viewer,++ authorName: (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker', sharing: 'gallery' });++ const listing = await ctx.gallery.publish({ subject: subjectOf(req),++ authorName: (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker', authorUsername: (req.viewer && req.viewer.username) || null,++ document: element, source: 'design', sourceId: req.params.id, remixOf: design.elementRemixOf || null });++ const value = { id: listing.id, slug: listing.slug, url: `/gallery/${encodeURIComponent(listing.slug)}`, at: new Date().toISOString() };++ await ctx.designs.rememberHandoff({ id: req.params.id, owner: subjectOf(req), key, viewer: req.viewer, kind: 'listing', value });++ return elementBack(req, res, suffix + 'ok');++ } catch (err) {++ await ctx.designs.rememberHandoff({ id: req.params.id, owner: subjectOf(req), key, viewer: req.viewer, kind: 'listing',++ value: { error: String(err.message || 'The gallery did not accept the element.').slice(0, 300), at: new Date().toISOString() } }).catch(() => {});++ return elementBack(req, res, suffix + `error=${encodeURIComponent(String(err.message || 'Could not publish it.').slice(0, 300))}`);++ }++ });++r.post('/d/:id/theme', express.urlencoded({ extended: false }), async (req, res) => {if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');try {@@@ -507,6 +581,122 @@ function createPageRoutes(ctx) {return page(res, 200, views.arenaRound({ round, me: meOf(req.viewer) }), req.viewer);});++ // ── Elements and packs gallery (owner correction: on Design, beside /create) ──++ const galleryNext = (slug) => `/gallery/${encodeURIComponent(slug)}`;++ const galleryStaff = (req) => isStaff(req.viewer);++ async function addListingToChannel(listing, token, channel) {++ const docs = listing.kind === 'pack' ? (Array.isArray(listing.document.items) ? listing.document.items : []) : [listing.document];++ const items = [];++ for (const doc of docs) {++ try { items.push({ name: doc.name, ...(await ctx.elementHandoff.addToChannel({ element: doc, token })) }); }++ catch (err) { return { items, error: String(err.message || 'Live did not accept the element.').slice(0, 300) }; }++ }++ return { items };++ }++ async function galleryListing(req, res, { notice = null, error = null, status = 200 } = {}) {++ const listing = await ctx.gallery.get(req.params.slug);++ const who = req.viewer && req.viewer.kind === 'user' ? req.viewer : null;++ if (!listing || (listing.status !== 'published' && !galleryStaff(req) && !(who && who.subject === listing.author.subject))) return page(res, 404, views.notFound(), req.viewer);++ const [installed, myRating] = who ? await Promise.all([ctx.gallery.installedBy(listing.id, who.subject), ctx.gallery.ratingBy(listing.id, who.subject)]) : [null, null];++ return page(res, status, galleryView.listingPage({ listing, viewer: req.viewer || {}, installed, myRating,++ canRemix: Boolean(who && listing.author.subject !== who.subject && listing.status === 'published'), notice, error }), req.viewer,++ { cacheControl: status === 200 && listing.status === 'published' ? 'public, max-age=60' : 'private, no-store' });++ }++ r.get('/gallery', async (req, res) => {++ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';++ const kind = req.query.kind === 'element' || req.query.kind === 'pack' ? req.query.kind : '';++ const sort = ['new', 'installs', 'rating'].includes(String(req.query.sort)) ? String(req.query.sort) : 'new';++ const [listings, count] = await Promise.all([ctx.gallery.list({ q, kind, sort, limit: 60 }), ctx.gallery.count({ q, kind })]);++ return page(res, 200, galleryView.galleryPage({ listings, count, q, kind, sort, viewer: req.viewer || {},++ notice: req.query.published === '1' ? 'Published to the gallery.' : null }), req.viewer, { cacheControl: 'public, max-age=60' });++ });++ r.get('/gallery/moderation', async (req, res) => {++ if (!galleryStaff(req)) return page(res, 403, views.notFound(), req.viewer);++ const reports = await ctx.gallery.reports({ status: 'pending' });++ return page(res, 200, galleryView.moderationPage({ reports, notice: req.query.done ? 'Saved.' : null }), req.viewer, { cacheControl: 'private, no-store' });++ });++ r.post('/gallery/moderation/reports/:id', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {++ if (!galleryStaff(req)) return res.sendStatus(403);++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ const decision = (req.body || {}).decision === 'reject' ? 'rejected' : 'accepted';++ await ctx.gallery.reviewReport({ id: req.params.id, decision, reviewer: subjectOf(req) });++ return res.redirect(303, '/gallery/moderation?done=1');++ });++ r.post('/gallery/moderation/:id', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {++ if (!galleryStaff(req)) return res.sendStatus(403);++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ const listing = await ctx.gallery.get(req.params.id);++ if (!listing) return res.sendStatus(404);++ await ctx.gallery.setStatus({ id: listing.id, status: listing.status === 'published' ? 'hidden' : 'published', reviewer: subjectOf(req) });++ return res.redirect(303, galleryNext(listing.slug));++ });++ r.get('/gallery/:slug/try', async (req, res) => {++ const listing = await ctx.gallery.get(req.params.slug);++ if (!listing || listing.status !== 'published') return page(res, 404, views.notFound(), req.viewer);++ const doc = listing.kind === 'pack' ? (listing.document.items || [])[0] : listing.document;++ if (!doc) return page(res, 404, views.notFound(), req.viewer);++ const sim = elementLib.simulateElement(doc);++ let price = null;++ try { price = await elementLib.priceOf(doc, ctx.ai); } catch { /* the price line says unknown */ }++ return page(res, 200, elementPreviewView.elementPreview({ design: null, element: doc, sim, price, viewer: req.viewer, mode: 'try',++ listingId: listing.id, path: `/gallery/${encodeURIComponent(listing.slug)}/try`, listingUrl: galleryNext(listing.slug) }), req.viewer,++ { cacheControl: 'public, max-age=60' });++ });++ r.get('/gallery/:slug', (req, res) => galleryListing(req, res, {++ notice: req.query.installed === '1' ? 'Installed on your channel.'++ : req.query.rated === '1' ? 'Your rating is saved.'++ : req.query.remixed === '1' ? 'Your remix is open in the canvas.'++ : req.query.reported === '1' ? 'Thank you. Staff will read the report.' : null,++ error: req.query.library_error ? String(req.query.library_error).slice(0, 300) : null,++ }));++ r.post('/gallery/:slug/install', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {++ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(galleryNext(req.params.slug))}`);++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ try {++ const listing = await ctx.gallery.get(req.params.slug);++ if (!listing) return res.sendStatus(404);++ const library = await addListingToChannel(listing, tokenOf(req), (req.viewer && req.viewer.username) || null);++ await ctx.gallery.install({ listingId: listing.id, subject: subjectOf(req), channel: (req.viewer && req.viewer.username) || null, library });++ const suffix = library.error ? `?installed=1&library_error=${encodeURIComponent(library.error)}` : '?installed=1';++ return res.redirect(303, galleryNext(req.params.slug) + suffix);++ } catch (err) { return galleryListing(req, res, { error: err.message, status: err.status || 422 }); }++ });++ r.post('/gallery/:slug/rating', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {++ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(galleryNext(req.params.slug))}`);++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ try { await ctx.gallery.rate({ listingId: req.params.slug, subject: subjectOf(req), stars: (req.body || {}).stars, note: (req.body || {}).note }); }++ catch (err) { return galleryListing(req, res, { error: err.message, status: err.status || 422 }); }++ return res.redirect(303, `${galleryNext(req.params.slug)}?rated=1`);++ });++ r.post('/gallery/:slug/remix', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {++ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(galleryNext(req.params.slug))}`);++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ try {++ const listing = await ctx.gallery.get(req.params.slug);++ if (!listing || listing.status !== 'published') return res.sendStatus(404);++ // An element remix opens as an editable copy in the canvas; a pack is copied as a pack listing that++ // credits the original (a pack's items are edited one at a time, after stage 2).++ if (listing.kind === 'pack') {++ const document = JSON.parse(JSON.stringify(listing.document));++ document.name = `${listing.name} (remix)`;++ const copy = await ctx.gallery.publish({ subject: subjectOf(req),++ authorName: (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker',++ authorUsername: (req.viewer && req.viewer.username) || null,++ document, remixOf: listing.id });++ return res.redirect(303, galleryNext(copy.slug));++ }++ const out = await ctx.designs.importElement({ element: listing.document, owner: subjectOf(req), listing, remixOf: listing.id });++ return res.redirect(303, out.editUrl);++ } catch (err) { return galleryListing(req, res, { error: err.message, status: err.status || 422 }); }++ });++ r.post('/gallery/:slug/report', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {++ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(galleryNext(req.params.slug))}`);++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ try { await ctx.gallery.report({ listingId: req.params.slug, subject: subjectOf(req), reason: (req.body || {}).reason, detail: (req.body || {}).detail }); }++ catch (err) { return galleryListing(req, res, { error: err.message, status: err.status || 422 }); }++ return res.redirect(303, `${galleryNext(req.params.slug)}?reported=1`);++ });++r.get('/developers', (req, res) => page(res, 200, views.developers(), req.viewer));r.use((req, res) => page(res, 404, views.notFound(), req.viewer));@@@ -14,7 +14,7 @@ const TABLES = [{ table: 'arena_rounds', subject: 'created_by', file: 'arena_rounds.json', erase: 'delete' },{ table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },// The designs a signed-in person asked for; a guest's design has no owner and matches nobody. Versions cascade.-- { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'frame', 'look', 'look_variables', 'models', 'start', 'source_design_id', 'research_id', 'estimate', 'thumbnail_svg', 'space_url', 'published_at', 'created_at', 'updated_at'] },++ { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'frame', 'look', 'look_variables', 'models', 'start', 'source_design_id', 'research_id', 'element_remix_of', 'estimate', 'thumbnail_svg', 'space_url', 'published_at', 'created_at', 'updated_at'] },// The deep dives a person ran and the rules they proposed: their research, its report, and their decisions.{ table: 'design_research', subject: 'owner', file: 'deep_dives.json', order: 'created_at', columns: ['id', 'question', 'depth', 'sites', 'run_id', 'status', 'progress', 'report', 'points', 'brief', 'report_url', 'estimate', 'error', 'created_at', 'updated_at'], erase: 'delete' },{ table: 'rule_proposals', subject: 'owner', file: 'rule_proposals.json', order: 'created_at', columns: ['id', 'research_id', 'rule', 'detail', 'why', 'area', 'evidence', 'state', 'pr_url', 'pr_branch', 'decided_at', 'created_at', 'updated_at'], erase: 'delete' },@@@ -30,6 +30,12 @@ const TABLES = [{ table: 'ai_vectors', subject: 'owner', file: 'ai_vectors.json', order: 'created_at', columns: ['id', 'prompt', 'kind', 'svg', 'provider', 'model', 'license', 'state', 'note', 'created_at', 'updated_at'], erase: 'delete' },// How many model runs a person made and when. It is their own usage; it goes with the account.{ table: 'ai_make_runs', subject: 'owner', file: 'ai_make_runs.json', order: 'created_at', columns: ['id', 'kind', 'day', 'created_at'], erase: 'delete' },++ // The Elements and packs gallery: the person's own listings and what they installed, rated or reported.++ // Installing another person's listing exports the listing id and the Live ids it produced, never the author's data.++ { table: 'design_element_listings', subject: 'author_subject', file: 'element_listings.json', order: 'created_at', columns: ['id', 'slug', 'kind', 'name', 'summary', 'document', 'look', 'source', 'source_id', 'remix_of', 'official', 'status', 'installs', 'rating_sum', 'rating_count', 'created_at', 'updated_at'], erase: 'delete' },++ { table: 'design_element_installs', subject: 'subject', file: 'element_installs.json', order: 'created_at', columns: ['id', 'listing_id', 'channel', 'library', 'created_at'], erase: 'delete' },++ { table: 'design_element_ratings', subject: 'subject', file: 'element_ratings.json', order: 'created_at', columns: ['id', 'listing_id', 'stars', 'note', 'created_at', 'updated_at'], erase: 'delete' },++ { table: 'design_element_reports', subject: 'subject', file: 'element_reports.json', order: 'created_at', columns: ['id', 'listing_id', 'reason', 'detail', 'status', 'created_at'], erase: 'delete' },];async function extraExport(db, subject) {nnew file mode 100644@@@ -0,0 +1,10 @@++'use strict';++/** Is this session viewer OpenVibe staff? The Network role claim, ranked by the contracts' one table. */++const { staff } = require('openvibe-contracts');++++function isStaff(viewer) {++ if (!viewer || viewer.kind !== 'user' || typeof viewer.role !== 'string' || !viewer.role) return false;++ try { return staff.atLeast(viewer.role, 'global_mod'); } catch { return false; }++}++++module.exports = { isStaff };@@@ -7,6 +7,7 @@*/const { createApp } = require('./app');const { gracefulStop } = require('openvibe-sdk/service');++const { seedOfficial } = require('./elements/official');/*** The process stop (openvibe-sdk/service): stop taking connections, drain the requests in flight, stop the Arena@@@ -33,6 +34,8 @@ function createLifecycle({ server, ctx, exit, signals } = {}) {async function start() {const { app, ctx } = await createApp();const { config } = ctx;++ // The first official pack (LOOI), free and published by OpenVibe itself, once; a failure never blocks boot.++ await seedOfficial(ctx.gallery, ctx.log || console);// Socket activation (2026-10-31): systemd's .socket unit holds the listener across restarts, so a deploy// queues requests in the kernel instead of answering 502. Without the socket unit, the usual listen.const socketActivated = process.env.LISTEN_FDS === '1' && Number(process.env.LISTEN_PID) === process.pid;@@@ -30,6 +30,9 @@ const MAKES = [{ id: 'mark', name: 'Mark', glyph: G('<circle cx="12" cy="12" r="8.2"/><path d="M8.4 12.4l2.4 2.4 4.8-5.4"/>'),placeholder: 'Describe the mark: the name, what it stands for and where it will be used…',examples: ['A mark for a robotics club', 'An app icon for a notes app', 'A monogram, two letters'] },++ { id: 'element', name: 'Element', glyph: G('<rect x="4" y="6" width="16" height="12" rx="6"/><circle cx="12" cy="12" r="2.4"/>'),++ placeholder: 'Describe an element: what it looks like and what it does…',++ examples: ['A big red button that makes the robot spin, once a minute, costs 100 points', 'A meter that fills when chat reacts', 'A button that turns the headlights on'] },];const MAKE = Object.fromEntries(MAKES.map((m) => [m.id, m]));const FRAME_ICON = G('<rect x="3.5" y="5" width="12" height="9" rx="2"/><rect x="14" y="9.5" width="6.5" height="10" rx="2"/>');@@@ -117,6 +120,7 @@ ${models.length ? `<p class="dv-group-h">Or choose the models yourself<small>Upconst kindName = Object.fromEntries(kinds.map((k) => [k.id, k]));const lookName = Object.fromEntries(looksAll.map((t) => [t.id, t.name]));const body = `<section class="dv-create"><h1>What will you design?</h1>++<p class="dv-create-gallery"><a href="/gallery">Elements and packs gallery →</a> Free controls people made with AI: install one on your channel or remix it.</p><form method="post" action="/create" class="dv-composer" data-dv-composer${options ? ' data-api="/api/create"' : ''}>${kindRow}<label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>@@@ -210,14 +214,20 @@ function canvas({ data, themes = [], editKey = '', viewer = null, publishing = {const cards = variants.map((v, index) => {const picked = index === pickedIndex;const done = v.status === 'done';++ const isElement = design.kind === 'element';return `<article class="dv-card" data-status="${esc(v.status)}" data-index="${index}" data-picked="${picked ? 'true' : 'false'}" data-variant-id="${esc(v.id || '')}">--<div class="dv-frame">${done ? frame(v.html, `${v.model || 'Design'} frame`, { interactive: picked }) :++<div class="dv-frame${isElement ? ' dv-frame--element' : ''}">${isElement++ ? (done && v.look ? `<div class="dv-el-look">${v.look}</div>`++ : `<div class="dv-skeleton" role="status"><span>${esc(v.status === 'failed' ? 'Could not make the element' : 'Writing the element…')}</span><small>${esc(v.status === 'failed' ? String(v.element_error || v.error || 'The AI did not return a valid element.').slice(0, 240) : 'A look and a recipe together')}</small></div>`)++ : done ? frame(v.html, `${v.model || 'Design'} frame`, { interactive: picked }) :`<div class="dv-skeleton" role="status"><span>${esc(v.model || 'Designer')}</span><small>${esc(v.status === 'failed' ? 'Could not generate' : 'Creating…')}</small></div>`}</div><div class="dv-label"><span class="dv-model">${esc(v.model || 'Designer')}</span>${done ?`<span>${(Number(v.ms || 0) / 1000).toFixed(1)} s · $${Number(v.costUsd || 0).toFixed(4)}</span>` : ''}</div>${deepDiveView.takePoints(v.research_points || v.researchPoints, design.research)}<div class="dv-card-actions">${picked-- ? `<span class="dv-picked">Working frame</span><span class="dv-fine">Click any element to edit it</span>`++ ? (design.kind === 'element'++ ? `<span class="dv-picked">Working element</span><a class="dv-fine" href="/d/${encodeURIComponent(design.id)}/element/preview${editKey ? `?key=${encodeURIComponent(editKey)}` : ''}">Preview, tweak and publish →</a>`++ : `<span class="dv-picked">Working frame</span><span class="dv-fine">Click any element to edit it</span>`): done ? (pick ? '<span class="dv-fine">Not the working frame</span>': `<form method="post" action="/d/${encodeURIComponent(design.id)}/pick"><input type="hidden" name="key" value="${esc(editKey)}"><input type="hidden" name="variantIndex" value="${index}"><button type="submit" class="dv-pick" data-pick="${index}">Use this design</button></form>`): '<span class="dv-fine">Waiting for this designer…</span>'}</div>@@@ -247,30 +257,33 @@ ${versionDetail(v)}</span></li>`).join('');: design.status === 'done' ? 'Three designs ready. Pick one to start editing.': design.status === 'failed' ? 'Generation failed.' : 'Designers are working…';const keyInput = `<input type="hidden" name="key" value="${esc(editKey)}">`;-- const body = `<section class="dv-canvas" data-id="${esc(design.id)}" data-key="${esc(editKey)}" data-picked="${pick ? pickedIndex : ''}" data-variant-id="${esc(pickedVariant && pickedVariant.id || '')}">--<header class="dv-heading"><a href="/create">← New design</a><h1>${esc(design.prompt)}</h1>--<p id="dv-status" role="status">${esc(statusText)}</p>--<p class="dv-share"><button type="button" data-ov-share-sheet data-kind="design" data-id="${esc(design.id)}"-- data-title="${esc(design.prompt)}" data-url="${esc(`/d/${encodeURIComponent(design.id)}`)}">Share</button></p></header>--${deepDiveView.researchPanel(design.research)}--<form class="dv-chat" id="dv-chat-form" method="post" action="/d/${encodeURIComponent(design.id)}/edit">++ const versionsPanel = design.kind === 'element' ? '' : `<details class="dv-versions" id="dv-versions" open>++<summary>Versions <span>${versions.length}</span></summary>++<div class="dv-vcontrols">++<form method="post" action="/d/${encodeURIComponent(design.id)}/undo">${keyInput}<button type="submit"${pick && pick.canUndo ? '' : ' disabled'}>Undo</button></form>++<form method="post" action="/d/${encodeURIComponent(design.id)}/redo">${keyInput}<button type="submit"${pick && pick.canRedo ? '' : ' disabled'}>Redo</button></form>++</div>++${versions.length ? `<ol class="dv-vlist" id="dv-vlist">${versionRows}</ol>` : '<p class="dv-fine">Pick a frame; its HTML becomes version 1. Every accepted edit adds a version and nothing is deleted.</p>'}++</details>`;++ const chatPanel = design.kind === 'element' ? '' : `<form class="dv-chat" id="dv-chat-form" method="post" action="/d/${encodeURIComponent(design.id)}/edit">${keyInput}<label for="dv-instruction">Edit with AI</label><div class="dv-chat-row"><input id="dv-instruction" name="instruction" maxlength="1000" placeholder="${pick ? 'Make the hero bolder, add a pricing section…' : 'Pick a frame first'}"${canChat ? '' : ' disabled'}><button type="submit"${canChat ? '' : ' disabled'}>Send</button></div><p class="dv-chat-status" id="dv-edit-status" role="status" aria-live="polite"></p><p class="dv-fine">${pick ? `${chatLeft} of ${pick.chatEditLimit} AI edits left on this design. Click edits and undo are free.` : 'Chat edits work on the picked frame.'}</p>--</form>++</form>`;++ const body = `<section class="dv-canvas" data-kind="${esc(design.kind || 'page')}" data-id="${esc(design.id)}" data-key="${esc(editKey)}" data-picked="${pick ? pickedIndex : ''}" data-variant-id="${esc(pickedVariant && pickedVariant.id || '')}">++<header class="dv-heading"><a href="/create">← New design</a><h1>${esc(design.prompt)}</h1>++<p id="dv-status" role="status">${esc(statusText)}</p>++<p class="dv-share"><button type="button" data-ov-share-sheet data-kind="design" data-id="${esc(design.id)}"++ data-title="${esc(design.prompt)}" data-url="${esc(`/d/${encodeURIComponent(design.id)}`)}">Share</button></p></header>++${deepDiveView.researchPanel(design.research)}++${design.kind === 'element' ? `<section class="dv-el-panel"><h2>Element</h2><p>The AI wrote the look and the recipe together. The preview runs it on sample data and moves the size, tint and label sliders; the same page adds it to your Live channel or publishes it to the Elements and packs gallery.</p><p><a class="ovk-btn ovk-btn--primary" href="/d/${encodeURIComponent(design.id)}/element/preview${editKey ? `?key=${encodeURIComponent(editKey)}` : ''}">Open the element preview</a></p></section>` : ''}++${chatPanel}<div class="dv-rail" id="dv-rail">${cards}</div>--${publishPanel({ design, pick, viewer, editKey, publishing })}--<details class="dv-versions" id="dv-versions" open>--<summary>Versions <span>${versions.length}</span></summary>--<div class="dv-vcontrols">--<form method="post" action="/d/${encodeURIComponent(design.id)}/undo">${keyInput}<button type="submit"${pick && pick.canUndo ? '' : ' disabled'}>Undo</button></form>--<form method="post" action="/d/${encodeURIComponent(design.id)}/redo">${keyInput}<button type="submit"${pick && pick.canRedo ? '' : ' disabled'}>Redo</button></form>--</div>--${versions.length ? `<ol class="dv-vlist" id="dv-vlist">${versionRows}</ol>` : '<p class="dv-fine">Pick a frame; its HTML becomes version 1. Every accepted edit adds a version and nothing is deleted.</p>'}--</details>++${design.kind === 'element' ? '' : publishPanel({ design, pick, viewer, editKey, publishing })}++${versionsPanel}<nav class="dv-toolbar" aria-label="Canvas tools"><label>Device <select id="dv-device"><option value="390">Phone · 390</option><option value="834">Tablet · 834</option><option value="1280">Desktop · 1280</option></select></label><form method="post" action="/d/${encodeURIComponent(design.id)}/theme" id="dv-theme-form">${keyInput}<label>Theme <select id="dv-theme" name="themeId">${themeOptions}</select></label><button type="submit">Apply</button></form></nav></section>nnew file mode 100644@@@ -0,0 +1,133 @@++'use strict';++++/**++ * The Element preview: the look, the recipe run on sample data (people of each role pressing, a fake chat and the++ * robot actions drawn in Bot's simulated arena), the price of any AI it calls, and — for the maker — the tweak++ * sliders, "Add to my channel", "Publish to gallery" and "Build with Codes".++ *++ * It is the same page for the canvas and for Exchange's "Try it": the public copy has no owner controls and no++ * private design id in its form actions.++ */++const kit = require('openvibe-shared/kit');++const { layout, esc } = require('../render/layout');++const { renderLook } = require('../create/element');++++const ICON = { press: '●', chat: '💬', refused: '✕', cost: '◆', chance: '?', robot: '▸', screen: '▣', state: '▤', stream: '▶', ai: '✦', money: '¤', unknown: '·' };++++const money = (n) => (n == null ? '–' : `$${Number(n) < 0.1 ? Number(n).toPrecision(2) : Number(n).toFixed(2)}`);++++function priceLine({ design = null, price = null }) {++ const making = design && design.estimate && Number.isFinite(Number(design.estimate.total))++ ? `Making it: ${money(design.estimate.total)}${design.estimate.priced === false ? ' (some models unpriced)' : ''}.`++ : '';++ const calls = price && price.calls && price.calls.length++ ? (price.calls.map((c) => `${c.available ? `${c.model}: ${money(c.price_in)} in / ${money(c.price_out)} out per million${c.max_cost_usd != null ? `, at most ${money(c.max_cost_usd)} a press` : ''}` : `${c.model || 'The model'}: not on the live catalogue, price unknown`}`).join(' · '))++ : 'No AI it calls: it runs from the recipe alone.';++ return `<p class="el-price"><b>Price</b> ${esc(making)} ${esc(calls)} Elements are free: an AI action is paid for by the person whose channel runs it, through their own Balance.</p>`;++}++++function timeline(sim) {++ if (!sim || !Array.isArray(sim.events) || !sim.events.length) return '<p class="dv-fine">No sample events.</p>';++ return `<ol class="el-events">${sim.events.map((e) => `<li class="el-event el-event--${esc(e.kind)}"><span class="el-event-icon" aria-hidden="true">${ICON[e.kind] || '·'}</span><span class="el-event-text">${esc(e.text)}</span>${e.person ? `<small>${esc(e.person)}</small>` : ''}</li>`).join('')}</ol>`;++}++++/** The robot actions only, in the shape public/js/element-sim.js plays. */++function robotActions(sim) {++ const actions = [];++ for (const e of (sim && sim.events) || []) {++ if (e.kind !== 'robot') continue;++ const robot = e.robot && e.robot.kind === 'robot.drive_pulse' ? { ...e.robot, kind: 'robot.drive_pulse' } : e.headlight != null ? { on: e.headlight } : null;++ actions.push({ kind: 'robot', text: e.text, robot });++ }++ return actions;++}++++function buildWithCodes({ element, design, baseUrl }) {++ const spec = [++ `Build an OpenVibe element mod for: ${element.name}.`,++ element.description || '',++ 'The element document (ui.element@1) is below. Write the mod against ov.element@1 in the sandbox ' +++ '(iframe, opaque origin, postMessage only, no network except the streamer-listed endpoint), keep it under 256 KB, ' +++ 'and test it in the element test bench.',++ JSON.stringify(element, null, 2),++ ].join('\n\n').slice(0, 12000);++ const href = `https://openvibe.codes/improve/OpenVibers/OpenVibe.Design?prompt=${encodeURIComponent(spec)}`;++ return `<details class="el-codes"><summary>Build with Codes</summary>++<p>OpenVibe.Codes writes the mod from this spec and its tests. Nothing is built until the streamer asks for it.</p>++<label>Task text <textarea rows="6" readonly>${esc(spec)}</textarea></label>++<p>${kit.button({ href, label: 'Build with Codes', kind: 'quiet' })} <span class="dv-fine">Opens OpenVibe.Codes with the task text prefilled.</span></p>++</details>`;++}++++function tweakForm({ design, variant, editKey }) {++ const tweaks = (variant && variant.tweaks) || [];++ const ranges = tweaks.filter((t) => t.kind === 'range');++ const label = tweaks.find((t) => t.name === 'label');++ const show = tweaks.find((t) => t.name === 'show_label');++ if (!tweaks.length) return '';++ return `<form class="el-tweaks" method="post" action="/d/${encodeURIComponent(design.id)}/element/tweak">++<input type="hidden" name="key" value="${esc(editKey)}">++<fieldset><legend>Make it yours</legend>++${ranges.map((t) => `<label class="el-tweak"><span>${esc(t.label)}</span><input type="range" name="${esc(t.name)}" min="${Number(t.min)}" max="${Number(t.max)}" step="${Number(t.step)}" value="${Number(t.value)}" list="el-${esc(t.name)}-values"><datalist id="el-${esc(t.name)}-values">${(t.options || []).map((o, i) => `<option value="${i}">${esc(o)}</option>`).join('')}</datalist><small>${esc((t.options || [])[Number(t.value)] || '')}</small></label>`).join('')}++${label ? `<label class="el-tweak"><span>${esc(label.label)}</span><input type="text" name="label" maxlength="${Number(label.maxlength) || 40}" value="${esc(label.value)}"></label>` : ''}++${show ? `<label class="el-tweak el-tweak--check"><input type="checkbox" name="show_label" value="1"${show.value ? ' checked' : ''}> ${esc(show.label)}</label>` : ''}++${kit.button({ label: 'Apply', type: 'submit', kind: 'quiet' })}++</fieldset></form>`;++}++++/**++ * o: { design (null for a listing), element, sim, price, editKey, viewer, mode ('canvas'|'try'), notice, error,++ * listingUrl, tryUrl, baseUrl }++ */++function elementPreview(o) {++ const { design = null, element, sim = null, price = null, editKey = '', viewer = null, mode = 'canvas', notice = null, error = null } = o;++ const mine = Boolean(design && viewer && (mode === 'canvas'));++ const signedIn = Boolean(viewer);++ const channel = design && design.elementChannel;++ const listing = design && design.elementListing;++ const variant = design && o.variant ? o.variant : null;++ const prompts = design ? `<p class="dv-fine">Made from: “${esc(design.prompt)}”</p>${design.elementRemixOf ? `<p class="dv-fine">Remixed from <a href="/gallery/${encodeURIComponent(design.elementRemixOf)}">the original</a>.</p>` : ''}` : '';++ const back = design ? `<a href="/d/${encodeURIComponent(design.id)}${editKey ? `?key=${encodeURIComponent(editKey)}` : ''}">← Back to the design</a>` : '<a href="/gallery">Elements and packs</a>';++ const handoffs = mine ? `<div class="el-handoffs">++${channel && !channel.error ? `<p class="dv-ok" role="status">On your channel${channel.url ? `: <a href="${esc(channel.url)}">${esc(channel.url.replace(/^https?:\/\//, ''))}</a>` : '.'}</p>` : ''}++${channel && channel.error ? `<p class="dv-err" role="alert">${esc(channel.error)}</p>` : ''}++${listing && !listing.error ? `<p class="dv-ok" role="status">In the gallery${listing.url ? `: <a href="${esc(listing.url)}">${esc(listing.url)}</a>` : '.'}</p>` : ''}++${listing && listing.error ? `<p class="dv-err" role="alert">${esc(listing.error)}</p>` : ''}++<form method="post" action="/d/${encodeURIComponent(design.id)}/element/channel"><input type="hidden" name="key" value="${esc(editKey)}">${kit.button({ label: channel && !channel.error ? 'Add again to my channel' : 'Add to my channel', type: 'submit' })}</form>++<form method="post" action="/d/${encodeURIComponent(design.id)}/element/gallery"><input type="hidden" name="key" value="${esc(editKey)}">${kit.button({ label: listing && !listing.error ? 'Publish again to the gallery' : 'Publish to the gallery', type: 'submit', kind: 'quiet' })}</form>++<p class="dv-fine">Add to my channel puts it in your Live element library. Publish puts it in the Elements and packs gallery here on Design as a free listing under your name.${signedIn ? '' : ' Sign in to use either.'}</p>++</div>` : (mode === 'canvas' ? '<p class="dv-fine">Sign in at OpenVibe to add this element to your channel or publish it.</p>' : '');++ const share = o.listingUrl ? `<p><a href="${esc(o.listingUrl)}">See it in the Elements and packs gallery →</a></p>` : '';++ const tryIt = o.tryUrl ? `<p><a class="ovk-btn ovk-btn--quiet" href="${esc(o.tryUrl)}">Try it again</a></p>` : '';++ const body = `<section class="el-page">++<header class="el-head">${back}<h1>${esc(element.name)}</h1>++<p>${esc(element.description || 'An element made with OpenVibe.Design.')}</p>${prompts}++${notice ? `<p class="dv-ok" role="status">${esc(notice)}</p>` : ''}${error ? `<p class="dv-err" role="alert">${esc(error)}</p>` : ''}</header>++<div class="el-grid">++<div class="el-look-panel">++${renderLook(element)}++<p class="dv-fine">${esc(element.look.component)} · ${esc(element.look.label || '')}${element.look.style && element.look.style.tint ? ` · tint ${esc(element.look.style.tint)}` : ''}</p>++<canvas id="el-sim" width="320" height="240" aria-label="OpenVibe.Bot's simulated arena with the element's robot actions played on a local rover"></canvas>++<p class="dv-fine" id="el-sim-state" role="status">Bot's simulated arena. This is a picture; no robot is connected.</p>++<script type="application/json" id="el-sim-data">${JSON.stringify({ actions: robotActions(sim) }).replace(/</g, '\\u003c')}</script>++</div>++<div class="el-run">++<h2>On sample data</h2>++<p class="dv-fine">${esc(sim && sim.note || 'The recipe is run against sample people and a fake chat.')}</p>++${timeline(sim)}++${priceLine({ design, price })}++</div></div>++${tweakForm({ design, variant, editKey })}++${handoffs}++${share}${tryIt}++${design ? buildWithCodes({ element, design }) : ''}++</section>`;++ return layout({++ title: `${element.name} · Element`, description: element.description || `A free ${element.look.component} element made with OpenVibe.Design.`,++ path: o.path || (design ? `/d/${design.id}/element/preview` : `/elements/${o.listingId || ''}/try`),++ robots: o.robots || (design ? 'noindex, nofollow' : 'index, follow'),++ body, scripts: ['js/sim-world.js', 'js/element-sim.js'], active: design ? '/create' : '/gallery',++ });++}++++module.exports = { elementPreview, timeline, priceLine, robotActions, buildWithCodes };nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++++/**++ * The Elements and packs gallery on OpenVibe.Design: the list beside /create, one listing with Install, Remix,++ * Try it, a rating and a report, and the staff report queue. The look is drawn from the element document with++ * Design's own control preview; the real one is the Try it page.++ */++const kit = require('openvibe-shared/kit');++const { layout, esc } = require('../render/layout');++const { renderLook } = require('../create/element');++++const stars = (n) => '★★★★★'.slice(0, n) + '☆☆☆☆☆'.slice(0, 5 - n);++const ratingText = (r) => (r && r.count ? `${r.average} / 5 from ${r.count} rating${r.count === 1 ? '' : 's'}` : 'No ratings yet');++const lookOf = (l) => renderLook({ look: l.look || (l.document && l.document.items && l.document.items[0] && l.document.items[0].look) || { component: 'button', label: l.name } });++++function card(l) {++ return `<li class="el-g-card"><a href="/gallery/${encodeURIComponent(l.slug)}">++<span class="el-g-look">${lookOf(l)}</span>++<span class="el-g-text"><span class="el-g-title"><b>${esc(l.name)}</b> ${kit.badge(l.kind === 'pack' ? 'Pack' : 'Element', l.official ? 'accent' : undefined)}</span>++<small>${esc(l.author && l.author.name || 'A maker')} · ${l.installs} install${l.installs === 1 ? '' : 's'} · ${esc(ratingText(l.rating))}</small>++<span class="el-g-summary">${esc(l.summary || '')}</span></span></a></li>`;++}++++function galleryPage({ listings = [], count = 0, q = '', kind = '', sort = 'new', viewer = {}, notice = null } = {}) {++ const signedIn = viewer.kind === 'user';++ const body = `<section class="el-page el-gallery">++${kit.head({ title: 'Elements and packs', sub: 'Free controls, buttons and stream widgets people made with AI. Install one on your channel, remix it, or try it first.', actions: [kit.button({ href: '/create', label: 'Make one' })] })}++${notice ? `<p class="dv-ok" role="status">${esc(notice)}</p>` : ''}++<div class="el-g-sec">++<form class="el-g-search" method="get" action="/gallery" role="search">++<input type="search" name="q" value="${esc(q)}" placeholder="Search elements and packs" aria-label="Search elements and packs">++<select name="kind" aria-label="Kind"><option value="">All kinds</option><option value="element"${kind === 'element' ? ' selected' : ''}>Elements</option><option value="pack"${kind === 'pack' ? ' selected' : ''}>Packs</option></select>++<select name="sort" aria-label="Sort"><option value="new"${sort === 'new' ? ' selected' : ''}>Newest</option><option value="installs"${sort === 'installs' ? ' selected' : ''}>Most installed</option><option value="rating"${sort === 'rating' ? ' selected' : ''}>Best rated</option></select>++${kit.button({ label: 'Search', kind: 'quiet', type: 'submit' })}++</form>++<p class="dv-fine">${count} listing${count === 1 ? '' : 's'}, free to install.${signedIn ? ' Make one in <a href="/create">/create</a>, choose Element, then publish it from its preview.' : ''}</p>++${listings.length ? `<ul class="el-g-list">${listings.map(card).join('')}</ul>` : kit.empty({ icon: 'spark', title: 'Nothing here yet', text: 'No listing matches that search.' })}++</div></section>`;++ return layout({ title: 'Elements and packs', description: 'Free buttons, controls and stream widgets people made with AI: install one on your channel, remix it, or try it first.', path: '/gallery', body, active: '/gallery' });++}++++function itemList(l) {++ if (l.kind !== 'pack' || !Array.isArray(l.document.items)) return '';++ return `<ul class="el-g-items">${l.document.items.map((it) => `<li><b>${esc(it.name || 'Element')}</b>${it.description ? ` <small>${esc(it.description)}</small>` : ''}</li>`).join('')}</ul>`;++}++++function listingPage({ listing: l, viewer = {}, installed = null, myRating = null, canRemix = false, notice = null, error = null } = {}) {++ const signedIn = viewer.kind === 'user';++ const next = `/gallery/${encodeURIComponent(l.slug)}`;++ const installForm = !signedIn++ ? kit.notice({ kind: 'info', text: `Sign in to install or rate this ${l.kind}.` }) + `<p>${kit.button({ href: `/auth/login?next=${encodeURIComponent(next)}`, label: 'Sign in with OpenVibe' })}</p>`++ : installed++ ? `<p class="dv-ok" role="status">${kit.badge('Installed', 'ok')} On your channel${installed.channel ? ` (${esc(installed.channel)})` : ''}${installed.library && installed.library.error ? `; Live said: ${esc(installed.library.error)}` : ''}.</p>`++ : `<form method="post" action="${next}/install"><p>${kit.button({ label: 'Install', type: 'submit' })}</p><p class="dv-fine">Free. It goes to your Live channel and the maker is credited.</p></form>`;++ const rateForm = signedIn && installed++ ? `<form method="post" action="${next}/rating" class="el-g-rate"><fieldset><legend>Your rating</legend>++<label>Stars <select name="stars" required>${[5, 4, 3, 2, 1].map((n) => `<option value="${n}"${myRating && myRating.stars === n ? ' selected' : ''}>${n} — ${stars(n)}</option>`).join('')}</select></label>++<label>Note (optional) <input type="text" name="note" maxlength="400" value="${esc(myRating && myRating.note || '')}"></label>++${kit.button({ label: myRating ? 'Update rating' : 'Rate it', type: 'submit', kind: 'quiet' })}</fieldset></form>`++ : (signedIn ? '<p class="dv-fine">Install it to rate it.</p>' : '');++ const remixForm = signedIn && canRemix++ ? `<form method="post" action="${next}/remix" class="el-g-remix"><p>${kit.button({ label: 'Remix', type: 'submit', kind: 'quiet' })}</p><p class="dv-fine">A copy of your own that credits ${esc(l.author && l.author.name || 'the original')}.</p></form>`++ : (signedIn ? '<p class="dv-fine">Your own element; other people can remix it and credit you.</p>' : `<p class="dv-fine"><a href="/auth/login?next=${encodeURIComponent(next)}">Sign in</a> to remix it.</p>`);++ const reportForm = signedIn++ ? `<details class="el-g-report"><summary>Report this ${l.kind}</summary><form method="post" action="${next}/report">++<label>Reason <select name="reason"><option value="unsafe">Unsafe</option><option value="broken">Broken</option><option value="spam">Spam</option><option value="other">Other</option></select></label>++<label>What is wrong? <textarea name="detail" maxlength="1000" rows="3"></textarea></label>++${kit.button({ label: 'Send report', type: 'submit', kind: 'quiet' })}</form><p class="dv-fine">Staff read reports; nothing is hidden by a report itself.</p></details>`++ : '';++ return layout({++ title: l.name, description: l.summary || `${l.name}, a free ${l.kind} for OpenVibe.`, path: next,++ robots: l.status === 'published' ? 'index, follow' : 'noindex, nofollow',++ active: '/gallery',++ body: `<section class="el-page el-listing">++${kit.head({ title: l.name, sub: `${l.kind === 'pack' ? 'Pack' : 'Element'} by ${l.author && l.author.name || 'a maker'} · ${l.installs} install${l.installs === 1 ? '' : 's'}`, back: { href: '/gallery', label: 'Elements and packs' }, actions: [kit.badge('Free', 'ok')] })}++${notice ? `<p class="dv-ok" role="status">${esc(notice)}</p>` : ''}${error ? `<p class="dv-err" role="alert">${esc(error)}</p>` : ''}++<div class="el-g-detail">++<div class="el-g-preview">${lookOf(l)}</div>++<div class="el-g-side">++${l.summary ? `<p class="dv-prose">${esc(l.summary)}</p>` : ''}++${l.remix_of ? `<p class="dv-fine">Remixed from <a href="/gallery/${encodeURIComponent(l.remix_of)}">an earlier element</a>.</p>` : ''}++<h2>Install</h2>${installForm}++<h2>Try it</h2><p><a class="ovk-btn ovk-btn--quiet" href="${next}/try">Try it</a></p>++<h2>Remix</h2>${remixForm}++<h2>Rating</h2><p>${esc(ratingText(l.rating))}</p>${rateForm}++${itemList(l)}++<h2>Report</h2>${reportForm}++</div></div></section>`,++ });++}++++function moderationPage({ reports = [], notice = null } = {}) {++ return layout({++ title: 'Element reports', description: 'Staff reports on the Elements gallery.', path: '/gallery/moderation', robots: 'noindex, nofollow', active: '/gallery',++ body: `<section class="el-page">++${kit.head({ title: 'Element reports', sub: 'Pending reports from people. Accepting one hides the listing; rejecting one leaves it be.', back: { href: '/gallery', label: 'Elements and packs' } })}++${notice ? `<p class="dv-ok" role="status">${esc(notice)}</p>` : ''}++${reports.length ? `<ul class="el-g-reports">${reports.map((r) => `<li class="el-g-report-row">++<p><a href="/gallery/${encodeURIComponent(r.slug)}"><b>${esc(r.name)}</b></a> ${kit.badge(r.listing_status)}</p>++<p>${esc(r.reason)} · ${esc(new Date(r.created_at).toISOString().slice(0, 16).replace('T', ' '))}${r.detail ? ` — ${esc(r.detail)}` : ''}</p>++<form method="post" action="/gallery/moderation/reports/${encodeURIComponent(r.id)}">++${kit.button({ label: 'Accept: hide the listing', type: 'submit', kind: 'primary' })}++${kit.button({ label: 'Reject', type: 'submit', kind: 'quiet', name: 'decision', value: 'reject' })}++</form></li>`).join('')}</ul>` : kit.empty({ icon: 'shield', title: 'No pending reports', text: 'Nothing for staff to read right now.' })}++</section>`,++ });++}++++module.exports = { galleryPage, listingPage, moderationPage };@@@ -84,7 +84,8 @@ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));// what is made, who designs it, and the recent designs as cards that say what each is.const kindsShown = [...recent.text.matchAll(/name="kind" value="([a-z-]+)"/g)].map((m) => m[1]);assert.deepEqual(kindsShown.slice(0, 3), ['page', 'screen', 'component'], 'the things that can be made, the first three first');-- assert.ok(kindsShown.length === 3 || kindsShown.length === 7, 'three without the options, seven with them');++ assert.ok(kindsShown.length === 3 || kindsShown.length === 8, 'three without the options, eight with them (Element included)');++ if (kindsShown.length === 8) assert.ok(kindsShown.includes('element'), 'Element is one of the things that can be made');assert.deepEqual([...recent.text.matchAll(/name="who" value="([a-z]+)"/g)].map((m) => m[1]), ['claude', 'sonnet', 'any', 'compare'], 'the four ways to pick who designs');assert.match(recent.text, /href="https:\/\/openvibe\.exchange\/models">Compare these models on OpenVibe Exchange<\/a>/, 'the models are compared on Exchange');assert.match(recent.text, /<a class="dv-rc" href="\/d\/[^"]+"><span class="dv-rc-k" aria-hidden="true"><(?:svg|img src="\/api\/create\/designs\/)/, 'a recent design is a card with its picture (or its kind drawn where there is none)');nnew file mode 100644@@@ -0,0 +1,252 @@++'use strict';++/**++ * Design /create's kind Element and the Elements and packs gallery beside it (owner correction, 2026-11-11:++ * the gallery is on Design; Exchange is only the AI price catalogue). The AI writes a ui.element@1 look and++ * recipe as JSON, a bad take is asked again once with its reasons, the picked element runs on sample data++ * (never a robot), the generated sliders move size, tint and label, and the element is added to Live and++ * published, installed, rated, remixed and reported on Design itself.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++++const ELEMENT = {++ name: 'Big red button',++ description: 'A big red button that makes the robot spin, once a minute, for 100 points.',++ look: { component: 'button', label: 'Spin', icon: 'spark', props: { size: 'large' }, style: { tint: 'accent', shape: 'pill', opacity: 1, show_label: true } },++ recipe: {++ triggers: [{ kind: 'press' }],++ conditions: { role: 'everyone', cost: { points: 100 }, cooldown: { per_person_seconds: 60, overall_seconds: 60 } },++ actions: [{ kind: 'robot.drive_pulse', product: 'bot', direction: 'spin_left', ms: 2000 }],++ },++ capabilities: ['robot.drive_pulse'],++ initial_state: { spins: 0 },++};++++function listen(handler) {++ return new Promise((resolve) => {++ const srv = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', async () => {++ const body = Buffer.concat(chunks).toString('utf8');++ let json = {};++ try { json = JSON.parse(body || '{}'); } catch { /* empty */ }++ const answer = (status, doc) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(doc)); };++ try { await handler(req, json, answer); } catch (err) { answer(500, { detail: err.message }); }++ });++ });++ srv.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${srv.address().port}`, close: () => new Promise((r) => srv.close(r)) }));++ });++}++++const fakeAi = (state, doc = ELEMENT) => ({++ designPlan: async () => ({ slots: [{ provider: 'acme', model: 'alpha-1' }], prefer: { provider: 'acme', model: 'alpha-1' }, pin: true }),++ text: async ({ prompt, system }) => {++ state.prompts.push(prompt);++ (state.systems = state.systems || []).push(system);++ const answer = state.badFirst && state.prompts.length === 1 ? JSON.stringify({ name: 'Broken' }) : JSON.stringify(doc);++ return { text: answer, model: 'alpha-1', provider: 'acme', run_id: `run_${state.prompts.length}`, cost_usd: 0.0012, ms: 300 };++ },++ exchangeCatalog: async () => ({ models: [{ slug: 'acme/alpha-1', name: 'Alpha', offers: [{ price: { in: 1, out: 2 } }] }] }),++});++++(async () => {++ const aiState = { prompts: [], systems: [], badFirst: false };++ const ai = fakeAi(aiState);++ const liveCalls = [];++ const live = await listen((req, body, answer) => {++ liveCalls.push({ url: req.url, auth: Boolean(req.headers.authorization), body });++ answer(201, { id: 'el_LIVE0000000000000000000000', url: 'https://openvibe.live/elements/el_LIVE0000000000000000000000' });++ });++ const t = await boot({ ai, env: { OV_LIVE_URL: live.url } });++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const mod = t.network.addUser('mod', { role: 'global_mod' });++ const token = (u) => ({ authorization: `Bearer ${t.network.userToken(u)}` });++ const read = async (path, as = alice) => { for (let i = 0; i < 80; i += 1) { const r = await t.get(path, { as }); const d = r.json(); if (d.variants.every((v) => v.status === 'done' || v.status === 'failed')) return r; await new Promise((r) => setTimeout(r, 25)); } throw new Error('generation did not finish'); };++++ try {++ await check('an Element design is generated as one JSON document, never HTML', async () => {++ const r = await t.get('/api/v1/designs', { headers: token(alice), json: { prompt: 'a big red button that makes the robot spin, once a minute, costs 100 points', kind: 'element' } });++ assert.strictEqual(r.status, 201, r.text);++ const id = r.json().design.id;++ const data = (await read(`/api/v1/designs/${id}`)).json();++ const v = data.variants[0];++ assert.strictEqual(v.status, 'done', v.error || '');++ assert.strictEqual(v.element.name, 'Big red button');++ assert.strictEqual(v.element.look.component, 'button');++ assert.strictEqual(v.element.recipe.actions[0].kind, 'robot.drive_pulse');++ assert.ok(!v.html.includes('<'), 'an element is not an HTML document');++ assert.ok(v.look.includes('dv-elx'), 'the look is drawn from the component');++ t.designId = id;++ t.elementVariant = v;++ assert.ok(aiState.systems[0].includes('ui.element@1'), 'the prompt asks for the element contract');++ assert.ok(aiState.systems[0].includes('robot.drive_pulse'), 'the prompt carries the action catalog');++ });++++ await check('an invalid first take is asked once more with the reasons', async () => {++ aiState.badFirst = true;++ aiState.prompts = [];++ const r = await t.get('/api/v1/designs', { headers: token(alice), json: { prompt: 'a big red button', kind: 'element' } });++ assert.strictEqual(r.status, 201, r.text);++ const data = (await read(`/api/v1/designs/${r.json().design.id}`)).json();++ assert.strictEqual(aiState.prompts.length, 2, 'one retry');++ assert.match(aiState.prompts[1], /refused for these reasons/);++ assert.strictEqual(data.variants[0].status, 'done');++ aiState.badFirst = false;++ });++++ await check('pick, then the generated sliders move size, tint and label', async () => {++ const picked = await t.get(`/api/v1/designs/${t.designId}/pick`, { headers: token(alice), json: { variantIndex: 0 } });++ assert.strictEqual(picked.status, 201, picked.text);++ const page = await t.get(`/d/${t.designId}`, { as: alice });++ assert.strictEqual(page.status, 200, page.text);++ assert.ok(page.text.includes('Open the element preview'));++ assert.ok(!page.text.includes('Edit with AI'), 'an element is changed with its sliders, not a chat edit');++ const first = t.elementVariant;++ const tweak = await t.get(`/api/v1/designs/${t.designId}/element/tweak`, { headers: token(alice), json: { size: 0, tint: 1, label: 'Spin it!' } });++ assert.strictEqual(tweak.status, 200, tweak.text);++ const v = tweak.json().variants[0];++ assert.strictEqual(v.element.look.label, 'Spin it!');++ assert.strictEqual(v.element.look.props.size, 'small');++ assert.notStrictEqual(v.element.look.style.tint, first.element.look.style.tint);++ assert.ok(v.tweaks.find((x) => x.name === 'size'));++ });++++ await check('the preview runs the recipe on sample data: roles, cost, cooldown and a simulated robot', async () => {++ const sim = await t.get(`/api/v1/designs/${t.designId}/element/simulate`, { as: alice });++ assert.strictEqual(sim.status, 200, sim.text);++ const out = sim.json();++ assert.ok(out.events.some((e) => e.kind === 'press'));++ assert.ok(out.events.some((e) => e.kind === 'cost' && e.text.includes('100')));++ assert.ok(out.events.some((e) => e.kind === 'refused' && /cooldown/.test(e.text)));++ assert.ok(out.events.some((e) => e.kind === 'robot' && /simulated/.test(e.text)));++ assert.ok(!out.events.some((e) => e.kind === 'ai'), 'nothing calls AI');++ const page = await t.get(`/d/${t.designId}/element/preview`, { as: alice });++ assert.strictEqual(page.status, 200, page.text);++ assert.ok(page.text.includes('On sample data'));++ assert.ok(page.text.includes("Bot's simulated arena"));++ assert.ok(page.text.includes('Build with Codes'));++ assert.ok(page.text.includes('id="el-sim"'));++ });++++ await check('Add to my channel and Publish to the gallery both use the person\'s own token', async () => {++ const channel = await t.get(`/d/${t.designId}/element/channel`, { headers: token(alice), body: 'key=' });++ assert.strictEqual(channel.status, 303, channel.text);++ assert.strictEqual(liveCalls.length, 1);++ assert.strictEqual(liveCalls[0].url, '/api/me/elements');++ assert.strictEqual(liveCalls[0].body.author.name, 'alice');++ assert.strictEqual(liveCalls[0].body.sharing, 'private');++ const published = await t.get(`/d/${t.designId}/element/gallery`, { headers: token(alice), body: 'key=' });++ assert.strictEqual(published.status, 303, published.text);++ const page = await t.get(`/d/${t.designId}/element/preview`, { as: alice });++ assert.ok(page.text.includes('On your channel'));++ assert.ok(page.text.includes('In the gallery'));++ const gallery = await t.get('/gallery');++ assert.strictEqual(gallery.status, 200, gallery.text);++ assert.ok(gallery.text.includes('Big red button'));++ const listing = await t.get('/gallery/big-red-button');++ assert.strictEqual(listing.status, 200, listing.text);++ assert.ok(listing.text.includes('Free'));++ assert.ok(listing.text.includes('Try it'));++ });++++ await check('another person installs it, rates it and remixes it with credit', async () => {++ const install = await t.get('/gallery/big-red-button/install', { headers: token(bob), body: 'key=' });++ assert.strictEqual(install.status, 303, install.text);++ assert.ok(liveCalls.some((c) => c.url === '/api/me/elements'), 'the install goes to Live too');++ const page = await t.get('/gallery/big-red-button', { as: bob });++ assert.ok(page.text.includes('Installed'));++ const rate = await t.get('/gallery/big-red-button/rating', { headers: { ...token(bob), 'content-type': 'application/x-www-form-urlencoded' }, body: 'stars=5¬e=It+spins' });++ assert.strictEqual(rate.status, 303, rate.text);++ const rated = await t.get('/gallery/big-red-button');++ assert.ok(rated.text.includes('5 / 5 from 1 rating'));++ const remix = await t.get('/gallery/big-red-button/remix', { headers: token(bob), body: 'key=' });++ assert.strictEqual(remix.status, 303, remix.text);++ const remixId = /^\/d\/([^?]+)/.exec(remix.headers.get('location') || '')?.[1];++ assert.ok(remixId, 'the remix opens in the canvas');++ const copy = await t.get(`/d/${remixId}`, { as: bob });++ assert.strictEqual(copy.status, 200, copy.text);++ assert.ok(copy.text.includes('Open the element preview'));++ const data = (await t.get(`/api/v1/designs/${remixId}`, { as: bob })).json();++ const original = (await t.get(`/api/v1/designs/${t.designId}`, { as: alice })).json();++ t.listingId = original.design.elementListing.id;++ assert.strictEqual(data.design.elementRemixOf, t.listingId, 'the remix credits the original');++ // Publishing the remix keeps the credit on the listing.++ const published = await t.get(`/d/${remixId}/element/gallery`, { headers: token(bob), body: 'key=' });++ assert.strictEqual(published.status, 303, published.text);++ const remixListing = t.ctx.gallery.get('big-red-button-remix');++ const copyListing = await t.get('/gallery/big-red-button-remix');++ assert.strictEqual((await remixListing).remix_of, t.listingId, 'the published remix keeps the credit');++ assert.strictEqual(copyListing.status, 200, copyListing.text);++ });++++ await check('Try it runs the public listing; a report reaches staff and hides it', async () => {++ const tryPage = await t.get('/gallery/big-red-button/try');++ assert.strictEqual(tryPage.status, 200, tryPage.text);++ assert.ok(tryPage.text.includes('On sample data'));++ assert.ok(tryPage.text.includes('See it in the Elements and packs gallery'));++ const report = await t.get('/gallery/big-red-button/report', { headers: { ...token(bob), 'content-type': 'application/x-www-form-urlencoded' }, body: 'reason=broken&detail=The+button+does+nothing' });++ assert.strictEqual(report.status, 303, report.text);++ const queue = await t.get('/gallery/moderation', { as: mod });++ assert.strictEqual(queue.status, 200, queue.text);++ assert.ok(queue.text.includes('The button does nothing'));++ const accept = await t.get('/gallery/moderation/reports/' + /\/gallery\/moderation\/reports\/([^"]+)/.exec(queue.text)?.[1], { headers: token(mod), body: 'key=' });++ assert.strictEqual(accept.status, 303, accept.text);++ assert.strictEqual((await t.get('/gallery/big-red-button')).status, 404, 'a hidden listing is not public');++ });++++ await check('a look prop cannot smuggle a colour or a URL into the gallery', async () => {++ const bad = { ...ELEMENT, name: 'Prop colour', look: { ...ELEMENT.look, props: { size: 'large', colour: '#ff0000' } } };++ const r = await t.get('/api/v1/gallery/listings', { headers: token(alice), json: { document: bad } });++ assert.strictEqual(r.status, 422, r.text);++ assert.match(r.json().detail, /literal colour/);++ });++++ await check('the price line names the AI an element calls, from the live catalogue', async () => {++ const elementLib = require('../server/create/element');++ const priced = await elementLib.priceOf({ recipe: { actions: [{ kind: 'ai.ask', product: 'ai', prompt: 'Is it good?', model: 'acme/alpha-1' }] } }, ai);++ assert.ok(priced.calls.length === 1);++ assert.strictEqual(priced.calls[0].price_in, 1);++ assert.strictEqual(priced.calls[0].price_out, 2);++ assert.match(priced.text, /\$1\/M in/);++ });++++ await check('Live can hand a validated element to the gallery with the person\'s own token', async () => {++ const doc = { ...ELEMENT, name: 'Live sent button' };++ const r = await t.get('/api/v1/gallery/listings', { headers: token(alice), json: { document: doc, source: 'live', source_id: 'live_1' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().slug, 'live-sent-button');++ assert.strictEqual(r.json().source, 'live');++ const one = await t.get('/api/v1/gallery/listings/live-sent-button');++ assert.strictEqual(one.status, 200, one.text);++ });++++ await check('the official LOOI pack is published by OpenVibe once', async () => {++ const { seedOfficial } = require('../server/elements/official');++ const first = await seedOfficial(t.ctx.gallery, { log() {}, warn() {} });++ const again = await seedOfficial(t.ctx.gallery, { log() {}, warn() {} });++ assert.ok(first && first.id);++ assert.strictEqual(first.official, true);++ assert.strictEqual(first.author.subject, null);++ assert.strictEqual(first.kind, 'pack');++ assert.strictEqual(again.id, first.id);++ const page = await t.get('/gallery/looi-pack');++ assert.strictEqual(page.status, 200, page.text);++ assert.ok(page.text.includes('LOOI pack'));++ assert.ok(page.text.includes('Headlights on'));++ const remix = await t.get('/gallery/looi-pack/remix', { headers: token(bob), body: 'key=' });++ assert.strictEqual(remix.status, 303, remix.text);++ const copySlug = /\/gallery\/([^?]+)/.exec(remix.headers.get('location') || '')?.[1];++ assert.strictEqual(copySlug, 'looi-pack-remix');++ const copy = await t.ctx.gallery.get(copySlug);++ assert.strictEqual(copy.remix_of, first.id, 'a pack remix credits the original');++ assert.strictEqual(copy.kind, 'pack');++ });++ } finally {++ await t.close();++ await live.close();++ }++ return done();++})();
@@ -3,6 +3,7 @@ on:
@@ -3,6 +3,7 @@ on:
push:
push:
branches: [main]
branches: [main]
pull_request:
pull_request:
+ workflow_dispatch:
jobs:
jobs:
# install, node --check and npm test: the shared test job (Track Q).
# install, node --check and npm test: the shared test job (Track Q).
test:
test:
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+-- phase: expand
+-- OpenVibe Elements stage 2 (2026-11-11): Design /create gets the kind Element. A design of this kind stores a
+-- validated ui.element@1 document in each variant instead of an HTML take, and remembers where the picked element
+-- was added: Live's element library and the Exchange gallery. The kind check is widened, never narrowed, and the
+-- two links are JSONB so a failed hand-off leaves its plain reason beside the element.
+
+ALTER TABLE designs DROP CONSTRAINT IF EXISTS designs_kind;
+ALTER TABLE designs ADD CONSTRAINT designs_kind
+ CHECK (kind IN ('page', 'screen', 'component', 'slide', 'email', 'social-card', 'mark', 'element'));
+
+ALTER TABLE designs ADD COLUMN IF NOT EXISTS element_channel jsonb;
+ALTER TABLE designs ADD COLUMN IF NOT EXISTS element_listing jsonb;
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+-- phase: expand
+-- OpenVibe Elements stage 2 (2026-11-11, owner correction: the gallery lives on OpenVibe.Design, Exchange stays
+-- the AI model catalogue): the free "Elements and packs" gallery beside /create. A listing is a validated
+-- ui.element@1 document or a ui.element-pack@1 pack, with the maker, the original it remixes, and its counts.
+-- Installs add the element to the person's Live channel and are one row each; ratings come only from people who
+-- installed it; reports are rows for staff. Nothing here carries a price.
+
+ALTER TABLE designs ADD COLUMN IF NOT EXISTS element_remix_of text;
+
+CREATE TABLE IF NOT EXISTS design_element_listings (
+ id text COLLATE "C" PRIMARY KEY, -- del_ + 26 Crockford base32 characters (ULID)
+ slug text COLLATE "C" NOT NULL UNIQUE,
+ kind text COLLATE "C" NOT NULL, -- element | pack
+ name text NOT NULL,
+ summary text,
+ document jsonb NOT NULL,
+ look jsonb,
+ author_subject text,
+ author_name text NOT NULL,
+ author_username text,
+ source text COLLATE "C" NOT NULL DEFAULT 'design',
+ source_id text,
+ remix_of text COLLATE "C" REFERENCES design_element_listings(id) ON DELETE SET NULL,
+ official boolean NOT NULL DEFAULT false,
+ status text COLLATE "C" NOT NULL DEFAULT 'published',
+ installs integer NOT NULL DEFAULT 0,
+ rating_sum integer NOT NULL DEFAULT 0,
+ rating_count integer NOT NULL DEFAULT 0,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT design_element_listings_kind CHECK (kind IN ('element', 'pack')),
+ CONSTRAINT design_element_listings_source CHECK (source IN ('design', 'gallery', 'remix', 'official', 'live')),
+ CONSTRAINT design_element_listings_status CHECK (status IN ('published', 'hidden')),
+ CONSTRAINT design_element_listings_name CHECK (length(name) BETWEEN 2 AND 120),
+ CONSTRAINT design_element_listings_summary CHECK (summary IS NULL OR length(summary) <= 600),
+ CONSTRAINT design_element_listings_official CHECK (official = false OR author_subject IS NULL)
+);
+
+CREATE INDEX IF NOT EXISTS design_element_listings_published
+ ON design_element_listings (status, created_at DESC) WHERE status = 'published';
+CREATE INDEX IF NOT EXISTS design_element_listings_author
+ ON design_element_listings (author_subject, created_at DESC);
+
+CREATE TABLE IF NOT EXISTS design_element_installs (
+ id text COLLATE "C" PRIMARY KEY,
+ listing_id text COLLATE "C" NOT NULL REFERENCES design_element_listings(id) ON DELETE CASCADE,
+ subject text NOT NULL,
+ channel text,
+ library jsonb, -- what Live answered, or its plain error
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT design_element_installs_once UNIQUE (listing_id, subject)
+);
+CREATE INDEX IF NOT EXISTS design_element_installs_subject ON design_element_installs (subject, created_at DESC);
+
+CREATE TABLE IF NOT EXISTS design_element_ratings (
+ id text COLLATE "C" PRIMARY KEY,
+ listing_id text COLLATE "C" NOT NULL REFERENCES design_element_listings(id) ON DELETE CASCADE,
+ subject text NOT NULL,
+ stars integer NOT NULL,
+ note text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT design_element_ratings_once UNIQUE (listing_id, subject),
+ CONSTRAINT design_element_ratings_stars CHECK (stars BETWEEN 1 AND 5),
+ CONSTRAINT design_element_ratings_note CHECK (note IS NULL OR length(note) <= 400)
+);
+
+CREATE TABLE IF NOT EXISTS design_element_reports (
+ id text COLLATE "C" PRIMARY KEY,
+ listing_id text COLLATE "C" NOT NULL REFERENCES design_element_listings(id) ON DELETE CASCADE,
+ subject text,
+ reason text COLLATE "C" NOT NULL,
+ detail text,
+ status text COLLATE "C" NOT NULL DEFAULT 'pending',
+ reviewer_note text,
+ reviewed_by text,
+ reviewed_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT design_element_reports_reason CHECK (reason IN ('unsafe', 'broken', 'spam', 'other')),
+ CONSTRAINT design_element_reports_status CHECK (status IN ('pending', 'accepted', 'rejected')),
+ CONSTRAINT design_element_reports_detail CHECK (detail IS NULL OR length(detail) <= 1000)
+);
+CREATE INDEX IF NOT EXISTS design_element_reports_pending ON design_element_reports (status, created_at DESC);
@@ -967,3 +967,78 @@ html[data-ov-motion=off] .ar-vote-pair[data-pick] .ar-pickable{transform: none}
@@ -967,3 +967,78 @@ html[data-ov-motion=off] .ar-vote-pair[data-pick] .ar-pickable{transform: none}
.ar-kind-t b { grid-area: b; overflow-wrap: normal; hyphens: manual; }
.ar-kind-t b { grid-area: b; overflow-wrap: normal; hyphens: manual; }
.ar-kind-t small { grid-area: s; }
.ar-kind-t small { grid-area: s; }
}
}
+
+/* Elements (2026-11-11): the look preview, the sample run and the generated sliders. Plain, 390 px first. */
+.dv-frame--element { display: grid; place-items: center; min-height: 220px; background: var(--bg-primary, #0b0f17); }
+.dv-el-look { display: grid; place-items: center; padding: 18px; }
+.dv-elx { display: inline-grid; place-items: center; gap: 4px; min-width: 128px; max-width: 100%; padding: 18px 24px; border-radius: var(--el-radius, 999px); background: var(--el-tint); color: #fff; font-weight: 600; box-sizing: border-box; }
+.dv-elx small { font-weight: 400; opacity: .8; font-size: .72rem; }
+.dv-elx-label { font-size: 1.05rem; overflow-wrap: anywhere; }
+.dv-elx-number { font-size: 1.8rem; }
+.dv-elx-number small { font-size: .8rem; margin-left: 4px; }
+.dv-elx-bar { display: block; width: 120px; height: 10px; border-radius: 99px; background: rgba(255,255,255,.25); overflow: hidden; }
+.dv-elx-bar span { display: block; height: 100%; background: #fff; }
+.dv-elx-switch { display: block; width: 34px; height: 20px; border-radius: 99px; background: rgba(0,0,0,.35); position: relative; }
+.dv-elx-switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: #fff; }
+.dv-elx-slider { display: block; width: 120px; height: 6px; border-radius: 99px; background: rgba(255,255,255,.3); }
+.dv-elx-wheel, .dv-elx-pad { font-size: 1.8rem; }
+.dv-elx-ticker { letter-spacing: .3em; }
+.dv-elx-alert { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #b42318; font-weight: 800; }
+
+.el-page { max-width: 1080px; margin: 0 auto; padding: 16px; }
+.el-head h1 { margin: 8px 0 4px; }
+.el-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap: 20px; margin: 16px 0; align-items: start; }
+.el-look-panel { display: grid; gap: 8px; justify-items: center; border: 1px solid var(--border, #1f2d47); border-radius: 16px; padding: 16px; background: var(--bg-secondary, #101828); }
+.el-look-panel canvas { width: 100%; max-width: 320px; height: auto; border-radius: 12px; border: 1px solid var(--border, #1f2d47); }
+.el-run h2 { margin-top: 0; }
+.el-events { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; max-height: 420px; overflow: auto; }
+.el-event { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; gap: 8px; align-items: start; padding: 8px 10px; border: 1px solid var(--border, #1f2d47); border-radius: 10px; background: var(--bg-secondary, #101828); font-size: .92rem; }
+.el-event-icon { text-align: center; }
+.el-event small { color: var(--text-muted, #7386a3); }
+.el-event--refused { border-color: #6b2b2b; }
+.el-event--refused .el-event-icon { color: #f97066; }
+.el-event--robot { border-color: #2d4d6b; }
+.el-event--ai { border-color: #5b4b6b; }
+.el-price { margin-top: 12px; padding: 12px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-secondary, #101828); }
+.el-tweaks { margin: 16px 0; }
+.el-tweaks fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 12px; display: grid; gap: 10px; }
+.el-tweak { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto; gap: 8px; align-items: center; }
+.el-tweak input[type=range] { width: 100%; min-height: 44px; }
+.el-tweak input[type=text] { min-height: 44px; width: 100%; box-sizing: border-box; }
+.el-tweak--check { display: flex; gap: 8px; align-items: center; }
+.el-handoffs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
+.el-handoffs form { display: inline-block; }
+.el-codes textarea { width: 100%; box-sizing: border-box; font-family: ui-monospace, monospace; font-size: .82rem; }
+@media (max-width: 760px) { .el-grid { grid-template-columns: minmax(0, 1fr); } .el-tweak { grid-template-columns: minmax(0, 1fr); } }
+
+/* Elements and packs gallery (2026-11-11): the list, one listing and the staff queue. Plain, 390 px first. */
+.el-gallery { max-width: 1080px; }
+.el-g-search { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 8px 0 14px; }
+.el-g-search input, .el-g-search select { min-height: 44px; min-width: 0; }
+.el-g-search input { flex: 1 1 220px; }
+.el-g-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 12px; margin: 0; padding: 0; list-style: none; }
+.el-g-card { border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-secondary, #101828); overflow: hidden; }
+.el-g-card a { display: grid; grid-template-rows: auto 1fr; color: inherit; text-decoration: none; height: 100%; }
+.el-g-look { display: grid; place-items: center; min-height: 150px; padding: 12px; background: var(--bg-primary, #0b0f17); }
+.el-g-text { display: grid; gap: 4px; padding: 12px; }
+.el-g-title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
+.el-g-text small { color: var(--text-muted, #7386a3); }
+.el-g-summary { color: var(--text-muted, #7386a3); font-size: .9rem; }
+.el-g-detail { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr); gap: 20px; align-items: start; }
+.el-g-preview { display: grid; place-items: center; min-height: 280px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-primary, #0b0f17); padding: 16px; }
+.el-g-side { min-width: 0; display: grid; gap: 8px; }
+.el-g-side h2 { margin: 14px 0 2px; font-size: 1.05rem; }
+.el-g-side input, .el-g-side select, .el-g-side textarea { width: 100%; min-width: 0; min-height: 44px; box-sizing: border-box; }
+.el-g-side fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 12px; display: grid; gap: 8px; padding: 12px; }
+.el-g-items { margin: 0; padding-left: 18px; }
+.el-g-reports { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
+.el-g-report-row { border: 1px solid var(--border, #1f2d47); border-radius: 12px; padding: 12px; }
+.el-g-report-row form { display: flex; flex-wrap: wrap; gap: 8px; }
+.dv-prose { line-height: 1.5; }
+@media (max-width: 760px) { .el-g-detail { grid-template-columns: minmax(0, 1fr); } .el-g-preview { min-height: 200px; } }
+
+/* An element take is a look, not a document frame: one compact card, and no device/theme toolbar over it. */
+.dv-canvas[data-kind="element"] .dv-rail { justify-content: center; }
+.dv-canvas[data-kind="element"] .dv-card { flex: 0 0 min(100%, 420px); min-width: 0; }
+.dv-canvas[data-kind="element"] .dv-frame--element { height: auto; min-height: 170px; display: grid; place-items: center; }
+.dv-canvas[data-kind="element"] .dv-toolbar { display: none; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+/**
+ * The element preview's robot: OpenVibe.Bot's own arena (public/js/sim-world.js, copied) with the element's robot
+ * actions played on a local rover. It is a picture, not a device: no command is sent anywhere, and the rover only
+ * moves when the element's recipe says a robot action would run.
+ */
+(function () {
+ var canvas = document.getElementById('el-sim');
+ var world = window.OVSimWorld;
+ if (!canvas || !world) return;
+ var ctx = canvas.getContext('2d');
+ var data = {};
+ try { data = JSON.parse(document.getElementById('el-sim-data').textContent || '{}'); } catch (e) { return; }
+ var actions = (data.actions || []).filter(function (a) { return a && a.kind === 'robot'; });
+ var pose = { x: world.START.x, y: world.START.y, heading: world.START.heading };
+ var headlights = false;
+ var label = document.getElementById('el-sim-state');
+ var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ function scale() {
+ var pad = 10;
+ var w = world.COLS * world.CELL;
+ var h = world.ROWS * world.CELL;
+ var s = Math.min((canvas.width - pad * 2) / w, (canvas.height - pad * 2) / h);
+ return { s: s, x: (canvas.width - w * s) / 2, y: (canvas.height - h * s) / 2 };
+ }
+ function draw() {
+ var z = scale();
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ for (var cy = 0; cy < world.ROWS; cy += 1) {
+ for (var cx = 0; cx < world.COLS; cx += 1) {
+ var cell = world.MAP[cy][cx];
+ var floor = world.floorAt(cx * world.CELL + world.CELL / 2, cy * world.CELL + world.CELL / 2);
+ ctx.fillStyle = cell === '#' ? '#2a3446' : cell === 'r' ? '#b06a62' : cell === 'b' ? '#5c7fbf' : cell === 'g' ? '#5f9c78' : cell === 'y' ? '#b3a35c'
+ : floor === 'line' ? '#d9c94e' : floor === 'tile2' ? '#151d2a' : '#101722';
+ ctx.fillRect(z.x + cx * world.CELL * z.s, z.y + cy * world.CELL * z.s, world.CELL * z.s + 1, world.CELL * z.s + 1);
+ }
+ }
+ var px = z.x + pose.x * z.s;
+ var py = z.y + pose.y * z.s;
+ if (headlights) {
+ ctx.beginPath();
+ ctx.fillStyle = 'rgba(255, 226, 130, .28)';
+ ctx.arc(px, py, 24, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.save();
+ ctx.translate(px, py);
+ ctx.rotate((pose.heading * Math.PI) / 180);
+ ctx.beginPath();
+ ctx.fillStyle = '#e7edf7';
+ ctx.moveTo(0, -8);
+ ctx.lineTo(6, 7);
+ ctx.lineTo(-6, 7);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+ }
+
+ function say(text) { if (label) label.textContent = text; }
+
+ function stepFor(action, seconds) {
+ var drive = { throttle: 0, steer: 0 };
+ var direction = action.direction || 'forward';
+ if (direction === 'forward') drive.throttle = 1;
+ if (direction === 'back') drive.throttle = -1;
+ if (direction === 'left') drive.steer = -1;
+ if (direction === 'right') drive.steer = 1;
+ if (direction === 'spin_left') drive.steer = -1;
+ if (direction === 'spin_right') drive.steer = 1;
+ return world.step(pose, drive, seconds);
+ }
+
+ function play(action, done) {
+ var seconds = Math.min(2.4, Math.max(0.4, (Number(action.robot && action.robot.ms) || 1000) / 1000));
+ var left = reduced ? 0 : seconds;
+ var last = Date.now();
+ say(action.text);
+ (function frame() {
+ var now = Date.now();
+ var dt = Math.min(0.05, (now - last) / 1000);
+ last = now;
+ if (left > 0) {
+ var r = stepFor(action.robot || action, dt);
+ pose = r.pose;
+ left -= dt;
+ }
+ draw();
+ if (left > 0) requestAnimationFrame(frame);
+ else setTimeout(done, reduced ? 0 : 500);
+ })();
+ }
+
+ var i = 0;
+ function next() {
+ if (i >= actions.length) { say('Simulated rover, ready.'); return; }
+ var action = actions[i++];
+ if (action.robot && (action.robot.on === true || action.robot.on === false)) {
+ headlights = action.robot.on;
+ say(action.text);
+ draw();
+ setTimeout(next, reduced ? 0 : 700);
+ return;
+ }
+ if (action.robot && action.robot.kind === 'robot.drive_pulse') { play(action, next); return; }
+ say(action.text);
+ draw();
+ setTimeout(next, reduced ? 0 : 700);
+ }
+
+ draw();
+ setTimeout(next, reduced ? 0 : 400);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
+'use strict';
+
+/**
+ * The simulated rover's world (sim.rover), copied from OpenVibe.Bot's public/sim-world.js so Design's element
+ * preview draws the same arena Bot's simulator and /try page use. Bot stays the authority; this copy never talks to
+ * a robot. No DOM, no Node API: it loads as a CommonJS module on the server and as `window.OVSimWorld` in a page.
+ *
+ * Units are metres, seconds and degrees, with +y "up" a top-down view and +x to its right. Heading 0 faces +y and
+ * 90 faces +x, so a positive steer turns right (the simulator's convention since Bot#20): forward at full throttle is
+ * 0.5 m/s and a full steer turns 90 °/s, as before. MAP's text rows run from y = 0 at the top of the text.
+ *
+ * step(pose, drive, dt) → { pose, bumped } one kinematic step with collisions (a bumper stops the move)
+ * distanceCm(pose) → the ultrasonic reading ahead, in cm (≤ 400), from a 12° cone
+ * line(pose) → { left, middle, right }: whether each line sensor is over the tape
+ * floorAt(x, y) → 'line' | 'tile' | 'tile2'
+ * cast(x, y, angle, max) → the cells a ray crosses, nearest first ({ dist, cell, side, u })
+ * solid(cx, cy), cellAt(cx, cy), MAP, CELL, COLS, ROWS, START, ROVER_RADIUS, CAMERA_HEIGHT, heights
+ */
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) module.exports = factory();
+ else root.OVSimWorld = factory();
+}(typeof self !== 'undefined' ? self : this, () => {
+ const CELL = 0.5;
+ // '#' arena wall, 'r' 'b' 'g' 'y' crates, '.' floor. Row r covers y ∈ [r·CELL, (r+1)·CELL); column c covers x.
+ const MAP = [
+ '################',
+ '#..............#',
+ '#..r.......b...#',
+ '#..............#',
+ '#.....##.......#',
+ '#.....##....g..#',
+ '#..............#',
+ '#..y...........#',
+ '#..........b...#',
+ '#......r.......#',
+ '#..............#',
+ '################',
+ ];
+ const COLS = MAP[0].length;
+ const ROWS = MAP.length;
+ const ROVER_RADIUS = 0.14;
+ const CAMERA_HEIGHT = 0.12;
+ const SPEED = 0.5; // m/s at full throttle
+ const TURN = 90; // °/s at full steer
+ /** How tall each solid is, in metres: the arena wall stands above the rover's camera, crates are lower. */
+ const heights = { '#': 0.42, r: 0.26, b: 0.3, g: 0.22, y: 0.26 };
+ // Parked on the tape at the near left corner, facing along it (+y).
+ const START = Object.freeze({ x: 1.0, y: 1.5, heading: 0 });
+
+ // The tape loop: a rectangle with rounded corners, 5 cm wide, around the crates.
+ const LOOP = { x0: 1.0, y0: 0.75, x1: 7.0, y1: 5.25, r: 0.45, half: 0.025 };
+
+ const cellAt = (cx, cy) => (cy < 0 || cy >= ROWS || cx < 0 || cx >= COLS ? '#' : MAP[cy][cx]);
+ const solid = (cx, cy) => cellAt(cx, cy) !== '.';
+ const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
+ const rad = (deg) => (deg * Math.PI) / 180;
+
+ /** Distance from (x, y) to the loop's centre line. */
+ function loopDistance(x, y) {
+ const { x0, y0, x1, y1, r } = LOOP;
+ // Distance to a rounded rectangle's outline: clamp to the inner rectangle, then subtract the radius.
+ const ix0 = x0 + r; const iy0 = y0 + r; const ix1 = x1 - r; const iy1 = y1 - r;
+ const dx = Math.max(ix0 - x, 0, x - ix1);
+ const dy = Math.max(iy0 - y, 0, y - iy1);
+ if (dx > 0 || dy > 0) return Math.abs(Math.hypot(dx, dy) - r);
+ // Inside the inner rectangle: the nearest straight edge.
+ return Math.min(x - ix0, ix1 - x, y - iy0, iy1 - y) + r;
+ }
+ function floorAt(x, y) {
+ if (loopDistance(x, y) <= LOOP.half) return 'line';
+ return (Math.floor(x / CELL) + Math.floor(y / CELL)) % 2 ? 'tile2' : 'tile';
+ }
+
+ /** Does a rover of radius `r` at (x, y) touch a solid cell? */
+ function blocked(x, y, r = ROVER_RADIUS) {
+ for (let cy = Math.floor((y - r) / CELL); cy <= Math.floor((y + r) / CELL); cy++) {
+ for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++) {
+ if (!solid(cx, cy)) continue;
+ const nx = Math.max(cx * CELL, Math.min(x, (cx + 1) * CELL));
+ const ny = Math.max(cy * CELL, Math.min(y, (cy + 1) * CELL));
+ if ((x - nx) ** 2 + (y - ny) ** 2 < r * r) return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * One kinematic step: forward speed from throttle (or y), turn rate from steer (or rotation), sideways from x
+ * (a holonomic drive). Each axis of the move is tried on its own, so the rover slides along a wall; a move a
+ * solid stops is `bumped`.
+ */
+ function step(pose, drive = {}, dt = 0) {
+ const speed = num(drive.throttle != null ? drive.throttle : drive.y);
+ const turn = num(drive.steer != null ? drive.steer : drive.rotation);
+ const strafe = num(drive.x);
+ const t = Math.max(0, Math.min(1, dt));
+ const heading = (((num(pose.heading) + turn * TURN * t) % 360) + 360) % 360;
+ const a = rad(heading);
+ const dx = (speed * Math.sin(a) + strafe * Math.cos(a)) * SPEED * t;
+ const dy = (speed * Math.cos(a) - strafe * Math.sin(a)) * SPEED * t;
+ let x = num(pose.x); let y = num(pose.y);
+ let bumped = false;
+ if (dx) { if (blocked(x + dx, y)) bumped = true; else x += dx; }
+ if (dy) { if (blocked(x, y + dy)) bumped = true; else y += dy; }
+ return { pose: { x, y, heading }, bumped };
+ }
+
+ /**
+ * Every cell a ray from (x, y) at `angle` (degrees, the heading convention) crosses until an arena wall or
+ * `max` metres, nearest first: { dist (along the ray), cell, side ('x' | 'y'), u (0..1 across the face) }.
+ */
+ function cast(x, y, angle, max = 12) {
+ const a = rad(angle);
+ const rx = Math.sin(a); const ry = Math.cos(a);
+ let cx = Math.floor(x / CELL); let cy = Math.floor(y / CELL);
+ const stepX = rx > 0 ? 1 : -1; const stepY = ry > 0 ? 1 : -1;
+ const dX = rx ? Math.abs(CELL / rx) : Infinity; const dY = ry ? Math.abs(CELL / ry) : Infinity;
+ let tX = rx ? ((rx > 0 ? (cx + 1) * CELL - x : x - cx * CELL) / Math.abs(rx)) : Infinity;
+ let tY = ry ? ((ry > 0 ? (cy + 1) * CELL - y : y - cy * CELL) / Math.abs(ry)) : Infinity;
+ const hits = [];
+ for (let i = 0; i < 64; i++) {
+ let dist; let side;
+ if (tX < tY) { dist = tX; tX += dX; cx += stepX; side = 'x'; } else { dist = tY; tY += dY; cy += stepY; side = 'y'; }
+ if (dist > max) break;
+ const cell = cellAt(cx, cy);
+ if (cell === '.') continue;
+ const hx = x + rx * dist; const hy = y + ry * dist;
+ const u = side === 'x' ? (hy / CELL) % 1 : (hx / CELL) % 1;
+ hits.push({ dist, cell, side, u, cx, cy });
+ if (cell === '#') break;
+ }
+ return hits;
+ }
+
+ /** The ultrasonic sensor 0.1 m ahead of the centre: the nearest solid inside a 12° cone, in whole cm (≤ 400). */
+ function distanceCm(pose) {
+ const a = rad(pose.heading);
+ const sx = pose.x + Math.sin(a) * 0.1; const sy = pose.y + Math.cos(a) * 0.1;
+ let best = 4;
+ for (const off of [-6, -3, 0, 3, 6]) {
+ const h = cast(sx, sy, pose.heading + off, 4)[0];
+ if (h && h.dist < best) best = h.dist;
+ }
+ return Math.round(best * 100);
+ }
+
+ /** Three line sensors 0.12 m ahead, 4 cm apart: whether each is over the tape. */
+ function line(pose) {
+ const a = rad(pose.heading);
+ const fx = pose.x + Math.sin(a) * 0.12; const fy = pose.y + Math.cos(a) * 0.12;
+ const at = (side) => floorAt(fx + Math.cos(a) * side, fy - Math.sin(a) * side) === 'line';
+ return { left: at(-0.04), middle: at(0), right: at(0.04) };
+ }
+
+ return { MAP, CELL, COLS, ROWS, START, ROVER_RADIUS, CAMERA_HEIGHT, SPEED, TURN, LOOP, heights, cellAt, solid, blocked, floorAt, step, cast, distanceCm, line };
+}));
@@ -40,6 +40,8 @@ const { createMcpRoutes } = require('./http/mcp');
@@ -40,6 +40,8 @@ const { createMcpRoutes } = require('./http/mcp');
const { createArenaApi } = require('./http/arena-api');
const { createArenaApi } = require('./http/arena-api');
const { createDesignApi } = require('./http/design-api');
const { createDesignApi } = require('./http/design-api');
const { createDesigns } = require('./designs');
const { createDesigns } = require('./designs');
+const { createElementHandoff } = require('./elements/handoff');
+const { createGallery } = require('./elements/gallery');
const { createDeepDiveAi } = require('./deepdive/ai');
const { createDeepDiveAi } = require('./deepdive/ai');
const { createDeepDiveStore } = require('./deepdive/store');
const { createDeepDiveStore } = require('./deepdive/store');
const { createDeepDiveService } = require('./deepdive/service');
const { createDeepDiveService } = require('./deepdive/service');
@@ -122,6 +124,10 @@ async function createApp(opts = {}) {
@@ -122,6 +124,10 @@ async function createApp(opts = {}) {
const createOptions = opts.createOptions || createCreateOptions({ config, ai, catalog, fetchImpl, log, now });
const createOptions = opts.createOptions || createCreateOptions({ config, ai, catalog, fetchImpl, log, now });
// Publishing a design on openvibe.space (open item D8).
// Publishing a design on openvibe.space (open item D8).
const publisher = opts.publisher || require('./publish').createPublisher({ config, fetchImpl, log });
const publisher = opts.publisher || require('./publish').createPublisher({ config, fetchImpl, log });
+ // Elements stage 2: add a made element to Live as the person; the gallery itself is here on Design.
+ const elementHandoff = opts.elementHandoff || createElementHandoff({ config, fetchImpl, log });
+ // Elements and packs: the free gallery beside /create (owner correction: it lives here, not on Exchange).
+ const gallery = opts.gallery || createGallery({ s, now, log });
const accountData = accountDataLib.create({ db: s.db, log });
const accountData = accountDataLib.create({ db: s.db, log });
const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });
const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });
const fonts = opts.fonts || createFontCatalog({ db: s.db, fetchImpl, now, log });
const fonts = opts.fonts || createFontCatalog({ db: s.db, fetchImpl, now, log });
@@ -135,7 +141,7 @@ async function createApp(opts = {}) {
@@ -135,7 +141,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
const ctx = {
- config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts, aiStore, maker, registry, createOptions, balance, deepDive, deepDiveStore, deepDiveAi, proposals, rulePr,
+ config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, elementHandoff, gallery, log, picks, fonts, aiStore, maker, registry, createOptions, balance, deepDive, deepDiveStore, deepDiveAi, proposals, rulePr,
random: opts.random || Math.random,
random: opts.random || Math.random,
};
};
@@ -307,6 +313,7 @@ async function createApp(opts = {}) {
@@ -307,6 +313,7 @@ async function createApp(opts = {}) {
}
}
// ── API: the Arena, the glyphs (with their pages), then the theme proxy ──
// ── API: the Arena, the glyphs (with their pages), then the theme proxy ──
app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
+ app.use('/api/v1/gallery', require('./http/gallery-api').createGalleryApi(ctx));
app.use('/api', express.json({ limit: '64kb' }));
app.use('/api', express.json({ limit: '64kb' }));
app.use('/api', createDeepDiveApi(ctx));
app.use('/api', createDeepDiveApi(ctx));
app.use('/api/create', createCreateApi(ctx));
app.use('/api/create', createCreateApi(ctx));
@@ -156,6 +156,14 @@ function load(env = process.env) {
@@ -156,6 +156,14 @@ function load(env = process.env) {
chatEditsPerDesign: Math.max(1, int(env.DESIGN_CHAT_EDITS_PER_DESIGN, 5)),
chatEditsPerDesign: Math.max(1, int(env.DESIGN_CHAT_EDITS_PER_DESIGN, 5)),
},
},
+ // OpenVibe.Live's element library (OpenVibe Elements stage 2): the person's own token adds the element
+ // to their channel; Design never holds a Live token of its own for this.
+ live: {
+ url: trim(env.OV_LIVE_URL || 'https://openvibe.live'),
+ elementsPath: env.OV_LIVE_ELEMENTS_PATH || '/api/me/elements',
+ timeoutMs: Math.max(1000, int(env.DESIGN_LIVE_TIMEOUT_MS, 15_000)),
+ },
+
// The typefaces and vectors a person makes with the model (plan R1 item 8): how many model runs
// The typefaces and vectors a person makes with the model (plan R1 item 8): how many model runs
// one person may make a day. A typeface is several runs (the letters are drawn a batch at a time),
// one person may make a day. A typeface is several runs (the letters are drawn a batch at a time),
// so the cap counts runs, not pages.
// so the cap counts runs, not pages.
new file mode 100644
new file mode 100644
@@ -0,0 +1,484 @@
@@ -0,0 +1,484 @@
+'use strict';
+
+/**
+ * Elements in Design /create (OpenVibe Elements stage 2, 2026-11-11).
+ *
+ * An element is one ui.element@1 document: a look drawn from Design's control vocabulary and the theme's
+ * tokens, a recipe from the fixed trigger and action catalogs, and the capabilities its actions need. The AI
+ * writes the look and the recipe together; the SDK validates the document, an invalid take is asked once more
+ * with the reasons, and a second refusal is said plainly.
+ *
+ * The preview never calls a robot, Live, Chat or AI's models. It runs the recipe against sample data (people of
+ * each role pressing, a fake chat, and the robot actions drawn in OpenVibe.Bot's own arena) and lists what
+ * would happen and what was refused, with the reason.
+ */
+
+const { vocabulary: designVocabulary } = require('../components/registry');
+
+// The control components an element's look may name. Each is data: the component page draws it, the contract
+// only checks the shape. `props` are the values the generated sliders move.
+const COMPONENTS = Object.freeze([
+ { name: 'button', title: 'Button', summary: 'One press action with a label and an icon.', props: { size: ['small', 'medium', 'large'] } },
+ { name: 'toggle', title: 'Toggle', summary: 'A control that is on or off.', props: { on: [true, false] } },
+ { name: 'slider', title: 'Slider', summary: 'A value between a minimum and a maximum.', props: { min: 0, max: 100, step: 1 } },
+ { name: 'meter', title: 'Meter', summary: 'A readout of a value in a range: battery, hype, latency.', props: { value: 0, max: 100 } },
+ { name: 'counter', title: 'Counter', summary: 'A count that goes up and down.', props: { start: 0 } },
+ { name: 'readout', title: 'Readout', summary: 'One number with its unit.', props: { unit: '' } },
+ { name: 'wheel', title: 'Wheel', summary: 'A spinning list whose result is the winner.', props: {} },
+ { name: 'sound-pad', title: 'Sound pad', summary: 'A pad that plays one of the streamer\'s sounds.', props: {} },
+ { name: 'ticker', title: 'Ticker', summary: 'A scrolling line of text.', props: {} },
+ { name: 'alert', title: 'Alert', summary: 'A short call-out on the stream.', props: {} },
+]);
+const COMPONENT_NAMES = COMPONENTS.map((c) => c.name);
+const COMPONENT = new Map(COMPONENTS.map((c) => [c.name, c]));
+
+const TRIGGERS = {
+ press: 'The element itself was pressed.',
+ chat_command: 'A chat command, e.g. !dance.',
+ chat_text: 'Chat says one of the words.',
+ hold: 'The element is held down.',
+ double_press: 'The element is pressed twice.',
+ key: 'A key is pressed.',
+ gamepad: 'A gamepad button is pressed.',
+ follow: 'A new follower.',
+ tip: 'A tip.',
+ ai_moment: 'An AI Moment.',
+ rundown_topic: 'A Rundown topic.',
+ timer: 'A timer.',
+ schedule: 'A schedule.',
+ robot_telemetry: 'A robot sensor crosses a value.',
+ robot_event: 'A robot event.',
+ stream_start: 'The stream starts.',
+ stream_end: 'The stream ends.',
+ viewer_count: 'The viewer count crosses a number.',
+ element_event: 'Another element fires.',
+};
+
+const ACTIONS = {
+ 'robot.drive_pulse': 'Drive the robot for a moment, then stop (direction, ms ≤ 3000).',
+ 'robot.head_preset': 'Take the robot\'s head to a preset (preset).',
+ 'robot.headlight': 'Turn the robot\'s headlight on or off (on, brightness, colour).',
+ 'robot.say': 'Make the robot say a line (text, voice).',
+ 'robot.macro': 'Replay a recorded robot macro (stage 4: the preview says it is not supported yet).',
+ 'chat.message': 'Send a chat message as the channel bot (text).',
+ 'chat.pin': 'Pin a chat message (source: trigger | latest).',
+ 'chat.poll': 'Start a poll (question, options).',
+ 'screen.alert': 'Show an alert on stream (title, text).',
+ 'screen.animation': 'Play an animation on screen (name).',
+ 'screen.confetti': 'Throw confetti (intensity).',
+ 'screen.sound': 'Play one of the streamer\'s sounds (sound, volume).',
+ 'screen.tts': 'Speak a line on stream (text, voice).',
+ 'screen.counter': 'Move one of the element\'s counters (key, by).',
+ 'screen.state': 'Set one of the element\'s state values (key, value).',
+ 'screen.visibility': 'Show or hide another element (element, visible).',
+ 'screen.scene': 'Switch the layout to a scene (scene).',
+ 'stream.clip': 'Clip the last seconds (seconds ≤ 30).',
+ 'stream.moment': 'Mark a moment (label).',
+ 'stream.title': 'Change the stream title (owner only).',
+ 'stream.category': 'Change the stream category (owner only).',
+ 'ai.ask': 'Ask an AI model through Exchange (prompt, model, max_cost_usd). THE PREVIEW NEVER RUNS IT.',
+ 'actor.task': 'Hand a task to an Actor agent (task).',
+ 'webhook.call': 'Call one of the streamer\'s own endpoints by name (endpoint).',
+ 'money.tip_goal': 'Update a tip goal\'s display; no money moves without Billing (goal).',
+};
+const PRODUCT = {
+ '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', 'ai.ask': 'ai', 'actor.task': 'actor', 'webhook.call': 'live', 'money.tip_goal': 'billing',
+};
+
+const fail = (message, code = 'element.invalid') => Object.assign(new Error(message), { status: 422, code });
+const textOf = (v) => (typeof v === 'string' ? v.trim() : '');
+
+/** The theme's token names, without the leading `--`: tint is a name the theme turns into a colour. */
+function themeTokens(variables = {}) {
+ return Object.keys(variables)
+ .filter((name) => /^--[a-z][a-z0-9-]{1,40}$/.test(name))
+ .map((name) => name.slice(2));
+}
+
+/** The system prompt for an element: JSON only, the catalogs, the theme's token names and the control set. */
+function elementSystemPrompt() {
+ return [
+ 'You are OpenVibe Elements\' designer. You write ONE ui.element@1 element as JSON: a small control for a stream, with a look and a recipe.',
+ 'Return only a JSON object. No prose, no code fences, no HTML.',
+ 'The object is: {name, description, look, recipe, capabilities, initial_state?}. The author and sharing are filled by OpenVibe.',
+ 'look is {component, label, icon?, props?, style?}. component is one of: ' + COMPONENT_NAMES.join(', ') + '.',
+ 'label is 1–40 characters, the words on the control. icon is a lower-case glyph name.',
+ 'props holds the component\'s own values (text, number or true/false only). style is {tint, shape, opacity, show_label}:',
+ 'tint is a theme token NAME without -- (for example accent or bg-card), never a colour; shape is round, pill or square; opacity 0.4–1.',
+ 'recipe is {triggers, conditions?, actions}. triggers use the kinds and fields below; actions use the kinds and fields below.',
+ 'Every action must appear in capabilities as its kind. A recipe uses only the catalog: never invent a trigger or action.',
+ 'Use this catalog:',
+ Object.entries(TRIGGERS).map(([k, v]) => `- trigger ${k}: ${v}`).join('\n'),
+ Object.entries(ACTIONS).map(([k, v]) => `- action ${k} (product ${PRODUCT[k]}): ${v}`).join('\n'),
+ 'conditions: {role, cost:{points}, cooldown:{per_person_seconds, overall_seconds}, daily_cap:{per_person, overall}, live_only, robot_turn_only, chance:{one_in}}.',
+ 'role is everyone, follower, subscriber, vip, operator or owner. Money never moves: only channel points.',
+ 'The element carries no URL, no HTML and no script. It never adds a power: the product still checks the presser and the limits.',
+ 'The person\'s words decide the recipe. "once a minute" is cooldown overall_seconds 60; "costs 100 points" is cost.points 100; "the robot spins" is robot.drive_pulse with direction spin_left or spin_right.',
+ 'For AI, use ai.ask with a short prompt and, when known, a model and a max_cost_usd; the price is looked up live.',
+ 'If the person asks for something the catalog cannot do, choose the closest real action and say what it does in the description.',
+ 'OpenVibe design vocabulary (for the look\'s wording, not component names):',
+ designVocabulary().slice(0, 4000),
+ ].join('\n');
+}
+
+/** The person's words, the theme and the frame, as one user message. */
+function elementPrompt({ prompt, variables = {}, component = null }) {
+ const tokens = themeTokens(variables);
+ return [
+ `Make this element: ${String(prompt || '').trim()}`,
+ component ? `Use the component "${component}".` : 'Choose the component that fits the job.',
+ tokens.length ? `The theme's token names: ${tokens.join(', ')}. Use ONLY these as the look's tint.` : 'Use tint accent.',
+ 'Return the JSON object only.',
+ ].join('\n');
+}
+
+/** The first JSON object in a model's answer: fences and a sentence before it are allowed; nothing else is. */
+function parseElement(answer) {
+ let text = String(answer || '').trim().replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
+ if (!text) throw fail('The AI returned nothing.');
+ const start = text.indexOf('{');
+ const end = text.lastIndexOf('}');
+ if (start < 0 || end <= start) throw fail('The AI did not return a JSON object.');
+ let doc;
+ try { doc = JSON.parse(text.slice(start, end + 1)); } catch (err) { throw fail(`The AI's JSON could not be read: ${String(err.message).slice(0, 120)}.`); }
+ if (doc && doc.element && typeof doc.element === 'object' && !Array.isArray(doc.element)) doc = doc.element;
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw fail('The AI did not return one element object.');
+ return doc;
+}
+
+let sdk = null;
+try { sdk = require('openvibe-sdk/elements'); } catch { /* the SDK release that carries elements is not pinned yet */ }
+const contracts = require('openvibe-contracts');
+const HAS_CONTRACT = (() => { try { return Boolean(contracts.schema('ui.element@1')); } catch { return false; } })();
+
+const reasonsOf = (r) => {
+ if (!r) return ['the validator gave no reason'];
+ if (Array.isArray(r)) return r.map(String);
+ if (Array.isArray(r.reasons)) return r.reasons.map(String);
+ if (Array.isArray(r.errors)) return r.errors.map((e) => (e && (e.message || e.detail)) || String(e));
+ if (r.message) return [String(r.message)];
+ return ['the validator gave no reason'];
+};
+
+/**
+ * Validate one element. The SDK's `elements.validate` is the authority when this Design's pinned SDK has it; the
+ * contracts schema is next; the local shape check keeps a take from being accepted before either is released.
+ * The Design-only rules (a component in the control set, a tint that is a theme token) are added on top.
+ */
+function validateElement(doc) {
+ const reasons = [];
+ if (sdk && typeof sdk.validate === 'function') {
+ let out;
+ try { out = sdk.validate(doc); } catch (err) { out = { valid: false, errors: [{ message: err.message }] }; }
+ if (!(out === true || (out && (out.ok === true || out.valid === true)))) reasons.push(...reasonsOf(out));
+ } else if (HAS_CONTRACT) {
+ const out = contracts.validate('ui.element@1', doc);
+ if (!out.valid) reasons.push(...(out.errors || []).map((e) => `${e.path === '/' ? '' : `${e.path} `}${String(e.message).replace(/^must /, '')}`));
+ } else {
+ reasons.push(...localReasons(doc));
+ }
+ const look = doc && doc.look;
+ if (look && !COMPONENT.has(look.component)) reasons.push(`look.component must be one of ${COMPONENT_NAMES.join(', ')}.`);
+ if (look && look.style && look.style.tint && !/^[a-z][a-z0-9-]{0,39}$/.test(look.style.tint)) reasons.push('look.style.tint must be a theme token name, never a colour.');
+ return { ok: reasons.length === 0, reasons: [...new Set(reasons)].slice(0, 8) };
+}
+
+/** The smallest shape check, used only before the SDK or the contracts carry the schema. */
+function localReasons(doc) {
+ const out = [];
+ if (!doc || typeof doc !== 'object') return ['the answer is not an object'];
+ for (const key of ['name', 'author', 'sharing', 'look', 'recipe', 'capabilities']) if (doc[key] == null) out.push(`${key} is required`);
+ if (doc.look && typeof doc.look !== 'object') out.push('look must be an object');
+ if (doc.recipe) {
+ if (!Array.isArray(doc.recipe.triggers) || !doc.recipe.triggers.length) out.push('recipe.triggers needs at least one trigger');
+ if (!Array.isArray(doc.recipe.actions) || !doc.recipe.actions.length) out.push('recipe.actions needs at least one action');
+ const actions = Array.isArray(doc.recipe.actions) ? doc.recipe.actions : [];
+ const declared = new Set(Array.isArray(doc.capabilities) ? doc.capabilities : []);
+ for (const a of actions) {
+ if (!a || !ACTIONS[a.kind]) out.push(`action ${a && a.kind} is not in the catalog`);
+ else if (a.product !== PRODUCT[a.kind]) out.push(`${a.kind} is authorised by ${PRODUCT[a.kind]}`);
+ else if (!declared.has(a.kind)) out.push(`${a.kind} is not in capabilities`);
+ }
+ for (const t of Array.isArray(doc.recipe.triggers) ? doc.recipe.triggers : []) if (!t || !TRIGGERS[t.kind]) out.push(`trigger ${t && t.kind} is not in the catalog`);
+ }
+ return out;
+}
+
+/** Add what Design owns (the author and the private sharing) and keep the model's words. */
+function prepareElement(doc, { authorName = 'A maker' } = {}) {
+ const out = JSON.parse(JSON.stringify(doc));
+ delete out.element;
+ out.author = { name: authorName };
+ out.sharing = 'private';
+ if (!out.description) out.description = '';
+ return out;
+}
+
+/** The tweak sliders an element gets: size, tint and label, drawn from its own look and the theme. */
+function tweaksFor(doc, variables = {}) {
+ const tokens = themeTokens(variables);
+ const look = doc && doc.look ? doc.look : {};
+ const props = look.props || {};
+ const sizes = (COMPONENT.get(look.component) || {}).props && COMPONENT.get(look.component).props.size;
+ const tweaks = [];
+ if (Array.isArray(sizes) && sizes.length) {
+ const at = Math.max(0, sizes.indexOf(props.size));
+ tweaks.push({ name: 'size', label: 'Size', kind: 'range', min: 0, max: sizes.length - 1, step: 1, value: at, options: sizes });
+ }
+ if (tokens.length) {
+ const current = (look.style && look.style.tint) || 'accent';
+ const choices = (tokens.includes(current) ? tokens : [current, ...tokens]).slice(0, 24);
+ tweaks.push({ name: 'tint', label: 'Tint', kind: 'range', min: 0, max: choices.length - 1, step: 1, value: Math.max(0, choices.indexOf(current)), options: choices });
+ }
+ tweaks.push({ name: 'label', label: 'Label', kind: 'text', value: look.label || '', maxlength: 40 });
+ tweaks.push({ name: 'show_label', label: 'Show label', kind: 'checkbox', value: !(look.style && look.style.show_label === false) });
+ return tweaks;
+}
+
+/** Apply the sliders' values to a copy of the element. Values are indices or text, never a colour or markup. */
+function applyTweaks(doc, values = {}, variables = {}) {
+ const out = JSON.parse(JSON.stringify(doc));
+ out.look = out.look && typeof out.look === 'object' ? out.look : {};
+ const look = out.look;
+ look.props = look.props && typeof look.props === 'object' ? look.props : {};
+ look.style = look.style && typeof look.style === 'object' ? look.style : {};
+ const sizes = (COMPONENT.get(look.component) || {}).props && COMPONENT.get(look.component).props.size;
+ if (values.size != null && Array.isArray(sizes)) {
+ const at = Number(values.size);
+ if (!Number.isInteger(at) || at < 0 || at >= sizes.length) throw fail('Choose a size from the list.');
+ look.props.size = sizes[at];
+ }
+ const tokens = themeTokens(variables);
+ if (values.tint != null && tokens.length) {
+ const current = look.style.tint || 'accent';
+ const choices = tokens.includes(current) ? tokens : [current, ...tokens];
+ const at = Number(values.tint);
+ if (!Number.isInteger(at) || at < 0 || at >= Math.min(24, choices.length)) throw fail('Choose a tint from the theme.');
+ look.style.tint = choices[at];
+ }
+ if (values.label != null) {
+ const label = textOf(values.label);
+ if (!label || label.length > 40) throw fail('The label must be 1 to 40 characters.');
+ if (/https?:\/\//i.test(label) || /<[a-z!/]/i.test(label)) throw fail('The label cannot carry a link or markup.');
+ look.label = label;
+ }
+ if (values.show_label != null) look.style.show_label = values.show_label === true || values.show_label === 'on' || values.show_label === '1';
+ return out;
+}
+
+/** The look as plain markup on the page's tokens. The real component is drawn on the live surface. */
+function renderLook(doc) {
+ const look = (doc && doc.look) || {};
+ const style = look.style || {};
+ const tint = `var(--${String(style.tint || 'accent').replace(/[^a-z0-9-]/g, '')}, var(--accent))`;
+ const shape = style.shape === 'round' ? '50%' : style.shape === 'square' ? '8px' : '999px';
+ const opacity = style.opacity != null ? Math.max(0.4, Math.min(1, Number(style.opacity))) : 1;
+ const label = style.show_label === false ? '' : String(look.label || '');
+ const component = String(look.component || 'button');
+ const props = look.props || {};
+ let inner = '';
+ switch (component) {
+ case 'meter': {
+ const max = Math.max(1, Number(props.max) || 100);
+ const pct = Math.max(0, Math.min(100, Math.round((Number(props.value) || 0) / max * 100)));
+ inner = `<span class="dv-elx-bar"><span style="width:${pct}%"></span></span>`;
+ break;
+ }
+ case 'counter': inner = `<span class="dv-elx-number">${Number(props.start) || 0}</span>`; break;
+ case 'readout': inner = `<span class="dv-elx-number">0<small>${String(props.unit || '')}</small></span>`; break;
+ case 'toggle': inner = `<span class="dv-elx-switch" aria-hidden="true"></span>`; break;
+ case 'slider': inner = `<span class="dv-elx-slider" aria-hidden="true"></span>`; break;
+ case 'wheel': inner = '<span class="dv-elx-wheel" aria-hidden="true">✦</span>'; break;
+ case 'ticker': inner = '<span class="dv-elx-ticker" aria-hidden="true">· · ·</span>'; break;
+ case 'alert': inner = '<span class="dv-elx-alert" aria-hidden="true">!</span>'; break;
+ case 'sound-pad': inner = '<span class="dv-elx-pad" aria-hidden="true">♪</span>'; break;
+ default: inner = '';
+ }
+ return `<span class="dv-elx" data-component="${component}" style="--el-tint:${tint};--el-radius:${shape};opacity:${opacity}">${inner}${label ? `<span class="dv-elx-label">${esc(label)}</span>` : ''}<small>${component}</small></span>`;
+}
+
+function esc(value) {
+ return String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+}
+
+// ── The sample run (never a robot, Live, Chat or AI) ──────────────────────
+const ROLE_RANK = { everyone: 0, follower: 1, subscriber: 2, vip: 3, operator: 4, owner: 5 };
+const SAMPLE_PEOPLE = [
+ { name: 'Ada', role: 'everyone', points: 250 },
+ { name: 'Ben', role: 'follower', points: 250 },
+ { name: 'Cy', role: 'subscriber', points: 250 },
+ { name: 'Dee', role: 'vip', points: 250 },
+ { name: 'Eve', role: 'operator', points: 250 },
+ { name: 'The owner', role: 'owner', points: 250 },
+];
+
+const effectOf = (action) => {
+ switch (action.kind) {
+ case 'robot.drive_pulse': return { kind: 'robot', text: `The robot drives ${String(action.direction || 'forward').replace(/_/g, ' ')} for ${(Number(action.ms) || 0) / 1000}s (simulated).`, robot: action };
+ case 'robot.head_preset': return { kind: 'robot', text: `The robot's head goes to "${action.preset}" (simulated).` };
+ case 'robot.headlight': return { kind: 'robot', text: `The robot's headlight turns ${action.on ? 'on' : 'off'} (simulated).`, headlight: Boolean(action.on) };
+ case 'robot.say': return { kind: 'robot', text: `The robot says: "${action.text}" (simulated).`, say: action.text };
+ case 'robot.macro': return { kind: 'robot', text: `The macro "${action.macro}" is not supported yet; the preview leaves it for stage 4.`, unsupported: true };
+ case 'chat.message': return { kind: 'chat', text: `Chat, as the channel bot: "${action.text}".` };
+ case 'chat.pin': return { kind: 'chat', text: `Pins the ${action.source === 'latest' ? 'newest message' : 'message that fired it'}.` };
+ case 'chat.poll': return { kind: 'chat', text: `Starts a poll: "${action.question}" (${(action.options || []).join(' / ')}).` };
+ case 'screen.alert': return { kind: 'screen', text: `Alert on stream: "${action.title}"${action.text ? ` — ${action.text}` : ''}.` };
+ case 'screen.animation': return { kind: 'screen', text: `Plays the animation "${action.name}".` };
+ case 'screen.confetti': return { kind: 'screen', text: `Throws confetti (${action.intensity || 'medium'}).` };
+ case 'screen.sound': return { kind: 'screen', text: `Plays the sound "${action.sound}".` };
+ case 'screen.tts': return { kind: 'screen', text: `Speaks on stream: "${action.text}".` };
+ case 'screen.counter': return { kind: 'state', text: `Counter ${action.key} moves by ${action.by}.`, state: { key: action.key, by: action.by } };
+ case 'screen.state': return { kind: 'state', text: `State ${action.key} becomes ${JSON.stringify(action.value)}.`, state: { key: action.key, value: action.value } };
+ case 'screen.visibility': return { kind: 'screen', text: `${action.visible ? 'Shows' : 'Hides'} element ${action.element}.` };
+ case 'screen.scene': return { kind: 'screen', text: `Switches to the scene "${action.scene}".` };
+ case 'stream.clip': return { kind: 'stream', text: `Clips the last ${action.seconds || 30} seconds.` };
+ case 'stream.moment': return { kind: 'stream', text: `Marks the moment${action.label ? ` "${action.label}"` : ''}.` };
+ case 'stream.title': return { kind: 'stream', text: `Changes the stream title (owner only).` };
+ case 'stream.category': return { kind: 'stream', text: `Changes the stream category (owner only).` };
+ case 'ai.ask': return { kind: 'ai', text: `Would ask ${action.model || 'the routed model'} on Exchange: "${action.prompt}". The preview never runs it.`, ai: action };
+ case 'actor.task': return { kind: 'ai', text: 'Would hand a task to an Actor agent. The preview never runs it.' };
+ case 'webhook.call': return { kind: 'stream', text: `Would call the streamer's endpoint "${action.endpoint}". The preview never sends it.` };
+ case 'money.tip_goal': return { kind: 'money', text: `Updates the tip goal "${action.goal}"'s display; no money moves.` };
+ default: return { kind: 'unknown', text: `The action ${action.kind} is not one the preview knows.` };
+ }
+};
+
+/**
+ * Run one element on sample data. Every person of each role presses once, a fake chat says the command (when the
+ * trigger is a chat command) and the role, cooldown, points, chance and daily-cap conditions are applied by the
+ * same rules the host will. The robot actions are only drawn, never sent.
+ */
+function simulateElement(doc, { price = null } = {}) {
+ const recipe = (doc && doc.recipe) || {};
+ const conditions = recipe.conditions || {};
+ const role = conditions.role || 'everyone';
+ const required = ROLE_RANK[role] == null ? 0 : ROLE_RANK[role];
+ const points = conditions.cost && Number(conditions.cost.points) > 0 ? Number(conditions.cost.points) : 0;
+ const cool = conditions.cooldown || {};
+ const overall = Number(cool.overall_seconds) || 0;
+ const perPerson = Number(cool.per_person_seconds) || 0;
+ const oneIn = conditions.chance && Number(conditions.chance.one_in) > 1 ? Number(conditions.chance.one_in) : 0;
+ const events = [];
+ let at = 0;
+ let lastBy = null;
+ let lastOverall = null;
+ let state = { ...(doc.initial_state || {}) };
+ let headlights = false;
+ let robot = null;
+ const push = (kind, text, extra = {}) => { at += 1; events.push({ at, kind, text, ...extra }); };
+
+ const allowed = (person) => {
+ if (ROLE_RANK[person.role] < required) {
+ push('refused', `${person.name} (${person.role}) cannot: this element is for ${role}s.`);
+ return false;
+ }
+ if (lastOverall != null && overall && at - lastOverall < overall) {
+ push('refused', `${person.name} is refused: the overall cooldown is ${overall}s and only ${at - lastOverall}s have passed.`);
+ return false;
+ }
+ if (lastBy === person.name && perPerson) {
+ push('refused', `${person.name} is refused: their own cooldown is ${perPerson}s.`);
+ return false;
+ }
+ if (points && person.points < points) {
+ push('refused', `${person.name} cannot afford ${points} points.`);
+ return false;
+ }
+ if (oneIn) {
+ // A real host draws the lot; the sample run is deterministic and says which side it landed on.
+ push('chance', `A 1-in-${oneIn} draw: the sample lands on "go" (a real run may refuse and cost nothing).`);
+ }
+ return true;
+ };
+
+ const fire = (person, trigger) => {
+ if (!allowed(person)) return;
+ lastBy = person.name;
+ lastOverall = at;
+ if (points) push('cost', `${person.name} spends ${points} channel points (${person.points} → ${person.points - points}).`, { person: person.name });
+ for (const action of recipe.actions || []) {
+ const effect = effectOf(action);
+ if (effect.state) {
+ if (effect.state.by != null) state[effect.state.key] = (Number(state[effect.state.key]) || 0) + effect.state.by;
+ else state[effect.state.key] = effect.state.value;
+ }
+ if (effect.headlight != null) headlights = effect.headlight;
+ if (effect.robot && action.kind === 'robot.drive_pulse') robot = { direction: action.direction, ms: Number(action.ms) || 0 };
+ push(effect.kind, effect.text, { person: person.name, trigger, ...(effect.robot ? { robot: effect.robot } : {}), ...(effect.headlight != null ? { headlight: effect.headlight } : {}), ...(effect.say ? { say: effect.say } : {}), ...(effect.unsupported ? { unsupported: true } : {}) });
+ }
+ };
+
+ const command = (recipe.triggers || []).find((t) => t.kind === 'chat_command');
+ if (command) {
+ const lines = [`${command.command}`, 'hello!', `${command.command}`];
+ for (const [i, line] of lines.entries()) {
+ at += 5;
+ const person = SAMPLE_PEOPLE[(i + 2) % SAMPLE_PEOPLE.length];
+ events.push({ at, kind: 'chat', person: person.name, text: `Chat: ${person.name}: ${line}` });
+ if (line === command.command) fire(person, 'chat_command');
+ }
+ } else {
+ for (const person of SAMPLE_PEOPLE) {
+ at += 2;
+ events.push({ at, kind: 'press', person: person.name, text: `${person.name} (${person.role}) presses.` });
+ fire(person, (recipe.triggers || [])[0] ? recipe.triggers[0].kind : 'press');
+ }
+ }
+
+ return {
+ role, points, cooldown: { per_person: perPerson, overall }, chance: oneIn,
+ people: SAMPLE_PEOPLE.map((p) => ({ name: p.name, role: p.role })),
+ events, state, headlights, robot,
+ price,
+ note: 'Sample data only: no robot, no Live channel, no chat and no AI call. The robot picture is drawn in Bot\'s simulated arena.',
+ };
+}
+
+/** The price line for an element: the AI it would call, from Exchange's live catalogue, and its own budget cap. */
+function aiCallsOf(doc) {
+ return ((doc && doc.recipe && doc.recipe.actions) || []).filter((a) => a && a.kind === 'ai.ask');
+}
+
+/**
+ * Price the element's AI calls from the live catalogue. Returns one row per action: the model, its in/out prices
+ * per million tokens, and the action's own cap. Nothing here is estimated beyond the catalogue's own numbers.
+ */
+async function priceOf(elementDoc, ai) {
+ const calls = aiCallsOf(elementDoc);
+ if (!calls.length) return { calls: [], text: 'No AI it calls: it runs from the recipe alone.' };
+ let models = [];
+ try { models = (await ai.exchangeCatalog({ kind: 'chat' })).models || []; } catch { /* the page says unavailable */ }
+ const rows = calls.map((call) => {
+ const wanted = String(call.model || '');
+ const found = models.find((m) => m.slug === wanted || m.id === wanted || m.canonical === wanted)
+ || models.find((m) => wanted && String(m.name || '').toLowerCase().includes(wanted.toLowerCase()))
+ || models.find((m) => (m.offers || []).some((o) => o.price));
+ const offers = (found && found.offers) || [];
+ const priced = offers.filter((o) => o.price && Number.isFinite(Number(o.price.in)) && Number.isFinite(Number(o.price.out)));
+ priced.sort((a, b) => (Number(a.price.in) + Number(a.price.out)) - (Number(b.price.in) + Number(b.price.out)));
+ const offer = priced[0] || offers[0] || null;
+ return {
+ prompt: call.prompt, model: found ? (found.name || found.slug) : (wanted || 'Router choice'),
+ price_in: offer && offer.price ? Number(offer.price.in) : null,
+ price_out: offer && offer.price ? Number(offer.price.out) : null,
+ max_cost_usd: call.max_cost_usd == null ? null : Number(call.max_cost_usd),
+ available: Boolean(found && offer),
+ };
+ });
+ const known = rows.filter((r) => r.price_in != null);
+ const text = known.length
+ ? rows.map((r) => `${r.model}: $${r.price_in}/M in · $${r.price_out}/M out${r.max_cost_usd != null ? ` · at most $${r.max_cost_usd} a press` : ''}`).join(' · ')
+ : 'The AI it calls is not on the live catalogue right now; the price is unknown until it is.';
+ return { calls: rows, text };
+}
+
+module.exports = {
+ COMPONENTS, COMPONENT_NAMES, COMPONENT, TRIGGERS, ACTIONS, PRODUCT,
+ elementSystemPrompt, elementPrompt, parseElement, validateElement, prepareElement,
+ tweaksFor, applyTweaks, renderLook, simulateElement, priceOf, aiCallsOf, themeTokens,
+};
@@ -51,6 +51,10 @@ const KINDS = [
@@ -51,6 +51,10 @@ const KINDS = [
id: 'mark', name: 'Mark', hint: 'A logo or small brand mark sheet', frames: ['mark'],
id: 'mark', name: 'Mark', hint: 'A logo or small brand mark sheet', frames: ['mark'],
instruction: 'Build one brand mark sheet: the mark, a wordmark, a small palette and two or three lockups. The mark must stay recognisable at 16 px and be drawn with inline SVG, not a remote image.',
instruction: 'Build one brand mark sheet: the mark, a wordmark, a small palette and two or three lockups. The mark must stay recognisable at 16 px and be drawn with inline SVG, not a remote image.',
},
},
+ {
+ id: 'element', name: 'Element', hint: 'A control or stream widget with a recipe', frames: ['phone', 'desktop'],
+ instruction: 'Write one ui.element@1 element as JSON: its look (a control component and the theme tokens) and its recipe (triggers, conditions and actions from the fixed catalogs). Return the JSON object only.',
+ },
];
];
const BY_ID = new Map(KINDS.map((kind) => [kind.id, kind]));
const BY_ID = new Map(KINDS.map((kind) => [kind.id, kind]));
@@ -93,6 +97,7 @@ function categoryFor(kindId) {
@@ -93,6 +97,7 @@ function categoryFor(kindId) {
case 'email': return 'email';
case 'email': return 'email';
case 'social-card': return 'social';
case 'social-card': return 'social';
case 'mark': return 'mark';
case 'mark': return 'mark';
+ case 'element': return 'component';
default: return 'landing';
default: return 'landing';
}
}
}
}
@@ -7,6 +7,7 @@ const { vocabulary } = require('./components/registry');
@@ -7,6 +7,7 @@ const { vocabulary } = require('./components/registry');
const { kindOf, instructionFor, categoryFor } = require('./create/kinds');
const { kindOf, instructionFor, categoryFor } = require('./create/kinds');
const { makeThumbnail } = require('./create/thumbnail');
const { makeThumbnail } = require('./create/thumbnail');
const { publicStart, startPrompt } = require('./create/start');
const { publicStart, startPrompt } = require('./create/start');
+const elements = require('./create/element');
const random = () => crypto.randomBytes(24).toString('base64url');
const random = () => crypto.randomBytes(24).toString('base64url');
const hash = (key) => crypto.createHash('sha256').update(key).digest();
const hash = (key) => crypto.createHash('sha256').update(key).digest();
@@ -111,6 +112,9 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -111,6 +112,9 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',
? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',
createdAt: d.created_at, updatedAt: d.updated_at,
createdAt: d.created_at, updatedAt: d.updated_at,
published: d.space_url ? { url: d.space_url, at: d.published_at || null } : null,
published: d.space_url ? { url: d.space_url, at: d.published_at || null } : null,
+ elementChannel: d.element_channel || null,
+ elementListing: d.element_listing || null,
+ elementRemixOf: d.element_remix_of || null,
};
};
};
};
function allowed(d, owner, key) {
function allowed(d, owner, key) {
@@ -217,7 +221,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -217,7 +221,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
model: slot.model, model_id: slot.model_id || null, name: slot.name || null,
model: slot.model, model_id: slot.model_id || null, name: slot.name || null,
price_in: slot.price_in == null ? null : Number(slot.price_in), price_out: slot.price_out == null ? null : Number(slot.price_out),
price_in: slot.price_in == null ? null : Number(slot.price_in), price_out: slot.price_out == null ? null : Number(slot.price_out),
source: slot.source || null, status: 'pending', html: '', ms: 0, costUsd: 0, error: null,
source: slot.source || null, status: 'pending', html: '', ms: 0, costUsd: 0, error: null,
- paid_by: null, prefer: slot.prefer || null, pin: slot.pin === true }));
+ paid_by: null, prefer: slot.prefer || null, pin: slot.pin === true,
+ element: null, element_error: null, element_price: null, tweaks: null }));
const id = s.newId('dsn'), editKey = owner ? null : random();
const id = s.newId('dsn'), editKey = owner ? null : random();
// "Research first": start the dive now, on the person's behalf, and point the design at it. The takes
// "Research first": start the dive now, on the person's behalf, and point the design at it. The takes
// wait for the report in run(); an attached dive is not run or charged again.
// wait for the report in run(); an attached dive is not run or charged again.
@@ -257,6 +262,42 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -257,6 +262,42 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
await db.exec('UPDATE designs SET variants=jsonb_set(variants, ARRAY[$2::text], $3::jsonb, false), updated_at=$4::timestamptz WHERE id=$1',
await db.exec('UPDATE designs SET variants=jsonb_set(variants, ARRAY[$2::text], $3::jsonb, false), updated_at=$4::timestamptz WHERE id=$1',
[id, String(index), JSON.stringify(variant), s.iso()]);
[id, String(index), JSON.stringify(variant), s.iso()]);
}
}
+ /**
+ * One Element take: the AI writes the look and the recipe together as JSON. The SDK (or, before the release
+ * that carries it, the contracts schema) validates it; one refusal is sent back with its reasons and asked
+ * once more. A second refusal marks the take failed with those reasons in plain words.
+ */
+ async function runElement(d, running, index, theme) {
+ const variables = theme.variables || {};
+ const base = elements.elementPrompt({ prompt: d.prompt, variables });
+ const system = elements.elementSystemPrompt();
+ let lastReasons = [];
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ const prompt = attempt === 0 ? base
+ : `${base}\n\nYour previous answer was refused for these reasons:\n- ${lastReasons.join('\n- ')}\nReturn the whole JSON object again with every one of them fixed.`;
+ const out = await ai.text({
+ prompt, system, maxTokens: 3000,
+ target: { service: 'design', type: 'element_output', id: `${d.id}_${index}_${attempt}`.slice(0, 128) },
+ prefer: running.prefer || null, person: d.owner || null,
+ });
+ let doc;
+ try { doc = elements.prepareElement(elements.parseElement(out.text), { authorName: 'A maker' }); }
+ catch (err) { lastReasons = [err.message]; continue; }
+ delete doc.id;
+ delete doc.revision;
+ const check = elements.validateElement(doc);
+ if (!check.ok) { lastReasons = check.reasons; continue; }
+ let price = null;
+ try { price = await elements.priceOf(doc, ai); } catch (err) { log.warn('[OpenVibe.Design] element price:', err.message); }
+ return {
+ ...running, status: 'done', html: JSON.stringify(doc), element: doc, element_error: null, element_price: price,
+ tweaks: elements.tweaksFor(doc, variables), provider: out.provider || running.provider, model: out.model || running.model,
+ ms: out.ms || 0, costUsd: Number(out.cost_usd || 0), error: null, paid_by: out.paid_by || null, prefer: out.prefer || running.prefer || null,
+ };
+ }
+ throw fail(422, 'design.element_invalid', `The AI's element was refused: ${lastReasons.join('; ')}`);
+ }
+
async function run(id) {
async function run(id) {
const d = await db.maybe('SELECT * FROM designs WHERE id=$1', [id]);
const d = await db.maybe('SELECT * FROM designs WHERE id=$1', [id]);
if (!d) return;
if (!d) return;
@@ -266,6 +307,11 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -266,6 +307,11 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
await updateVariant(id, index, running);
await updateVariant(id, index, running);
try {
try {
const t = await lookTheme(d);
const t = await lookTheme(d);
+ if (d.kind === 'element') {
+ const done = await runElement(d, running, index, t);
+ await updateVariant(id, index, done);
+ return;
+ }
// The dive's report is part of the brief for every take. A dive that is still running or failed
// The dive's report is part of the brief for every take. A dive that is still running or failed
// does not hold the design forever: the takes are drawn without it and the canvas says so.
// does not hold the design forever: the takes are drawn without it and the canvas says so.
let report = null;
let report = null;
@@ -439,6 +485,15 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -439,6 +485,15 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
// `pin` is how the run was asked to route, not part of the frame; `prefer` is the request before the run
// `pin` is how the run was asked to route, not part of the frame; `prefer` is the request before the run
// and the run's explain (with `honoured`) after it.
// and the run's explain (with `honoured`) after it.
const { pin, ...v } = full;
const { pin, ...v } = full;
+ if (d.kind === 'element') {
+ // An element is stored as a JSON document, not a document to wrap. Its look is drawn from the
+ // component and the theme's tokens; the sliders are generated from its own look.
+ const doc = full.element || null;
+ return { ...v, html: '', element: doc, element_error: full.element_error || null,
+ element_price: full.element_price || null, look: doc ? elements.renderLook(doc) : null,
+ tweaks: doc ? elements.tweaksFor(doc, selected.variables || {}) : null,
+ currentSeq: p && index === Number(p.variant_index) && current ? Number(current.seq) : null };
+ }
if (p && index === Number(p.variant_index) && current) {
if (p && index === Number(p.variant_index) && current) {
return { ...v, html: wrap(current.html, selected.variables || {}), currentSeq: Number(current.seq), edited: current.kind !== 'generated' };
return { ...v, html: wrap(current.html, selected.variables || {}), currentSeq: Number(current.seq), edited: current.kind !== 'generated' };
}
}
@@ -498,6 +553,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -498,6 +553,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
if (!instruction || instruction.length > CHAT_INSTRUCTION_MAX)
if (!instruction || instruction.length > CHAT_INSTRUCTION_MAX)
throw fail(422, 'design.invalid_instruction', `Write an instruction up to ${CHAT_INSTRUCTION_MAX} characters.`);
throw fail(422, 'design.invalid_instruction', `Write an instruction up to ${CHAT_INSTRUCTION_MAX} characters.`);
const { d, p, selected, variant } = await currentForEdit(id, owner, key, viewer);
const { d, p, selected, variant } = await currentForEdit(id, owner, key, viewer);
+ if (d.kind === 'element') throw fail(409, 'design.element_no_chat', 'An element is changed with its sliders, not a chat edit.');
const used = await db.maybe('UPDATE design_picks SET chat_edits=chat_edits+1, updated_at=$2 WHERE design_id=$1 AND chat_edits < $3 RETURNING chat_edits',
const used = await db.maybe('UPDATE design_picks SET chat_edits=chat_edits+1, updated_at=$2 WHERE design_id=$1 AND chat_edits < $3 RETURNING chat_edits',
[d.id, s.iso(), chatEditLimit]);
[d.id, s.iso(), chatEditLimit]);
if (!used) throw fail(429, 'design.edit_limit', `This design has used its ${chatEditLimit} AI edits. Click edits and undo still work.`);
if (!used) throw fail(429, 'design.edit_limit', `This design has used its ${chatEditLimit} AI edits. Click edits and undo still work.`);
@@ -523,6 +579,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -523,6 +579,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
async function clickEdit({ id, path, text, color, spacing, align, owner, key, subject = null, viewer = null }) {
async function clickEdit({ id, path, text, color, spacing, align, owner, key, subject = null, viewer = null }) {
const { d, p, selected, variant } = await currentForEdit(id, owner, key, viewer);
const { d, p, selected, variant } = await currentForEdit(id, owner, key, viewer);
+ if (d.kind === 'element') throw fail(409, 'design.element_no_click', 'An element is changed with its sliders, not a click edit.');
const current = await versionRow(id, p.current_seq);
const current = await versionRow(id, p.current_seq);
let change;
let change;
try {
try {
@@ -639,6 +696,87 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -639,6 +696,87 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const pending = await db.many("SELECT id FROM designs WHERE variants @> '[{\"status\":\"pending\"}]'::jsonb OR variants @> '[{\"status\":\"running\"}]'::jsonb ORDER BY created_at LIMIT 20");
const pending = await db.many("SELECT id FROM designs WHERE variants @> '[{\"status\":\"pending\"}]'::jsonb OR variants @> '[{\"status\":\"running\"}]'::jsonb ORDER BY created_at LIMIT 20");
for (const d of pending) run(d.id).catch(err => log.warn('[OpenVibe.Design] resume:', err.message));
for (const d of pending) run(d.id).catch(err => log.warn('[OpenVibe.Design] resume:', err.message));
}
}
+ /** The picked element of an element design, through the same access check every read uses. */
+ async function elementDraft({ id, owner, key, viewer = null }) {
+ const { d, p, selected, variant } = await currentForEdit(id, owner, key, viewer);
+ if (d.kind !== 'element') throw fail(409, 'design.not_element', 'This design is not an element.');
+ if (!variant || !variant.element) throw fail(409, 'design.element_not_ready', 'The element is not ready yet.');
+ return { d, p, selected, variant, element: variant.element };
+ }
+
+ /** Move the generated tweak sliders: size, tint and label, applied to the look only. */
+ async function tweakElement({ id, values = {}, owner, key, viewer = null }) {
+ const { d, p, selected, variant } = await elementDraft({ id, owner, key, viewer });
+ let doc;
+ try { doc = elements.applyTweaks(variant.element, values || {}, selected.variables || {}); }
+ catch (err) { throw fail(err.status || 422, err.code || 'design.invalid_tweak', err.detail || err.message); }
+ const check = elements.validateElement(doc);
+ if (!check.ok) throw fail(422, 'design.element_invalid', `The change was refused: ${check.reasons.join('; ')}`);
+ let price = variant.element_price || null;
+ try { price = await elements.priceOf(doc, ai); } catch { /* the price line says unknown */ }
+ await updateVariant(d.id, Number(p.variant_index), { ...variant, element: doc, html: JSON.stringify(doc),
+ element_price: price, tweaks: elements.tweaksFor(doc, selected.variables || {}) });
+ return read(id, owner, key, viewer);
+ }
+
+ /** The element to hand to Live or Exchange: the picked document with the maker's own display name. */
+ async function elementOf({ id, owner, key, viewer = null, authorName = null, sharing = 'gallery' }) {
+ const { d, variant } = await elementDraft({ id, owner, key, viewer });
+ const doc = JSON.parse(JSON.stringify(variant.element));
+ if (authorName) doc.author = { name: String(authorName).slice(0, 80) };
+ doc.sharing = sharing;
+ return { design: publicRow(d), element: doc };
+ }
+
+ /** Run the picked element on sample data: fake presses of every role, a fake chat, and no robot or AI call. */
+ async function simulate(id, { owner, key, viewer = null } = {}) {
+ const { d, variant } = await elementDraft({ id, owner, key, viewer });
+ let price = variant.element_price || null;
+ try { price = await elements.priceOf(variant.element, ai); } catch { /* said as unknown */ }
+ return { designId: d.id, prompt: d.prompt, element: variant.element, price, ...elements.simulateElement(variant.element, { price }) };
+ }
+
+ /**
+ * A remix opens as an editable copy: a Design of kind Element whose one take is the gallery listing's
+ * document, credited to the original. It runs no AI and spends nothing; the person tweaks and publishes.
+ */
+ async function importElement({ element, owner, listing, remixOf = null } = {}) {
+ if (!owner) throw fail(401, 'auth.required', 'Sign in to remix an element.');
+ if (!element || typeof element !== 'object') throw fail(422, 'design.invalid_element', 'The listing does not carry an element document.');
+ const selected = await theme(null);
+ const variables = selected.variables || {};
+ const id = s.newId('dsn');
+ const doc = JSON.parse(JSON.stringify(element));
+ if (doc.name && !/\(remix\)$/i.test(doc.name)) doc.name = `${doc.name} (remix)`;
+ const variant = {
+ id: s.newId('dva'), slot: 0, provider: 'gallery', model: (listing && listing.author && listing.author.name) || 'gallery',
+ model_id: null, name: null, price_in: null, price_out: null, source: 'gallery', status: 'done', html: JSON.stringify(doc),
+ ms: 0, costUsd: 0, error: null, paid_by: null, prefer: null, pin: false,
+ element: doc, element_error: null, element_price: null, tweaks: elements.tweaksFor(doc, variables),
+ };
+ const prompt = `Remix of ${listing.name}`;
+ const thumbnail = makeThumbnail({ kind: 'element', kindName: 'Element', prompt, model: variant.model, variables });
+ await db.exec(`INSERT INTO designs(id,prompt,kind,theme_id,variants,owner,guest_hash,guest_ip,created_at,updated_at,
+ frame,look,look_variables,models,start,source_design_id,estimate,thumbnail_svg,element_remix_of)
+ VALUES($1,$2,$3,$4,$5::jsonb,$6,NULL,NULL,$7::timestamptz,$7::timestamptz,$8,$9,$10::jsonb,NULL,$11::jsonb,NULL,NULL,$12,$13)`,
+ [id, prompt, 'element', selected.id, JSON.stringify([variant]), owner, s.iso(), 'phone', selected.id,
+ JSON.stringify(variables), JSON.stringify({ from_listing: listing.id, by: listing.author && listing.author.name || null }),
+ thumbnail, remixOf || listing.id]);
+ await db.exec(`INSERT INTO design_picks(design_id,variant_index,variant_id,created_at,updated_at)
+ VALUES($1,0,$2,$3::timestamptz,$3::timestamptz) ON CONFLICT (design_id) DO NOTHING`, [id, variant.id, s.iso()]);
+ if (sharing) await sharing.register(id, owner).catch((err) => log.warn('[OpenVibe.Design] share row not registered:', err.message));
+ return { design: { id, prompt, kind: 'element' }, editUrl: `/d/${id}` };
+ }
+
+ /** Remember a hand-off's outcome beside the element, so the canvas can say what happened after a reload. */
+ async function rememberHandoff({ id, owner, key, viewer = null, kind, value }) {
+ if (!['channel', 'listing'].includes(kind)) throw fail(422, 'design.invalid_handoff', 'Choose channel or listing.');
+ await row(id, owner, key, viewer, true);
+ const column = kind === 'channel' ? 'element_channel' : 'element_listing';
+ await db.exec(`UPDATE designs SET ${column}=$2::jsonb, updated_at=$3::timestamptz WHERE id=$1`, [id, JSON.stringify(value || null), s.iso()]);
+ return read(id, owner, key, viewer);
+ }
+
/**
/**
* Publish the working frame on openvibe.space (publish.js): its owner only (a guest's private link cannot), and
* Publish the working frame on openvibe.space (publish.js): its owner only (a guest's private link cannot), and
* only once a frame is picked. The site is remembered, so publishing again updates the same address.
* only once a frame is picked. The site is remembered, so publishing again updates the same address.
@@ -646,6 +784,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -646,6 +784,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
async function publish({ id, owner, name = '', publisher, viewer = null }) {
async function publish({ id, owner, name = '', publisher, viewer = null }) {
if (!owner) throw fail(401, 'auth.required', 'Sign in to publish a design on openvibe.space.');
if (!owner) throw fail(401, 'auth.required', 'Sign in to publish a design on openvibe.space.');
const d = await row(id, owner, null, viewer, true);
const d = await row(id, owner, null, viewer, true);
+ if (d.kind === 'element') throw fail(409, 'design.element_not_site', 'An element is added to your Live channel or published to the Elements and packs gallery, not to a Space site.');
if (d.owner !== owner) throw fail(403, 'design.forbidden', 'Only the person who made this design can publish it.');
if (d.owner !== owner) throw fail(403, 'design.forbidden', 'Only the person who made this design can publish it.');
const data = await read(id, owner, null);
const data = await read(id, owner, null);
const index = data.pick ? Number(data.pick.variantIndex) : -1;
const index = data.pick ? Number(data.pick.variantIndex) : -1;
@@ -655,7 +794,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -655,7 +794,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
return out;
return out;
}
}
- return { create, read, setTheme, recent, thumbnail, resume, theme, pick, chatEdit, clickEdit, versions, undo, redo, chatEditLimit, publish };
+ return { create, read, setTheme, recent, thumbnail, resume, theme, pick, chatEdit, clickEdit, versions, undo, redo, chatEditLimit, publish,
+ elementDraft, tweakElement, elementOf, simulate, rememberHandoff, importElement };
}
}
module.exports = { createDesigns, wrap, tokenCss, themeCss, themePrompt, editPrompt };
module.exports = { createDesigns, wrap, tokenCss, themeCss, themePrompt, editPrompt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,302 @@
@@ -0,0 +1,302 @@
+'use strict';
+
+/**
+ * The Elements and packs gallery beside Design /create (owner correction, 2026-11-11: the gallery lives on
+ * OpenVibe.Design; Exchange stays the AI model catalogue and is read only for live AI prices).
+ *
+ * A listing is a validated ui.element@1 document or a ui.element-pack@1 pack, free, plus who made it and the
+ * original it remixes. Installs add the element to the person's Live channel; ratings come only from people who
+ * installed it; reports are rows for staff. Nothing here runs an element, calls a robot, or carries a price.
+ */
+const { isExplicit } = require('openvibe-sdk/no-looks');
+const { COMPONENTS, COMPONENT_NAMES, COMPONENT, validateElement } = require('../create/element');
+
+const MAX_DOC_BYTES = 262144;
+const MAX_ITEMS = 24;
+const MAX_NAME = 120;
+const MAX_SUMMARY = 600;
+const TEXT = /https?:\/\/|<[a-z!/]/i;
+
+function bad(message, code = 'gallery.invalid', status = 422) {
+ const err = new Error(message);
+ err.code = code;
+ err.status = status;
+ return err;
+}
+
+const plain = (v) => (typeof v === 'string' ? v.trim() : '');
+const stringsIn = (value, out = []) => {
+ if (typeof value === 'string') out.push(value);
+ else if (Array.isArray(value)) value.forEach((v) => stringsIn(v, out));
+ else if (value && typeof value === 'object') Object.values(value).forEach((v) => stringsIn(v, out));
+ return out;
+};
+
+/** Content checks on top of the element runtime's validation: free, no URL or markup, no explicit text. */
+function contentReasons(doc, path = 'element') {
+ const reasons = [];
+ const name = plain(doc.name);
+ const summary = plain(doc.description);
+ if (name.length < 1 || name.length > 80) reasons.push(`${path} needs a name of 1 to 80 characters.`);
+ if (summary.length > 500) reasons.push(`${path}'s description is longer than 500 characters.`);
+ if (isExplicit(name, summary)) reasons.push(`${path} carries explicit adult text.`);
+ const look = doc.look || {};
+ if (!COMPONENT.has(look.component)) reasons.push(`${path}'s look uses a component that is not in the gallery's control set (${COMPONENT_NAMES.join(', ')}).`);
+ if (look.label && (String(look.label).length > 40 || TEXT.test(String(look.label)))) reasons.push(`${path}'s label is longer than 40 characters or carries a link.`);
+ if (look.style && look.style.tint && !/^[a-z][a-z0-9-]{0,39}$/.test(String(look.style.tint))) reasons.push(`${path}'s tint must be a theme token, never a colour.`);
+ for (const value of Object.values(look.props || {})) {
+ if (typeof value !== 'string') continue;
+ if (TEXT.test(value)) reasons.push(`${path}'s look props carry a URL or markup.`);
+ if (/(?:^|[\s"';:(])(?:#[0-9a-f]{3,8}|rgba?\s*\(|hsla?\s*\()/i.test(value)) reasons.push(`${path}'s look props carry a literal colour; use the theme's tokens.`);
+ }
+ for (const text of stringsIn(doc.recipe)) {
+ if (text.length > 2000) reasons.push(`${path}'s recipe has a value longer than 2000 characters.`);
+ if (TEXT.test(text)) reasons.push(`${path}'s recipe carries a URL or markup.`);
+ }
+ if (doc.price != null || doc.price_cents != null || doc.price_usd != null) {
+ const n = Number(doc.price_cents != null ? doc.price_cents : doc.price_usd != null ? doc.price_usd : doc.price);
+ if (Number.isFinite(n) && n > 0) reasons.push('Elements are free: a listing cannot carry a price.');
+ }
+ return reasons;
+}
+
+/** One element document (a draft or a stored one) as a listing: the runtime validates it, then the content. */
+function validateElementListing(doc, path = 'element') {
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw bad(`${path} must be an object.`);
+ const runtime = validateElement(doc);
+ const reasons = [...(runtime.ok ? [] : runtime.reasons), ...contentReasons(doc, path)];
+ if (reasons.length) throw bad(`The element is not valid: ${[...new Set(reasons)].slice(0, 6).join(' ')}`);
+ return { kind: 'element', name: plain(doc.name), summary: plain(doc.description), look: doc.look, doc };
+}
+
+/** A pack: a named list of validated element documents. */
+function validatePack(doc) {
+ if (!Array.isArray(doc.items) || doc.items.length === 0) throw bad('A pack needs at least one element.');
+ if (doc.items.length > MAX_ITEMS) throw bad(`A pack has at most ${MAX_ITEMS} elements.`);
+ const name = plain(doc.name);
+ const summary = plain(doc.description);
+ if (name.length < 2 || name.length > MAX_NAME) throw bad(`A pack's name must be 2 to ${MAX_NAME} characters.`);
+ if (summary.length > MAX_SUMMARY) throw bad(`A pack's description is longer than ${MAX_SUMMARY} characters.`);
+ if (isExplicit(name, summary)) throw bad('A pack carries explicit adult text.');
+ const names = new Set();
+ for (const [i, item] of doc.items.entries()) {
+ const got = validateElementListing(item, `pack item ${i + 1}`);
+ if (names.has(got.name.toLowerCase())) throw bad(`A pack cannot hold two elements with the same name: ${got.name}.`);
+ names.add(got.name.toLowerCase());
+ }
+ return { kind: 'pack', name, summary, look: doc.items[0].look, doc };
+}
+
+/** Check one listing document. `ui.element-pack@1` is a pack; anything else is one element. */
+function validateDocument(doc) {
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw bad('The listing has no element document.');
+ let json;
+ try { json = JSON.stringify(doc); } catch { throw bad('The document is not valid JSON.'); }
+ if (Buffer.byteLength(json, 'utf8') > MAX_DOC_BYTES) throw bad(`The document is larger than ${Math.round(MAX_DOC_BYTES / 1024)} KB.`);
+ const got = doc.pack === 'ui.element-pack@1' ? validatePack(doc) : validateElementListing(doc);
+ return { ...got, bytes: Buffer.byteLength(json, 'utf8') };
+}
+
+function slugOf(name) {
+ return String(name || '').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'element';
+}
+
+function createGallery({ s, now = () => Date.now(), log = console } = {}) {
+ const iso = () => new Date(now()).toISOString();
+ function wire(row, extra = {}) {
+ if (!row) return null;
+ return {
+ id: row.id, slug: row.slug, kind: row.kind, name: row.name, summary: row.summary,
+ document: row.document, look: row.look,
+ author: { subject: row.author_subject, name: row.author_name, username: row.author_username },
+ source: row.source, source_id: row.source_id, remix_of: row.remix_of, official: row.official === true,
+ status: row.status, installs: Number(row.installs) || 0,
+ rating: { count: Number(row.rating_count) || 0, average: row.rating_count ? Math.round((Number(row.rating_sum) / Number(row.rating_count)) * 10) / 10 : null },
+ created_at: row.created_at, updated_at: row.updated_at, ...extra,
+ };
+ }
+ async function uniqueSlug(name) {
+ const base = slugOf(name);
+ for (let n = 1; n < 50; n += 1) {
+ const slug = n === 1 ? base : `${base}-${n}`;
+ if (!(await s.db.maybe('SELECT 1 FROM design_element_listings WHERE slug = $1', [slug]))) return slug;
+ }
+ return `${base}-${now().toString(36)}`;
+ }
+ async function get(idOrSlug) {
+ const key = String(idOrSlug || '').slice(0, 120);
+ if (!key) return null;
+ return wire(await s.db.maybe('SELECT * FROM design_element_listings WHERE id = $1 OR slug = $1', [key]));
+ }
+ async function publish({ subject, authorName, authorUsername, document: doc, source = 'design', sourceId = null, remixOf = null, official = false } = {}) {
+ if (!official && !subject) throw bad('Publishing is a person\'s own action: sign in first.', 'gallery.sign_in', 401);
+ let document = doc;
+ if (remixOf) {
+ const original = await get(remixOf);
+ if (!original || original.status !== 'published') throw bad('The element you are remixing is not available.', 'gallery.remix_missing', 404);
+ if (original.author.subject === subject && subject) throw bad('That is already your element.', 'gallery.remix_own');
+ document = JSON.parse(JSON.stringify(original.document));
+ const newName = plain(doc && doc.name) || `${original.name} (remix)`;
+ document.name = newName;
+ if (doc && doc.description) document.description = plain(doc.description);
+ source = 'remix';
+ sourceId = original.id;
+ }
+ const author = official ? 'OpenVibe' : (plain(authorName) || (authorUsername ? `@${authorUsername}` : 'A maker'));
+ if (!official) {
+ // A product hands over a draft: the author and the public sharing are the publisher's, not the model's.
+ document = JSON.parse(JSON.stringify(document));
+ const fill = (doc) => {
+ if (!doc.author || !plain(doc.author.name)) doc.author = { name: author };
+ if (!doc.sharing) doc.sharing = 'gallery';
+ };
+ if (document.pack === 'ui.element-pack@1') (document.items || []).forEach((item) => { if (item && typeof item === 'object') fill(item); });
+ else fill(document);
+ }
+ const checked = validateDocument(document);
+ const id = s.newId('del');
+ const row = await s.db.maybe(
+ `INSERT INTO design_element_listings
+ (id, slug, kind, name, summary, document, look, author_subject, author_name, author_username,
+ source, source_id, remix_of, official, status, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10, $11, $12, $13, $14, 'published', $15::timestamptz, $15::timestamptz)
+ RETURNING *`,
+ [id, await uniqueSlug(checked.name), checked.kind, checked.name, checked.summary || null, JSON.stringify(checked.doc),
+ JSON.stringify(checked.look || null), official ? null : subject, author, official ? null : (authorUsername || null),
+ source, sourceId, remixOf || null, official, iso()],
+ );
+ return wire(row);
+ }
+ async function list({ q = '', kind = null, sort = 'new', limit = 48, offset = 0 } = {}) {
+ const where = ["status = 'published'"];
+ const args = [];
+ const needle = plain(q).slice(0, 120);
+ if (needle) {
+ args.push(`%${needle.replace(/[%_]/g, (c) => `\\${c}`)}%`);
+ where.push(`(name ILIKE $${args.length} ESCAPE '\\' OR COALESCE(summary, '') ILIKE $${args.length} ESCAPE '\\')`);
+ }
+ if (kind === 'element' || kind === 'pack') { args.push(kind); where.push(`kind = $${args.length}`); }
+ const order = sort === 'installs' ? 'installs DESC, created_at DESC'
+ : sort === 'rating' ? 'CASE WHEN rating_count > 0 THEN rating_sum::float8 / rating_count ELSE 0 END DESC, rating_count DESC, created_at DESC'
+ : 'created_at DESC';
+ args.push(Math.max(1, Math.min(96, Number(limit) || 48)), Math.max(0, Number(offset) || 0));
+ const rows = await s.db.many(`SELECT * FROM design_element_listings WHERE ${where.join(' AND ')} ORDER BY ${order} LIMIT $${args.length - 1} OFFSET $${args.length}`, args);
+ return rows.map((r) => wire(r));
+ }
+ async function count({ q = '', kind = null } = {}) {
+ const where = ["status = 'published'"];
+ const args = [];
+ const needle = plain(q).slice(0, 120);
+ if (needle) {
+ args.push(`%${needle.replace(/[%_]/g, (c) => `\\${c}`)}%`);
+ where.push(`(name ILIKE $${args.length} ESCAPE '\\' OR COALESCE(summary, '') ILIKE $${args.length} ESCAPE '\\')`);
+ }
+ if (kind === 'element' || kind === 'pack') { args.push(kind); where.push(`kind = $${args.length}`); }
+ const row = await s.db.maybe(`SELECT COUNT(*)::int AS n FROM design_element_listings WHERE ${where.join(' AND ')}`, args);
+ return Number(row && row.n) || 0;
+ }
+ async function installedBy(listingId, subject) {
+ if (!subject) return null;
+ const row = await s.db.maybe('SELECT * FROM design_element_installs WHERE listing_id = $1 AND subject = $2', [String(listingId), String(subject)]);
+ return row ? { channel: row.channel, library: row.library, created_at: row.created_at } : null;
+ }
+ async function ratingBy(listingId, subject) {
+ if (!subject) return null;
+ const row = await s.db.maybe('SELECT stars, note, updated_at FROM design_element_ratings WHERE listing_id = $1 AND subject = $2', [String(listingId), String(subject)]);
+ return row ? { stars: row.stars, note: row.note, updated_at: row.updated_at } : null;
+ }
+ async function install({ listingId, subject, channel = null, library = null } = {}) {
+ if (!subject) throw bad('Sign in to install an element.', 'gallery.sign_in', 401);
+ const row = await get(listingId);
+ if (!row || row.status !== 'published') throw bad('That element is not available.', 'gallery.missing', 404);
+ const id = s.newId('dei');
+ const inserted = await s.db.maybe(
+ `INSERT INTO design_element_installs (id, listing_id, subject, channel, library, created_at)
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6::timestamptz) ON CONFLICT (listing_id, subject) DO NOTHING RETURNING id`,
+ [id, row.id, subject, plain(channel).slice(0, 64) || null, JSON.stringify(library || null), iso()],
+ );
+ if (inserted) await s.db.maybe('UPDATE design_element_listings SET installs = installs + 1 WHERE id = $1 RETURNING installs', [row.id]);
+ const fresh = await get(row.id);
+ return { listing: fresh, first: Boolean(inserted) };
+ }
+ async function rate({ listingId, subject, stars, note = null } = {}) {
+ if (!subject) throw bad('Sign in to rate an element.', 'gallery.sign_in', 401);
+ const n = Number(stars);
+ if (!Number.isInteger(n) || n < 1 || n > 5) throw bad('Choose one to five stars.', 'gallery.rating');
+ const row = await get(listingId);
+ if (!row || row.status !== 'published') throw bad('That element is not available.', 'gallery.missing', 404);
+ if (!(await installedBy(row.id, subject))) throw bad('Only people who installed an element can rate it.', 'gallery.not_installed', 403);
+ await s.db.maybe(
+ `INSERT INTO design_element_ratings (id, listing_id, subject, stars, note, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $6::timestamptz)
+ ON CONFLICT (listing_id, subject) DO UPDATE SET stars = EXCLUDED.stars, note = EXCLUDED.note, updated_at = EXCLUDED.updated_at`,
+ [s.newId('der'), row.id, subject, n, plain(note).slice(0, 400) || null, iso()],
+ );
+ await s.db.maybe(
+ `UPDATE design_element_listings SET rating_sum = (SELECT COALESCE(SUM(stars), 0)::int FROM design_element_ratings WHERE listing_id = $1),
+ rating_count = (SELECT COUNT(*)::int FROM design_element_ratings WHERE listing_id = $1) WHERE id = $1`, [row.id],
+ );
+ return get(row.id);
+ }
+ async function report({ listingId, subject = null, reason = 'other', detail = null } = {}) {
+ const row = await get(listingId);
+ if (!row || row.status !== 'published') throw bad('That element is not available.', 'gallery.missing', 404);
+ const why = String(reason || 'other');
+ if (!['unsafe', 'broken', 'spam', 'other'].includes(why)) throw bad('Choose a reason from the list.', 'gallery.reason');
+ const text = plain(detail).slice(0, 1000);
+ if (text && isExplicit(text)) throw bad('Keep the report to the problem itself.', 'gallery.report_text');
+ const saved = await s.db.maybe(
+ `INSERT INTO design_element_reports (id, listing_id, subject, reason, detail, status, created_at)
+ VALUES ($1, $2, $3, $4, $5, 'pending', $6::timestamptz) RETURNING *`,
+ [s.newId('dep'), row.id, subject || null, why, text || null, iso()],
+ );
+ return { id: saved.id, status: saved.status, created_at: saved.created_at };
+ }
+ async function reports({ status = 'pending', limit = 100 } = {}) {
+ const rows = await s.db.many(
+ `SELECT r.*, e.slug, e.name, e.status AS listing_status FROM design_element_reports r
+ JOIN design_element_listings e ON e.id = r.listing_id
+ WHERE r.status = $1 ORDER BY r.created_at DESC, r.id DESC LIMIT $2`,
+ [status, Math.max(1, Math.min(200, Number(limit) || 100))],
+ );
+ return rows.map((r) => ({ id: r.id, listing_id: r.listing_id, slug: r.slug, name: r.name, listing_status: r.listing_status,
+ subject: r.subject, reason: r.reason, detail: r.detail, status: r.status, created_at: r.created_at }));
+ }
+ async function reviewReport({ id, decision, note = null, reviewer = null } = {}) {
+ if (!['accepted', 'rejected'].includes(decision)) throw bad('Choose accepted or rejected.', 'gallery.decision');
+ const row = await s.db.maybe(
+ `UPDATE design_element_reports SET status = $1, reviewer_note = $2, reviewed_by = $3, reviewed_at = $4::timestamptz
+ WHERE id = $5 AND status = 'pending' RETURNING *`,
+ [decision, plain(note).slice(0, 500) || null, reviewer || null, iso(), String(id || '')],
+ );
+ if (!row) return null;
+ if (decision === 'accepted') await setStatus({ id: row.listing_id, status: 'hidden', reviewer });
+ return { id: row.id, listing_id: row.listing_id, status: row.status };
+ }
+ async function setStatus({ id, status, reviewer = null } = {}) {
+ if (!['published', 'hidden'].includes(status)) throw bad('Choose published or hidden.', 'gallery.status');
+ const row = await s.db.maybe('UPDATE design_element_listings SET status = $1, updated_at = $2::timestamptz WHERE id = $3 RETURNING *', [status, iso(), String(id || '')]);
+ if (!row) return null;
+ if (reviewer) log.log(`[OpenVibe.Design] gallery listing ${row.slug} set ${status} by ${reviewer}`);
+ return wire(row);
+ }
+ async function ensureOfficial({ slug, document: doc }) {
+ const existing = await s.db.maybe('SELECT * FROM design_element_listings WHERE slug = $1', [slug]);
+ if (existing) return wire(existing);
+ const checked = validateDocument(doc);
+ const row = await s.db.maybe(
+ `INSERT INTO design_element_listings
+ (id, slug, kind, name, summary, document, look, author_subject, author_name, author_username,
+ source, source_id, remix_of, official, status, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, NULL, 'OpenVibe', NULL, 'official', NULL, NULL, true, 'published', $8::timestamptz, $8::timestamptz)
+ ON CONFLICT (slug) DO NOTHING RETURNING *`,
+ [s.newId('del'), slugOf(slug), checked.kind, checked.name, checked.summary || null, JSON.stringify(checked.doc),
+ JSON.stringify(checked.look || null), iso()],
+ );
+ return wire(row) || wire(await s.db.maybe('SELECT * FROM design_element_listings WHERE slug = $1', [slugOf(slug)]));
+ }
+ return { publish, list, count, get, installedBy, ratingBy, install, rate, report, reports, reviewReport, setStatus, ensureOfficial, validateDocument, slugOf };
+}
+
+module.exports = { createGallery, validateDocument, validateElementListing, contentReasons, slugOf, COMPONENTS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+'use strict';
+
+/**
+ * Adding a made element to the maker's Live channel through Live's own element library. It carries the
+ * person's own Network token, never a service token: the action is theirs, and Live checks it as them. The
+ * document is already validated and free; nothing here sets a price. Failures are plain text (never a token,
+ * never a response body) so the canvas can say what happened. The gallery itself is here on Design.
+ */
+function createElementHandoff({ config = {}, fetchImpl = globalThis.fetch, log = console } = {}) {
+ const live = config.live || {};
+
+ async function call(name, url, token, body, timeoutMs) {
+ if (!token) {
+ const err = new Error('Sign in to add an element to your channel or publish it.');
+ err.status = 401;
+ err.code = 'design.sign_in';
+ throw err;
+ }
+ let res;
+ try {
+ res = await fetchImpl(url, {
+ method: 'POST',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(timeoutMs || 15_000),
+ });
+ } catch (err) {
+ const out = new Error(`${name} could not be reached.`);
+ out.status = 502;
+ out.code = 'design.handoff_unreachable';
+ throw out;
+ }
+ const doc = await res.json().catch(() => null);
+ if (!res.ok) {
+ const detail = doc && (doc.detail || doc.message || doc.error);
+ const out = new Error(`${name} refused it${detail ? `: ${String(detail).slice(0, 240)}` : ` (${res.status})`}.`);
+ out.status = res.status === 401 || res.status === 403 ? res.status : 502;
+ out.code = 'design.handoff_refused';
+ throw out;
+ }
+ return doc || {};
+ }
+
+ /** Add one validated element to the person's own Live element library. */
+ async function addToChannel({ element, token }) {
+ const base = String(live.url || '').replace(/\/+$/, '');
+ if (!base) throw Object.assign(new Error('Live is not configured, so the element cannot be added.'), { status: 503, code: 'design.live_unconfigured' });
+ const out = await call('OpenVibe.Live', `${base}${live.elementsPath || '/api/me/elements'}`, token, element, live.timeoutMs);
+ const id = out.id || (out.element && out.element.id) || null;
+ return { id, url: out.url || (id ? `${base}/elements/${encodeURIComponent(id)}` : `${base}/elements`), name: element.name };
+ }
+
+ return { addToChannel };
+}
+
+module.exports = { createElementHandoff };
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+
+/**
+ * The first official pack (OpenVibe Elements stage 2): the LOOI pack, published by OpenVibe, free.
+ *
+ * LOOI is the tracked desk robot whose head is a phone. The pack gives its owner a Viewer Deck: head presets,
+ * say lines and the headlights. The robot actions are requests through Bot's existing command path; the
+ * headlights need a LOOI profile with the headlight action (dsh:looi-headlights-1111d), and a preset the robot's
+ * profile does not have is refused by Bot, never invented here. A "look around" macro is stage 4 because the
+ * action catalog does not carry robot macros yet.
+ *
+ * Seeded once on Design's own start, by OpenVibe (author_subject NULL), never for a person. The seed is
+ * idempotent: an existing `looi-pack` slug (the official one, or one that took the name) is left alone.
+ */
+const author = { name: 'OpenVibe' };
+const button = (label) => ({ component: 'button', label, props: { size: 'large' }, style: { tint: 'accent', shape: 'pill', opacity: 1, show_label: true } });
+
+const el = (name, description, look, action) => ({
+ name,
+ description,
+ author,
+ sharing: 'gallery',
+ look,
+ recipe: {
+ triggers: [{ kind: 'press' }],
+ conditions: { role: 'everyone', cooldown: { per_person_seconds: 5, overall_seconds: 2 } },
+ actions: [action],
+ },
+ capabilities: [action.kind],
+});
+
+function looiPack() {
+ return {
+ pack: 'ui.element-pack@1',
+ name: 'LOOI pack',
+ description: 'Head presets, say lines and the headlights for the LOOI desk robot. Free; every action still goes through Bot\'s checks and your own limits.',
+ author,
+ sharing: 'gallery',
+ items: [
+ el('Look up', 'Tilt the head up.', button('Look up'), { kind: 'robot.head_preset', product: 'bot', preset: 'up' }),
+ el('Look down', 'Tilt the head down.', button('Look down'), { kind: 'robot.head_preset', product: 'bot', preset: 'down' }),
+ el('Look level', 'Bring the head back to level.', button('Look level'), { kind: 'robot.head_preset', product: 'bot', preset: 'center' }),
+ el('Say hello', 'The robot says hello to chat.', button('Say hello'), { kind: 'robot.say', product: 'bot', text: 'Hello, chat!', voice: 'default' }),
+ el('Say nice', 'The robot says something nice.', button('Say nice'), { kind: 'robot.say', product: 'bot', text: 'You are doing great.', voice: 'default' }),
+ el('Say bye', 'The robot says goodbye.', button('Say bye'), { kind: 'robot.say', product: 'bot', text: 'See you next stream!', voice: 'default' }),
+ el('Headlights on', 'Turn the headlights on.', { component: 'toggle', label: 'Headlights on', icon: 'spark', props: { on: true }, style: { tint: 'accent', shape: 'pill', opacity: 1, show_label: true } }, { kind: 'robot.headlight', product: 'bot', on: true }),
+ el('Headlights off', 'Turn the headlights off.', { component: 'toggle', label: 'Headlights off', icon: 'spark', props: { on: false }, style: { tint: 'surface', shape: 'pill', opacity: 1, show_label: true } }, { kind: 'robot.headlight', product: 'bot', on: false }),
+ ],
+ };
+}
+
+/** Publish the official pack once on this service's start; a failure is logged and never blocks the boot. */
+async function seedOfficial(gallery, log = console) {
+ try {
+ const listing = await gallery.ensureOfficial({ slug: 'looi-pack', document: looiPack() });
+ if (listing) log.log(`[OpenVibe.Design] official Elements pack ready: ${listing.slug} (${listing.kind})`);
+ return listing;
+ } catch (err) {
+ log.warn('[OpenVibe.Design] the official LOOI pack was not seeded:', (err && err.message) || err);
+ return null;
+ }
+}
+
+module.exports = { looiPack, seedOfficial };
@@ -76,6 +76,45 @@ function createDesignApi(ctx) {
@@ -76,6 +76,45 @@ function createDesignApi(ctx) {
color: body.color, spacing: body.spacing, align: body.align, owner: owner(req), key: keyOf(req), subject: owner(req), viewer: viewer(req) });
color: body.color, spacing: body.spacing, align: body.align, owner: owner(req), key: keyOf(req), subject: owner(req), viewer: viewer(req) });
return priv(res, 201, result);
return priv(res, 201, result);
}));
}));
+ // ── Elements (OpenVibe Elements stage 2) ────────────────
+ // A design of kind Element keeps a ui.element@1 document instead of an HTML take. These routes move its
+ // generated sliders, run it on sample data, add it to the maker's Live channel, and publish it to
+ // Exchange's free gallery. In every one the person's own token is the credential.
+ const authorNameOf = (req) => (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker';
+ routes.get('/:id/element/simulate', safe(async (req, res) => priv(res, 200,
+ await ctx.designs.simulate(req.params.id, { owner: owner(req), key: keyOf(req), viewer: viewer(req) }))));
+ routes.post('/:id/element/tweak', sameOrigin, safe(async (req, res) => priv(res, 200,
+ await ctx.designs.tweakElement({ id: req.params.id, values: req.body || {}, owner: owner(req), key: keyOf(req), viewer: viewer(req) }))));
+ routes.post('/:id/element/channel', sameOrigin, safe(async (req, res) => {
+ if (!owner(req)) return priv(res, 401, { error: 'design.sign_in', detail: 'Sign in to add an element to your channel.' });
+ const { element } = await ctx.designs.elementOf({ id: req.params.id, owner: owner(req), key: keyOf(req), viewer: viewer(req), authorName: authorNameOf(req), sharing: 'private' });
+ let channel;
+ try {
+ channel = await ctx.elementHandoff.addToChannel({ element, token: token(req) });
+ channel.at = new Date().toISOString();
+ } catch (err) {
+ channel = { error: String(err.message || 'Live did not accept the element.').slice(0, 300), at: new Date().toISOString() };
+ await ctx.designs.rememberHandoff({ id: req.params.id, owner: owner(req), key: keyOf(req), viewer: viewer(req), kind: 'channel', value: channel });
+ return priv(res, err.status || 502, { error: err.code || 'design.handoff_refused', detail: channel.error, channel });
+ }
+ const data = await ctx.designs.rememberHandoff({ id: req.params.id, owner: owner(req), key: keyOf(req), viewer: viewer(req), kind: 'channel', value: channel });
+ return priv(res, 200, { channel, ...data });
+ }));
+ routes.post('/:id/element/gallery', sameOrigin, safe(async (req, res) => {
+ if (!owner(req)) return priv(res, 401, { error: 'design.sign_in', detail: 'Sign in to publish an element to the gallery.' });
+ const { design, element } = await ctx.designs.elementOf({ id: req.params.id, owner: owner(req), key: keyOf(req), viewer: viewer(req),
+ authorName: (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker', sharing: 'gallery' });
+ try {
+ const listing = await ctx.gallery.publish({ subject: owner(req), authorName: (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker',
+ authorUsername: (req.viewer && req.viewer.username) || null, document: element, source: 'design', sourceId: req.params.id, remixOf: design.elementRemixOf || null });
+ const value = { id: listing.id, slug: listing.slug, url: `/gallery/${encodeURIComponent(listing.slug)}`, at: new Date().toISOString() };
+ const data = await ctx.designs.rememberHandoff({ id: req.params.id, owner: owner(req), key: keyOf(req), viewer: viewer(req), kind: 'listing', value });
+ return priv(res, 201, { listing: value, ...data });
+ } catch (err) {
+ return priv(res, err.status || 502, { error: err.code || 'design.gallery_refused', detail: String(err.message || 'The gallery did not accept the element.').slice(0, 300) });
+ }
+ }));
+
routes.get('/:id/versions', safe(async (req, res) => priv(res, 200,
routes.get('/:id/versions', safe(async (req, res) => priv(res, 200,
await ctx.designs.versions(req.params.id, owner(req), keyOf(req), viewer(req)))));
await ctx.designs.versions(req.params.id, owner(req), keyOf(req), viewer(req)))));
routes.post('/:id/undo', sameOrigin, safe(async (req, res) => priv(res, 200,
routes.post('/:id/undo', sameOrigin, safe(async (req, res) => priv(res, 200,
@@ -35,6 +35,7 @@ function publicPages(themes = []) {
@@ -35,6 +35,7 @@ function publicPages(themes = []) {
{ path: '/rules', changefreq: 'weekly', priority: 0.9, title: 'How OpenVibe decides', text: 'Every style, design and organization rule behind OpenVibe, with the day it was decided, why, and what carries it out. Also as data at /api/v1/rules.' },
{ path: '/rules', changefreq: 'weekly', priority: 0.9, title: 'How OpenVibe decides', text: 'Every style, design and organization rule behind OpenVibe, with the day it was decided, why, and what carries it out. Also as data at /api/v1/rules.' },
{ path: '/use', changefreq: 'weekly', priority: 0.8, title: 'Use: building with it', text: 'The SDK, the API, the files and the versions, and how to install and call them in your own app.' },
{ path: '/use', changefreq: 'weekly', priority: 0.8, title: 'Use: building with it', text: 'The SDK, the API, the files and the versions, and how to install and call them in your own app.' },
{ path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Design', text: 'Browse OpenVibe\'s themes, try them on a preview, edit one with contrast checks, use it, and roll back to any earlier setting.' },
{ path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Design', text: 'Browse OpenVibe\'s themes, try them on a preview, edit one with contrast checks, use it, and roll back to any earlier setting.' },
+ { path: '/gallery', changefreq: 'daily', priority: 0.8, title: 'Elements and packs', text: 'Free buttons, controls and stream widgets people made with AI: install one on your Live channel, remix it as your own, or try it on sample data first.' },
{ path: '/themes', changefreq: 'weekly', priority: 0.9, title: 'Themes', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },
{ path: '/themes', changefreq: 'weekly', priority: 0.9, title: 'Themes', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },
{ path: '/system', changefreq: 'weekly', priority: 0.8, title: 'The design system', text: 'Everything OpenVibe is made of in one map: themes, tokens, fonts, icons, the mark and motion; components, the kit and forms; the rules and patterns; apps and developer tools.' },
{ path: '/system', changefreq: 'weekly', priority: 0.8, title: 'The design system', text: 'Everything OpenVibe is made of in one map: themes, tokens, fonts, icons, the mark and motion; components, the kit and forms; the rules and patterns; apps and developer tools.' },
{ path: '/look', changefreq: 'monthly', priority: 0.6, title: 'Your look', text: 'Your theme, light or dark, text size, motion and seasonal themes for every OpenVibe site, in one place.' },
{ path: '/look', changefreq: 'monthly', priority: 0.6, title: 'Your look', text: 'Your theme, light or dark, text size, motion and seasonal themes for every OpenVibe site, in one place.' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+
+/**
+ * The Elements and packs gallery's JSON, for a product that hands a validated element over instead of using
+ * the canvas: OpenVibe.Live can publish a streamer's element here with the streamer's own token. The pages use
+ * the same gallery store, so a listing made here is on /gallery at once.
+ *
+ * GET /api/v1/gallery/listings?q=&kind=&sort= public; published only
+ * GET /api/v1/gallery/listings/:idOrSlug public; one listing
+ * POST /api/v1/gallery/listings a person publishes {document, source, source_id, remix_of?}
+ *
+ * The person is the credential (their Network token as a Bearer, or their session from this site); no service
+ * token publishes for somebody else. The document is validated by the SDK and the gallery's content checks.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { requireSameOrigin } = require('./same-origin');
+
+const text = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '');
+
+function createGalleryApi(ctx) {
+ const { config, gallery, log } = ctx;
+ const r = asyncRouter();
+ r.use(express.json({ limit: '320kb' }));
+ const fail = (res, status, code, detail, req) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
+ const sameOrigin = requireSameOrigin(config);
+
+ r.get('/listings', async (req, res) => {
+ const q = text(req.query.q, 120);
+ const kind = req.query.kind === 'element' || req.query.kind === 'pack' ? req.query.kind : null;
+ const sort = ['new', 'installs', 'rating'].includes(String(req.query.sort)) ? String(req.query.sort) : 'new';
+ const [listings, count] = await Promise.all([gallery.list({ q, kind, sort, limit: 96 }), gallery.count({ q, kind })]);
+ return res.set('Cache-Control', 'public, max-age=30').json({ kind: 'elements', count, listings });
+ });
+
+ r.get('/listings/:id', async (req, res) => {
+ const listing = await gallery.get(req.params.id);
+ if (!listing) return fail(res, 404, 'gallery.missing', 'No such element or pack.', req);
+ if (listing.status !== 'published' && !(signedIn(req) && listing.author.subject === req.viewer.subject)) return fail(res, 404, 'gallery.missing', 'No such element or pack.', req);
+ return res.set('Cache-Control', listing.status === 'published' ? 'public, max-age=30' : 'private, no-store').json(listing);
+ });
+
+ r.post('/listings', sameOrigin, async (req, res) => {
+ if (!signedIn(req)) return fail(res, 401, 'gallery.sign_in', 'Sign in to publish an element or pack.', req);
+ try {
+ const body = req.body || {};
+ const listing = await gallery.publish({
+ subject: req.viewer.subject,
+ authorName: req.viewer.displayName || req.viewer.username || 'A maker',
+ authorUsername: req.viewer.username || null,
+ document: body.document,
+ source: body.source === 'live' ? 'live' : 'design',
+ sourceId: text(body.source_id, 120) || null,
+ remixOf: text(body.remix_of, 120) || null,
+ });
+ log.log(`[OpenVibe.Design] gallery listing ${listing.slug} (${listing.kind}) by ${req.viewer.subject}`);
+ return res.status(201).json(listing);
+ } catch (err) {
+ const status = Number(err.status) || 500;
+ if (status >= 500) log.error('[OpenVibe.Design] gallery:', err && err.message);
+ return fail(res, status, (err && err.code) || 'gallery.error', status >= 500 ? 'The listing could not be saved.' : (err.message || 'That was not accepted.'), req);
+ }
+ });
+
+ return r;
+}
+
+module.exports = { createGalleryApi };
@@ -30,6 +30,10 @@ const componentsListView = require('../views/components-list');
@@ -30,6 +30,10 @@ const componentsListView = require('../views/components-list');
const kitView = require('../views/kit');
const kitView = require('../views/kit');
const themeTokens = require('../tokens');
const themeTokens = require('../tokens');
const designView = require('../views/design');
const designView = require('../views/design');
+const elementPreviewView = require('../views/element-preview');
+const elementLib = require('../create/element');
+const galleryView = require('../views/gallery');
+const { isStaff } = require('../identity/staff');
const patternsView = require('../views/patterns');
const patternsView = require('../views/patterns');
const brandView = require('../views/brand');
const brandView = require('../views/brand');
const changesView = require('../views/changes');
const changesView = require('../views/changes');
@@ -190,6 +194,76 @@ function createPageRoutes(ctx) {
@@ -190,6 +194,76 @@ function createPageRoutes(ctx) {
return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not read the design.');
return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not read the design.');
}
}
});
});
+ // ── Elements (OpenVibe Elements stage 2, 2026-11-11) ─────
+ // The canvas's element panel and the gallery's "Try it" share one preview view: the look, the recipe on
+ // sample data, the price line, the generated sliders and the hand-offs.
+ const elementBack = (req, res, query = '') => res.redirect(303, `/d/${encodeURIComponent(req.params.id)}/element/preview${query}`);
+ r.get('/d/:id/element/preview', async (req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ try {
+ const key = req.query.key || '';
+ const data = await ctx.designs.read(req.params.id, subjectOf(req), key, req.viewer);
+ if (data.design.kind !== 'element') return res.redirect(303, `/d/${encodeURIComponent(req.params.id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`);
+ if (!data.pick) return res.redirect(303, `/d/${encodeURIComponent(req.params.id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`);
+ const variant = data.variants[Number(data.pick.variantIndex)];
+ if (!variant || !variant.element) return page(res, 409, views.notFound(), req.viewer);
+ const sim = await ctx.designs.simulate(req.params.id, { owner: subjectOf(req), key, viewer: req.viewer });
+ return page(res, 200, elementPreviewView.elementPreview({
+ design: data.design, variant, element: variant.element, sim, price: sim.price, editKey: key, viewer: req.viewer, mode: 'canvas',
+ notice: req.query.channel === 'ok' ? 'Added to your Live element library.' : req.query.listing === 'ok' ? 'Published to the Elements and packs gallery.' : null,
+ error: req.query.channel_error ? String(req.query.channel_error).slice(0, 300) : req.query.listing_error ? String(req.query.listing_error).slice(0, 300) : null,
+ }), req.viewer, { cacheControl: 'private, no-store' });
+ } catch (err) {
+ return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not open the element preview.');
+ }
+ });
+ r.post('/d/:id/element/tweak', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ try {
+ await ctx.designs.tweakElement({ id: req.params.id, values: { size: req.body.size, tint: req.body.tint,
+ label: req.body.label, show_label: req.body.show_label === '1' }, owner: subjectOf(req), key: req.body.key || '', viewer: req.viewer });
+ return elementBack(req, res, req.body.key ? `?key=${encodeURIComponent(req.body.key)}` : '');
+ } catch (err) { return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not apply the tweak.'); }
+ });
+ r.post('/d/:id/element/channel', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/d/${req.params.id}/element/preview`)}`);
+ const key = req.body.key || '';
+ const suffix = key ? `?key=${encodeURIComponent(key)}&channel=` : '?channel=';
+ try {
+ const { element } = await ctx.designs.elementOf({ id: req.params.id, owner: subjectOf(req), key, viewer: req.viewer,
+ authorName: (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker', sharing: 'private' });
+ const channel = await ctx.elementHandoff.addToChannel({ element, token: tokenOf(req) });
+ channel.at = new Date().toISOString();
+ await ctx.designs.rememberHandoff({ id: req.params.id, owner: subjectOf(req), key, viewer: req.viewer, kind: 'channel', value: channel });
+ return elementBack(req, res, suffix + 'ok');
+ } catch (err) {
+ await ctx.designs.rememberHandoff({ id: req.params.id, owner: subjectOf(req), key, viewer: req.viewer, kind: 'channel',
+ value: { error: String(err.message || 'Live did not accept the element.').slice(0, 300), at: new Date().toISOString() } }).catch(() => {});
+ return elementBack(req, res, suffix + `error=${encodeURIComponent(String(err.message || 'Could not add it.').slice(0, 300))}`);
+ }
+ });
+ r.post('/d/:id/element/gallery', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/d/${req.params.id}/element/preview`)}`);
+ const key = req.body.key || '';
+ const suffix = key ? `?key=${encodeURIComponent(key)}&listing=` : '?listing=';
+ try {
+ const { design, element } = await ctx.designs.elementOf({ id: req.params.id, owner: subjectOf(req), key, viewer: req.viewer,
+ authorName: (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker', sharing: 'gallery' });
+ const listing = await ctx.gallery.publish({ subject: subjectOf(req),
+ authorName: (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker', authorUsername: (req.viewer && req.viewer.username) || null,
+ document: element, source: 'design', sourceId: req.params.id, remixOf: design.elementRemixOf || null });
+ const value = { id: listing.id, slug: listing.slug, url: `/gallery/${encodeURIComponent(listing.slug)}`, at: new Date().toISOString() };
+ await ctx.designs.rememberHandoff({ id: req.params.id, owner: subjectOf(req), key, viewer: req.viewer, kind: 'listing', value });
+ return elementBack(req, res, suffix + 'ok');
+ } catch (err) {
+ await ctx.designs.rememberHandoff({ id: req.params.id, owner: subjectOf(req), key, viewer: req.viewer, kind: 'listing',
+ value: { error: String(err.message || 'The gallery did not accept the element.').slice(0, 300), at: new Date().toISOString() } }).catch(() => {});
+ return elementBack(req, res, suffix + `error=${encodeURIComponent(String(err.message || 'Could not publish it.').slice(0, 300))}`);
+ }
+ });
+
r.post('/d/:id/theme', express.urlencoded({ extended: false }), async (req, res) => {
r.post('/d/:id/theme', express.urlencoded({ extended: false }), async (req, res) => {
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
try {
try {
@@ -507,6 +581,122 @@ function createPageRoutes(ctx) {
@@ -507,6 +581,122 @@ function createPageRoutes(ctx) {
return page(res, 200, views.arenaRound({ round, me: meOf(req.viewer) }), req.viewer);
return page(res, 200, views.arenaRound({ round, me: meOf(req.viewer) }), req.viewer);
});
});
+ // ── Elements and packs gallery (owner correction: on Design, beside /create) ──
+ const galleryNext = (slug) => `/gallery/${encodeURIComponent(slug)}`;
+ const galleryStaff = (req) => isStaff(req.viewer);
+ async function addListingToChannel(listing, token, channel) {
+ const docs = listing.kind === 'pack' ? (Array.isArray(listing.document.items) ? listing.document.items : []) : [listing.document];
+ const items = [];
+ for (const doc of docs) {
+ try { items.push({ name: doc.name, ...(await ctx.elementHandoff.addToChannel({ element: doc, token })) }); }
+ catch (err) { return { items, error: String(err.message || 'Live did not accept the element.').slice(0, 300) }; }
+ }
+ return { items };
+ }
+ async function galleryListing(req, res, { notice = null, error = null, status = 200 } = {}) {
+ const listing = await ctx.gallery.get(req.params.slug);
+ const who = req.viewer && req.viewer.kind === 'user' ? req.viewer : null;
+ if (!listing || (listing.status !== 'published' && !galleryStaff(req) && !(who && who.subject === listing.author.subject))) return page(res, 404, views.notFound(), req.viewer);
+ const [installed, myRating] = who ? await Promise.all([ctx.gallery.installedBy(listing.id, who.subject), ctx.gallery.ratingBy(listing.id, who.subject)]) : [null, null];
+ return page(res, status, galleryView.listingPage({ listing, viewer: req.viewer || {}, installed, myRating,
+ canRemix: Boolean(who && listing.author.subject !== who.subject && listing.status === 'published'), notice, error }), req.viewer,
+ { cacheControl: status === 200 && listing.status === 'published' ? 'public, max-age=60' : 'private, no-store' });
+ }
+ r.get('/gallery', async (req, res) => {
+ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';
+ const kind = req.query.kind === 'element' || req.query.kind === 'pack' ? req.query.kind : '';
+ const sort = ['new', 'installs', 'rating'].includes(String(req.query.sort)) ? String(req.query.sort) : 'new';
+ const [listings, count] = await Promise.all([ctx.gallery.list({ q, kind, sort, limit: 60 }), ctx.gallery.count({ q, kind })]);
+ return page(res, 200, galleryView.galleryPage({ listings, count, q, kind, sort, viewer: req.viewer || {},
+ notice: req.query.published === '1' ? 'Published to the gallery.' : null }), req.viewer, { cacheControl: 'public, max-age=60' });
+ });
+ r.get('/gallery/moderation', async (req, res) => {
+ if (!galleryStaff(req)) return page(res, 403, views.notFound(), req.viewer);
+ const reports = await ctx.gallery.reports({ status: 'pending' });
+ return page(res, 200, galleryView.moderationPage({ reports, notice: req.query.done ? 'Saved.' : null }), req.viewer, { cacheControl: 'private, no-store' });
+ });
+ r.post('/gallery/moderation/reports/:id', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
+ if (!galleryStaff(req)) return res.sendStatus(403);
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ const decision = (req.body || {}).decision === 'reject' ? 'rejected' : 'accepted';
+ await ctx.gallery.reviewReport({ id: req.params.id, decision, reviewer: subjectOf(req) });
+ return res.redirect(303, '/gallery/moderation?done=1');
+ });
+ r.post('/gallery/moderation/:id', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
+ if (!galleryStaff(req)) return res.sendStatus(403);
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ const listing = await ctx.gallery.get(req.params.id);
+ if (!listing) return res.sendStatus(404);
+ await ctx.gallery.setStatus({ id: listing.id, status: listing.status === 'published' ? 'hidden' : 'published', reviewer: subjectOf(req) });
+ return res.redirect(303, galleryNext(listing.slug));
+ });
+ r.get('/gallery/:slug/try', async (req, res) => {
+ const listing = await ctx.gallery.get(req.params.slug);
+ if (!listing || listing.status !== 'published') return page(res, 404, views.notFound(), req.viewer);
+ const doc = listing.kind === 'pack' ? (listing.document.items || [])[0] : listing.document;
+ if (!doc) return page(res, 404, views.notFound(), req.viewer);
+ const sim = elementLib.simulateElement(doc);
+ let price = null;
+ try { price = await elementLib.priceOf(doc, ctx.ai); } catch { /* the price line says unknown */ }
+ return page(res, 200, elementPreviewView.elementPreview({ design: null, element: doc, sim, price, viewer: req.viewer, mode: 'try',
+ listingId: listing.id, path: `/gallery/${encodeURIComponent(listing.slug)}/try`, listingUrl: galleryNext(listing.slug) }), req.viewer,
+ { cacheControl: 'public, max-age=60' });
+ });
+ r.get('/gallery/:slug', (req, res) => galleryListing(req, res, {
+ notice: req.query.installed === '1' ? 'Installed on your channel.'
+ : req.query.rated === '1' ? 'Your rating is saved.'
+ : req.query.remixed === '1' ? 'Your remix is open in the canvas.'
+ : req.query.reported === '1' ? 'Thank you. Staff will read the report.' : null,
+ error: req.query.library_error ? String(req.query.library_error).slice(0, 300) : null,
+ }));
+ r.post('/gallery/:slug/install', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
+ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(galleryNext(req.params.slug))}`);
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ try {
+ const listing = await ctx.gallery.get(req.params.slug);
+ if (!listing) return res.sendStatus(404);
+ const library = await addListingToChannel(listing, tokenOf(req), (req.viewer && req.viewer.username) || null);
+ await ctx.gallery.install({ listingId: listing.id, subject: subjectOf(req), channel: (req.viewer && req.viewer.username) || null, library });
+ const suffix = library.error ? `?installed=1&library_error=${encodeURIComponent(library.error)}` : '?installed=1';
+ return res.redirect(303, galleryNext(req.params.slug) + suffix);
+ } catch (err) { return galleryListing(req, res, { error: err.message, status: err.status || 422 }); }
+ });
+ r.post('/gallery/:slug/rating', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
+ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(galleryNext(req.params.slug))}`);
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ try { await ctx.gallery.rate({ listingId: req.params.slug, subject: subjectOf(req), stars: (req.body || {}).stars, note: (req.body || {}).note }); }
+ catch (err) { return galleryListing(req, res, { error: err.message, status: err.status || 422 }); }
+ return res.redirect(303, `${galleryNext(req.params.slug)}?rated=1`);
+ });
+ r.post('/gallery/:slug/remix', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
+ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(galleryNext(req.params.slug))}`);
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ try {
+ const listing = await ctx.gallery.get(req.params.slug);
+ if (!listing || listing.status !== 'published') return res.sendStatus(404);
+ // An element remix opens as an editable copy in the canvas; a pack is copied as a pack listing that
+ // credits the original (a pack's items are edited one at a time, after stage 2).
+ if (listing.kind === 'pack') {
+ const document = JSON.parse(JSON.stringify(listing.document));
+ document.name = `${listing.name} (remix)`;
+ const copy = await ctx.gallery.publish({ subject: subjectOf(req),
+ authorName: (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'A maker',
+ authorUsername: (req.viewer && req.viewer.username) || null,
+ document, remixOf: listing.id });
+ return res.redirect(303, galleryNext(copy.slug));
+ }
+ const out = await ctx.designs.importElement({ element: listing.document, owner: subjectOf(req), listing, remixOf: listing.id });
+ return res.redirect(303, out.editUrl);
+ } catch (err) { return galleryListing(req, res, { error: err.message, status: err.status || 422 }); }
+ });
+ r.post('/gallery/:slug/report', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
+ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(galleryNext(req.params.slug))}`);
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ try { await ctx.gallery.report({ listingId: req.params.slug, subject: subjectOf(req), reason: (req.body || {}).reason, detail: (req.body || {}).detail }); }
+ catch (err) { return galleryListing(req, res, { error: err.message, status: err.status || 422 }); }
+ return res.redirect(303, `${galleryNext(req.params.slug)}?reported=1`);
+ });
+
r.get('/developers', (req, res) => page(res, 200, views.developers(), req.viewer));
r.get('/developers', (req, res) => page(res, 200, views.developers(), req.viewer));
r.use((req, res) => page(res, 404, views.notFound(), req.viewer));
r.use((req, res) => page(res, 404, views.notFound(), req.viewer));
@@ -14,7 +14,7 @@ const TABLES = [
@@ -14,7 +14,7 @@ const TABLES = [
{ table: 'arena_rounds', subject: 'created_by', file: 'arena_rounds.json', erase: 'delete' },
{ table: 'arena_rounds', subject: 'created_by', file: 'arena_rounds.json', erase: 'delete' },
{ table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },
{ table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },
// The designs a signed-in person asked for; a guest's design has no owner and matches nobody. Versions cascade.
// The designs a signed-in person asked for; a guest's design has no owner and matches nobody. Versions cascade.
- { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'frame', 'look', 'look_variables', 'models', 'start', 'source_design_id', 'research_id', 'estimate', 'thumbnail_svg', 'space_url', 'published_at', 'created_at', 'updated_at'] },
+ { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'frame', 'look', 'look_variables', 'models', 'start', 'source_design_id', 'research_id', 'element_remix_of', 'estimate', 'thumbnail_svg', 'space_url', 'published_at', 'created_at', 'updated_at'] },
// The deep dives a person ran and the rules they proposed: their research, its report, and their decisions.
// The deep dives a person ran and the rules they proposed: their research, its report, and their decisions.
{ table: 'design_research', subject: 'owner', file: 'deep_dives.json', order: 'created_at', columns: ['id', 'question', 'depth', 'sites', 'run_id', 'status', 'progress', 'report', 'points', 'brief', 'report_url', 'estimate', 'error', 'created_at', 'updated_at'], erase: 'delete' },
{ table: 'design_research', subject: 'owner', file: 'deep_dives.json', order: 'created_at', columns: ['id', 'question', 'depth', 'sites', 'run_id', 'status', 'progress', 'report', 'points', 'brief', 'report_url', 'estimate', 'error', 'created_at', 'updated_at'], erase: 'delete' },
{ table: 'rule_proposals', subject: 'owner', file: 'rule_proposals.json', order: 'created_at', columns: ['id', 'research_id', 'rule', 'detail', 'why', 'area', 'evidence', 'state', 'pr_url', 'pr_branch', 'decided_at', 'created_at', 'updated_at'], erase: 'delete' },
{ table: 'rule_proposals', subject: 'owner', file: 'rule_proposals.json', order: 'created_at', columns: ['id', 'research_id', 'rule', 'detail', 'why', 'area', 'evidence', 'state', 'pr_url', 'pr_branch', 'decided_at', 'created_at', 'updated_at'], erase: 'delete' },
@@ -30,6 +30,12 @@ const TABLES = [
@@ -30,6 +30,12 @@ const TABLES = [
{ table: 'ai_vectors', subject: 'owner', file: 'ai_vectors.json', order: 'created_at', columns: ['id', 'prompt', 'kind', 'svg', 'provider', 'model', 'license', 'state', 'note', 'created_at', 'updated_at'], erase: 'delete' },
{ table: 'ai_vectors', subject: 'owner', file: 'ai_vectors.json', order: 'created_at', columns: ['id', 'prompt', 'kind', 'svg', 'provider', 'model', 'license', 'state', 'note', 'created_at', 'updated_at'], erase: 'delete' },
// How many model runs a person made and when. It is their own usage; it goes with the account.
// How many model runs a person made and when. It is their own usage; it goes with the account.
{ table: 'ai_make_runs', subject: 'owner', file: 'ai_make_runs.json', order: 'created_at', columns: ['id', 'kind', 'day', 'created_at'], erase: 'delete' },
{ table: 'ai_make_runs', subject: 'owner', file: 'ai_make_runs.json', order: 'created_at', columns: ['id', 'kind', 'day', 'created_at'], erase: 'delete' },
+ // The Elements and packs gallery: the person's own listings and what they installed, rated or reported.
+ // Installing another person's listing exports the listing id and the Live ids it produced, never the author's data.
+ { table: 'design_element_listings', subject: 'author_subject', file: 'element_listings.json', order: 'created_at', columns: ['id', 'slug', 'kind', 'name', 'summary', 'document', 'look', 'source', 'source_id', 'remix_of', 'official', 'status', 'installs', 'rating_sum', 'rating_count', 'created_at', 'updated_at'], erase: 'delete' },
+ { table: 'design_element_installs', subject: 'subject', file: 'element_installs.json', order: 'created_at', columns: ['id', 'listing_id', 'channel', 'library', 'created_at'], erase: 'delete' },
+ { table: 'design_element_ratings', subject: 'subject', file: 'element_ratings.json', order: 'created_at', columns: ['id', 'listing_id', 'stars', 'note', 'created_at', 'updated_at'], erase: 'delete' },
+ { table: 'design_element_reports', subject: 'subject', file: 'element_reports.json', order: 'created_at', columns: ['id', 'listing_id', 'reason', 'detail', 'status', 'created_at'], erase: 'delete' },
];
];
async function extraExport(db, subject) {
async function extraExport(db, subject) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+'use strict';
+/** Is this session viewer OpenVibe staff? The Network role claim, ranked by the contracts' one table. */
+const { staff } = require('openvibe-contracts');
+
+function isStaff(viewer) {
+ if (!viewer || viewer.kind !== 'user' || typeof viewer.role !== 'string' || !viewer.role) return false;
+ try { return staff.atLeast(viewer.role, 'global_mod'); } catch { return false; }
+}
+
+module.exports = { isStaff };
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
*/
*/
const { createApp } = require('./app');
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
const { gracefulStop } = require('openvibe-sdk/service');
+const { seedOfficial } = require('./elements/official');
/**
/**
* The process stop (openvibe-sdk/service): stop taking connections, drain the requests in flight, stop the Arena
* The process stop (openvibe-sdk/service): stop taking connections, drain the requests in flight, stop the Arena
@@ -33,6 +34,8 @@ function createLifecycle({ server, ctx, exit, signals } = {}) {
@@ -33,6 +34,8 @@ function createLifecycle({ server, ctx, exit, signals } = {}) {
async function start() {
async function start() {
const { app, ctx } = await createApp();
const { app, ctx } = await createApp();
const { config } = ctx;
const { config } = ctx;
+ // The first official pack (LOOI), free and published by OpenVibe itself, once; a failure never blocks boot.
+ await seedOfficial(ctx.gallery, ctx.log || console);
// Socket activation (2026-10-31): systemd's .socket unit holds the listener across restarts, so a deploy
// Socket activation (2026-10-31): systemd's .socket unit holds the listener across restarts, so a deploy
// queues requests in the kernel instead of answering 502. Without the socket unit, the usual listen.
// queues requests in the kernel instead of answering 502. Without the socket unit, the usual listen.
const socketActivated = process.env.LISTEN_FDS === '1' && Number(process.env.LISTEN_PID) === process.pid;
const socketActivated = process.env.LISTEN_FDS === '1' && Number(process.env.LISTEN_PID) === process.pid;
@@ -30,6 +30,9 @@ const MAKES = [
@@ -30,6 +30,9 @@ const MAKES = [
{ id: 'mark', name: 'Mark', glyph: G('<circle cx="12" cy="12" r="8.2"/><path d="M8.4 12.4l2.4 2.4 4.8-5.4"/>'),
{ id: 'mark', name: 'Mark', glyph: G('<circle cx="12" cy="12" r="8.2"/><path d="M8.4 12.4l2.4 2.4 4.8-5.4"/>'),
placeholder: 'Describe the mark: the name, what it stands for and where it will be used…',
placeholder: 'Describe the mark: the name, what it stands for and where it will be used…',
examples: ['A mark for a robotics club', 'An app icon for a notes app', 'A monogram, two letters'] },
examples: ['A mark for a robotics club', 'An app icon for a notes app', 'A monogram, two letters'] },
+ { id: 'element', name: 'Element', glyph: G('<rect x="4" y="6" width="16" height="12" rx="6"/><circle cx="12" cy="12" r="2.4"/>'),
+ placeholder: 'Describe an element: what it looks like and what it does…',
+ examples: ['A big red button that makes the robot spin, once a minute, costs 100 points', 'A meter that fills when chat reacts', 'A button that turns the headlights on'] },
];
];
const MAKE = Object.fromEntries(MAKES.map((m) => [m.id, m]));
const MAKE = Object.fromEntries(MAKES.map((m) => [m.id, m]));
const FRAME_ICON = G('<rect x="3.5" y="5" width="12" height="9" rx="2"/><rect x="14" y="9.5" width="6.5" height="10" rx="2"/>');
const FRAME_ICON = G('<rect x="3.5" y="5" width="12" height="9" rx="2"/><rect x="14" y="9.5" width="6.5" height="10" rx="2"/>');
@@ -117,6 +120,7 @@ ${models.length ? `<p class="dv-group-h">Or choose the models yourself<small>Up
@@ -117,6 +120,7 @@ ${models.length ? `<p class="dv-group-h">Or choose the models yourself<small>Up
const kindName = Object.fromEntries(kinds.map((k) => [k.id, k]));
const kindName = Object.fromEntries(kinds.map((k) => [k.id, k]));
const lookName = Object.fromEntries(looksAll.map((t) => [t.id, t.name]));
const lookName = Object.fromEntries(looksAll.map((t) => [t.id, t.name]));
const body = `<section class="dv-create"><h1>What will you design?</h1>
const body = `<section class="dv-create"><h1>What will you design?</h1>
+<p class="dv-create-gallery"><a href="/gallery">Elements and packs gallery →</a> Free controls people made with AI: install one on your channel or remix it.</p>
<form method="post" action="/create" class="dv-composer" data-dv-composer${options ? ' data-api="/api/create"' : ''}>
<form method="post" action="/create" class="dv-composer" data-dv-composer${options ? ' data-api="/api/create"' : ''}>
${kindRow}
${kindRow}
<label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>
<label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>
@@ -210,14 +214,20 @@ function canvas({ data, themes = [], editKey = '', viewer = null, publishing = {
@@ -210,14 +214,20 @@ function canvas({ data, themes = [], editKey = '', viewer = null, publishing = {
const cards = variants.map((v, index) => {
const cards = variants.map((v, index) => {
const picked = index === pickedIndex;
const picked = index === pickedIndex;
const done = v.status === 'done';
const done = v.status === 'done';
+ const isElement = design.kind === 'element';
return `<article class="dv-card" data-status="${esc(v.status)}" data-index="${index}" data-picked="${picked ? 'true' : 'false'}" data-variant-id="${esc(v.id || '')}">
return `<article class="dv-card" data-status="${esc(v.status)}" data-index="${index}" data-picked="${picked ? 'true' : 'false'}" data-variant-id="${esc(v.id || '')}">
-<div class="dv-frame">${done ? frame(v.html, `${v.model || 'Design'} frame`, { interactive: picked }) :
+<div class="dv-frame${isElement ? ' dv-frame--element' : ''}">${isElement
+ ? (done && v.look ? `<div class="dv-el-look">${v.look}</div>`
+ : `<div class="dv-skeleton" role="status"><span>${esc(v.status === 'failed' ? 'Could not make the element' : 'Writing the element…')}</span><small>${esc(v.status === 'failed' ? String(v.element_error || v.error || 'The AI did not return a valid element.').slice(0, 240) : 'A look and a recipe together')}</small></div>`)
+ : done ? frame(v.html, `${v.model || 'Design'} frame`, { interactive: picked }) :
`<div class="dv-skeleton" role="status"><span>${esc(v.model || 'Designer')}</span><small>${esc(v.status === 'failed' ? 'Could not generate' : 'Creating…')}</small></div>`}</div>
`<div class="dv-skeleton" role="status"><span>${esc(v.model || 'Designer')}</span><small>${esc(v.status === 'failed' ? 'Could not generate' : 'Creating…')}</small></div>`}</div>
<div class="dv-label"><span class="dv-model">${esc(v.model || 'Designer')}</span>${done ?
<div class="dv-label"><span class="dv-model">${esc(v.model || 'Designer')}</span>${done ?
`<span>${(Number(v.ms || 0) / 1000).toFixed(1)} s · $${Number(v.costUsd || 0).toFixed(4)}</span>` : ''}</div>
`<span>${(Number(v.ms || 0) / 1000).toFixed(1)} s · $${Number(v.costUsd || 0).toFixed(4)}</span>` : ''}</div>
${deepDiveView.takePoints(v.research_points || v.researchPoints, design.research)}
${deepDiveView.takePoints(v.research_points || v.researchPoints, design.research)}
<div class="dv-card-actions">${picked
<div class="dv-card-actions">${picked
- ? `<span class="dv-picked">Working frame</span><span class="dv-fine">Click any element to edit it</span>`
+ ? (design.kind === 'element'
+ ? `<span class="dv-picked">Working element</span><a class="dv-fine" href="/d/${encodeURIComponent(design.id)}/element/preview${editKey ? `?key=${encodeURIComponent(editKey)}` : ''}">Preview, tweak and publish →</a>`
+ : `<span class="dv-picked">Working frame</span><span class="dv-fine">Click any element to edit it</span>`)
: done ? (pick ? '<span class="dv-fine">Not the working frame</span>'
: done ? (pick ? '<span class="dv-fine">Not the working frame</span>'
: `<form method="post" action="/d/${encodeURIComponent(design.id)}/pick"><input type="hidden" name="key" value="${esc(editKey)}"><input type="hidden" name="variantIndex" value="${index}"><button type="submit" class="dv-pick" data-pick="${index}">Use this design</button></form>`)
: `<form method="post" action="/d/${encodeURIComponent(design.id)}/pick"><input type="hidden" name="key" value="${esc(editKey)}"><input type="hidden" name="variantIndex" value="${index}"><button type="submit" class="dv-pick" data-pick="${index}">Use this design</button></form>`)
: '<span class="dv-fine">Waiting for this designer…</span>'}</div>
: '<span class="dv-fine">Waiting for this designer…</span>'}</div>
@@ -247,30 +257,33 @@ ${versionDetail(v)}</span></li>`).join('');
@@ -247,30 +257,33 @@ ${versionDetail(v)}</span></li>`).join('');
: design.status === 'done' ? 'Three designs ready. Pick one to start editing.'
: design.status === 'done' ? 'Three designs ready. Pick one to start editing.'
: design.status === 'failed' ? 'Generation failed.' : 'Designers are working…';
: design.status === 'failed' ? 'Generation failed.' : 'Designers are working…';
const keyInput = `<input type="hidden" name="key" value="${esc(editKey)}">`;
const keyInput = `<input type="hidden" name="key" value="${esc(editKey)}">`;
- const body = `<section class="dv-canvas" data-id="${esc(design.id)}" data-key="${esc(editKey)}" data-picked="${pick ? pickedIndex : ''}" data-variant-id="${esc(pickedVariant && pickedVariant.id || '')}">
-<header class="dv-heading"><a href="/create">← New design</a><h1>${esc(design.prompt)}</h1>
-<p id="dv-status" role="status">${esc(statusText)}</p>
-<p class="dv-share"><button type="button" data-ov-share-sheet data-kind="design" data-id="${esc(design.id)}"
- data-title="${esc(design.prompt)}" data-url="${esc(`/d/${encodeURIComponent(design.id)}`)}">Share</button></p></header>
-${deepDiveView.researchPanel(design.research)}
-<form class="dv-chat" id="dv-chat-form" method="post" action="/d/${encodeURIComponent(design.id)}/edit">
+ const versionsPanel = design.kind === 'element' ? '' : `<details class="dv-versions" id="dv-versions" open>
+<summary>Versions <span>${versions.length}</span></summary>
+<div class="dv-vcontrols">
+<form method="post" action="/d/${encodeURIComponent(design.id)}/undo">${keyInput}<button type="submit"${pick && pick.canUndo ? '' : ' disabled'}>Undo</button></form>
+<form method="post" action="/d/${encodeURIComponent(design.id)}/redo">${keyInput}<button type="submit"${pick && pick.canRedo ? '' : ' disabled'}>Redo</button></form>
+</div>
+${versions.length ? `<ol class="dv-vlist" id="dv-vlist">${versionRows}</ol>` : '<p class="dv-fine">Pick a frame; its HTML becomes version 1. Every accepted edit adds a version and nothing is deleted.</p>'}
+</details>`;
+ const chatPanel = design.kind === 'element' ? '' : `<form class="dv-chat" id="dv-chat-form" method="post" action="/d/${encodeURIComponent(design.id)}/edit">
${keyInput}
${keyInput}
<label for="dv-instruction">Edit with AI</label>
<label for="dv-instruction">Edit with AI</label>
<div class="dv-chat-row"><input id="dv-instruction" name="instruction" maxlength="1000" placeholder="${pick ? 'Make the hero bolder, add a pricing section…' : 'Pick a frame first'}"${canChat ? '' : ' disabled'}>
<div class="dv-chat-row"><input id="dv-instruction" name="instruction" maxlength="1000" placeholder="${pick ? 'Make the hero bolder, add a pricing section…' : 'Pick a frame first'}"${canChat ? '' : ' disabled'}>
<button type="submit"${canChat ? '' : ' disabled'}>Send</button></div>
<button type="submit"${canChat ? '' : ' disabled'}>Send</button></div>
<p class="dv-chat-status" id="dv-edit-status" role="status" aria-live="polite"></p>
<p class="dv-chat-status" id="dv-edit-status" role="status" aria-live="polite"></p>
<p class="dv-fine">${pick ? `${chatLeft} of ${pick.chatEditLimit} AI edits left on this design. Click edits and undo are free.` : 'Chat edits work on the picked frame.'}</p>
<p class="dv-fine">${pick ? `${chatLeft} of ${pick.chatEditLimit} AI edits left on this design. Click edits and undo are free.` : 'Chat edits work on the picked frame.'}</p>
-</form>
+</form>`;
+ const body = `<section class="dv-canvas" data-kind="${esc(design.kind || 'page')}" data-id="${esc(design.id)}" data-key="${esc(editKey)}" data-picked="${pick ? pickedIndex : ''}" data-variant-id="${esc(pickedVariant && pickedVariant.id || '')}">
+<header class="dv-heading"><a href="/create">← New design</a><h1>${esc(design.prompt)}</h1>
+<p id="dv-status" role="status">${esc(statusText)}</p>
+<p class="dv-share"><button type="button" data-ov-share-sheet data-kind="design" data-id="${esc(design.id)}"
+ data-title="${esc(design.prompt)}" data-url="${esc(`/d/${encodeURIComponent(design.id)}`)}">Share</button></p></header>
+${deepDiveView.researchPanel(design.research)}
+${design.kind === 'element' ? `<section class="dv-el-panel"><h2>Element</h2><p>The AI wrote the look and the recipe together. The preview runs it on sample data and moves the size, tint and label sliders; the same page adds it to your Live channel or publishes it to the Elements and packs gallery.</p><p><a class="ovk-btn ovk-btn--primary" href="/d/${encodeURIComponent(design.id)}/element/preview${editKey ? `?key=${encodeURIComponent(editKey)}` : ''}">Open the element preview</a></p></section>` : ''}
+${chatPanel}
<div class="dv-rail" id="dv-rail">${cards}</div>
<div class="dv-rail" id="dv-rail">${cards}</div>
-${publishPanel({ design, pick, viewer, editKey, publishing })}
-<details class="dv-versions" id="dv-versions" open>
-<summary>Versions <span>${versions.length}</span></summary>
-<div class="dv-vcontrols">
-<form method="post" action="/d/${encodeURIComponent(design.id)}/undo">${keyInput}<button type="submit"${pick && pick.canUndo ? '' : ' disabled'}>Undo</button></form>
-<form method="post" action="/d/${encodeURIComponent(design.id)}/redo">${keyInput}<button type="submit"${pick && pick.canRedo ? '' : ' disabled'}>Redo</button></form>
-</div>
-${versions.length ? `<ol class="dv-vlist" id="dv-vlist">${versionRows}</ol>` : '<p class="dv-fine">Pick a frame; its HTML becomes version 1. Every accepted edit adds a version and nothing is deleted.</p>'}
-</details>
+${design.kind === 'element' ? '' : publishPanel({ design, pick, viewer, editKey, publishing })}
+${versionsPanel}
<nav class="dv-toolbar" aria-label="Canvas tools"><label>Device <select id="dv-device"><option value="390">Phone · 390</option><option value="834">Tablet · 834</option><option value="1280">Desktop · 1280</option></select></label>
<nav class="dv-toolbar" aria-label="Canvas tools"><label>Device <select id="dv-device"><option value="390">Phone · 390</option><option value="834">Tablet · 834</option><option value="1280">Desktop · 1280</option></select></label>
<form method="post" action="/d/${encodeURIComponent(design.id)}/theme" id="dv-theme-form">${keyInput}<label>Theme <select id="dv-theme" name="themeId">${themeOptions}</select></label><button type="submit">Apply</button></form></nav>
<form method="post" action="/d/${encodeURIComponent(design.id)}/theme" id="dv-theme-form">${keyInput}<label>Theme <select id="dv-theme" name="themeId">${themeOptions}</select></label><button type="submit">Apply</button></form></nav>
</section>
</section>
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
+'use strict';
+
+/**
+ * The Element preview: the look, the recipe run on sample data (people of each role pressing, a fake chat and the
+ * robot actions drawn in Bot's simulated arena), the price of any AI it calls, and — for the maker — the tweak
+ * sliders, "Add to my channel", "Publish to gallery" and "Build with Codes".
+ *
+ * It is the same page for the canvas and for Exchange's "Try it": the public copy has no owner controls and no
+ * private design id in its form actions.
+ */
+const kit = require('openvibe-shared/kit');
+const { layout, esc } = require('../render/layout');
+const { renderLook } = require('../create/element');
+
+const ICON = { press: '●', chat: '💬', refused: '✕', cost: '◆', chance: '?', robot: '▸', screen: '▣', state: '▤', stream: '▶', ai: '✦', money: '¤', unknown: '·' };
+
+const money = (n) => (n == null ? '–' : `$${Number(n) < 0.1 ? Number(n).toPrecision(2) : Number(n).toFixed(2)}`);
+
+function priceLine({ design = null, price = null }) {
+ const making = design && design.estimate && Number.isFinite(Number(design.estimate.total))
+ ? `Making it: ${money(design.estimate.total)}${design.estimate.priced === false ? ' (some models unpriced)' : ''}.`
+ : '';
+ const calls = price && price.calls && price.calls.length
+ ? (price.calls.map((c) => `${c.available ? `${c.model}: ${money(c.price_in)} in / ${money(c.price_out)} out per million${c.max_cost_usd != null ? `, at most ${money(c.max_cost_usd)} a press` : ''}` : `${c.model || 'The model'}: not on the live catalogue, price unknown`}`).join(' · '))
+ : 'No AI it calls: it runs from the recipe alone.';
+ return `<p class="el-price"><b>Price</b> ${esc(making)} ${esc(calls)} Elements are free: an AI action is paid for by the person whose channel runs it, through their own Balance.</p>`;
+}
+
+function timeline(sim) {
+ if (!sim || !Array.isArray(sim.events) || !sim.events.length) return '<p class="dv-fine">No sample events.</p>';
+ return `<ol class="el-events">${sim.events.map((e) => `<li class="el-event el-event--${esc(e.kind)}"><span class="el-event-icon" aria-hidden="true">${ICON[e.kind] || '·'}</span><span class="el-event-text">${esc(e.text)}</span>${e.person ? `<small>${esc(e.person)}</small>` : ''}</li>`).join('')}</ol>`;
+}
+
+/** The robot actions only, in the shape public/js/element-sim.js plays. */
+function robotActions(sim) {
+ const actions = [];
+ for (const e of (sim && sim.events) || []) {
+ if (e.kind !== 'robot') continue;
+ const robot = e.robot && e.robot.kind === 'robot.drive_pulse' ? { ...e.robot, kind: 'robot.drive_pulse' } : e.headlight != null ? { on: e.headlight } : null;
+ actions.push({ kind: 'robot', text: e.text, robot });
+ }
+ return actions;
+}
+
+function buildWithCodes({ element, design, baseUrl }) {
+ const spec = [
+ `Build an OpenVibe element mod for: ${element.name}.`,
+ element.description || '',
+ 'The element document (ui.element@1) is below. Write the mod against ov.element@1 in the sandbox ' +
+ '(iframe, opaque origin, postMessage only, no network except the streamer-listed endpoint), keep it under 256 KB, ' +
+ 'and test it in the element test bench.',
+ JSON.stringify(element, null, 2),
+ ].join('\n\n').slice(0, 12000);
+ const href = `https://openvibe.codes/improve/OpenVibers/OpenVibe.Design?prompt=${encodeURIComponent(spec)}`;
+ return `<details class="el-codes"><summary>Build with Codes</summary>
+<p>OpenVibe.Codes writes the mod from this spec and its tests. Nothing is built until the streamer asks for it.</p>
+<label>Task text <textarea rows="6" readonly>${esc(spec)}</textarea></label>
+<p>${kit.button({ href, label: 'Build with Codes', kind: 'quiet' })} <span class="dv-fine">Opens OpenVibe.Codes with the task text prefilled.</span></p>
+</details>`;
+}
+
+function tweakForm({ design, variant, editKey }) {
+ const tweaks = (variant && variant.tweaks) || [];
+ const ranges = tweaks.filter((t) => t.kind === 'range');
+ const label = tweaks.find((t) => t.name === 'label');
+ const show = tweaks.find((t) => t.name === 'show_label');
+ if (!tweaks.length) return '';
+ return `<form class="el-tweaks" method="post" action="/d/${encodeURIComponent(design.id)}/element/tweak">
+<input type="hidden" name="key" value="${esc(editKey)}">
+<fieldset><legend>Make it yours</legend>
+${ranges.map((t) => `<label class="el-tweak"><span>${esc(t.label)}</span><input type="range" name="${esc(t.name)}" min="${Number(t.min)}" max="${Number(t.max)}" step="${Number(t.step)}" value="${Number(t.value)}" list="el-${esc(t.name)}-values"><datalist id="el-${esc(t.name)}-values">${(t.options || []).map((o, i) => `<option value="${i}">${esc(o)}</option>`).join('')}</datalist><small>${esc((t.options || [])[Number(t.value)] || '')}</small></label>`).join('')}
+${label ? `<label class="el-tweak"><span>${esc(label.label)}</span><input type="text" name="label" maxlength="${Number(label.maxlength) || 40}" value="${esc(label.value)}"></label>` : ''}
+${show ? `<label class="el-tweak el-tweak--check"><input type="checkbox" name="show_label" value="1"${show.value ? ' checked' : ''}> ${esc(show.label)}</label>` : ''}
+${kit.button({ label: 'Apply', type: 'submit', kind: 'quiet' })}
+</fieldset></form>`;
+}
+
+/**
+ * o: { design (null for a listing), element, sim, price, editKey, viewer, mode ('canvas'|'try'), notice, error,
+ * listingUrl, tryUrl, baseUrl }
+ */
+function elementPreview(o) {
+ const { design = null, element, sim = null, price = null, editKey = '', viewer = null, mode = 'canvas', notice = null, error = null } = o;
+ const mine = Boolean(design && viewer && (mode === 'canvas'));
+ const signedIn = Boolean(viewer);
+ const channel = design && design.elementChannel;
+ const listing = design && design.elementListing;
+ const variant = design && o.variant ? o.variant : null;
+ const prompts = design ? `<p class="dv-fine">Made from: “${esc(design.prompt)}”</p>${design.elementRemixOf ? `<p class="dv-fine">Remixed from <a href="/gallery/${encodeURIComponent(design.elementRemixOf)}">the original</a>.</p>` : ''}` : '';
+ const back = design ? `<a href="/d/${encodeURIComponent(design.id)}${editKey ? `?key=${encodeURIComponent(editKey)}` : ''}">← Back to the design</a>` : '<a href="/gallery">Elements and packs</a>';
+ const handoffs = mine ? `<div class="el-handoffs">
+${channel && !channel.error ? `<p class="dv-ok" role="status">On your channel${channel.url ? `: <a href="${esc(channel.url)}">${esc(channel.url.replace(/^https?:\/\//, ''))}</a>` : '.'}</p>` : ''}
+${channel && channel.error ? `<p class="dv-err" role="alert">${esc(channel.error)}</p>` : ''}
+${listing && !listing.error ? `<p class="dv-ok" role="status">In the gallery${listing.url ? `: <a href="${esc(listing.url)}">${esc(listing.url)}</a>` : '.'}</p>` : ''}
+${listing && listing.error ? `<p class="dv-err" role="alert">${esc(listing.error)}</p>` : ''}
+<form method="post" action="/d/${encodeURIComponent(design.id)}/element/channel"><input type="hidden" name="key" value="${esc(editKey)}">${kit.button({ label: channel && !channel.error ? 'Add again to my channel' : 'Add to my channel', type: 'submit' })}</form>
+<form method="post" action="/d/${encodeURIComponent(design.id)}/element/gallery"><input type="hidden" name="key" value="${esc(editKey)}">${kit.button({ label: listing && !listing.error ? 'Publish again to the gallery' : 'Publish to the gallery', type: 'submit', kind: 'quiet' })}</form>
+<p class="dv-fine">Add to my channel puts it in your Live element library. Publish puts it in the Elements and packs gallery here on Design as a free listing under your name.${signedIn ? '' : ' Sign in to use either.'}</p>
+</div>` : (mode === 'canvas' ? '<p class="dv-fine">Sign in at OpenVibe to add this element to your channel or publish it.</p>' : '');
+ const share = o.listingUrl ? `<p><a href="${esc(o.listingUrl)}">See it in the Elements and packs gallery →</a></p>` : '';
+ const tryIt = o.tryUrl ? `<p><a class="ovk-btn ovk-btn--quiet" href="${esc(o.tryUrl)}">Try it again</a></p>` : '';
+ const body = `<section class="el-page">
+<header class="el-head">${back}<h1>${esc(element.name)}</h1>
+<p>${esc(element.description || 'An element made with OpenVibe.Design.')}</p>${prompts}
+${notice ? `<p class="dv-ok" role="status">${esc(notice)}</p>` : ''}${error ? `<p class="dv-err" role="alert">${esc(error)}</p>` : ''}</header>
+<div class="el-grid">
+<div class="el-look-panel">
+${renderLook(element)}
+<p class="dv-fine">${esc(element.look.component)} · ${esc(element.look.label || '')}${element.look.style && element.look.style.tint ? ` · tint ${esc(element.look.style.tint)}` : ''}</p>
+<canvas id="el-sim" width="320" height="240" aria-label="OpenVibe.Bot's simulated arena with the element's robot actions played on a local rover"></canvas>
+<p class="dv-fine" id="el-sim-state" role="status">Bot's simulated arena. This is a picture; no robot is connected.</p>
+<script type="application/json" id="el-sim-data">${JSON.stringify({ actions: robotActions(sim) }).replace(/</g, '\\u003c')}</script>
+</div>
+<div class="el-run">
+<h2>On sample data</h2>
+<p class="dv-fine">${esc(sim && sim.note || 'The recipe is run against sample people and a fake chat.')}</p>
+${timeline(sim)}
+${priceLine({ design, price })}
+</div></div>
+${tweakForm({ design, variant, editKey })}
+${handoffs}
+${share}${tryIt}
+${design ? buildWithCodes({ element, design }) : ''}
+</section>`;
+ return layout({
+ title: `${element.name} · Element`, description: element.description || `A free ${element.look.component} element made with OpenVibe.Design.`,
+ path: o.path || (design ? `/d/${design.id}/element/preview` : `/elements/${o.listingId || ''}/try`),
+ robots: o.robots || (design ? 'noindex, nofollow' : 'index, follow'),
+ body, scripts: ['js/sim-world.js', 'js/element-sim.js'], active: design ? '/create' : '/gallery',
+ });
+}
+
+module.exports = { elementPreview, timeline, priceLine, robotActions, buildWithCodes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+
+/**
+ * The Elements and packs gallery on OpenVibe.Design: the list beside /create, one listing with Install, Remix,
+ * Try it, a rating and a report, and the staff report queue. The look is drawn from the element document with
+ * Design's own control preview; the real one is the Try it page.
+ */
+const kit = require('openvibe-shared/kit');
+const { layout, esc } = require('../render/layout');
+const { renderLook } = require('../create/element');
+
+const stars = (n) => '★★★★★'.slice(0, n) + '☆☆☆☆☆'.slice(0, 5 - n);
+const ratingText = (r) => (r && r.count ? `${r.average} / 5 from ${r.count} rating${r.count === 1 ? '' : 's'}` : 'No ratings yet');
+const lookOf = (l) => renderLook({ look: l.look || (l.document && l.document.items && l.document.items[0] && l.document.items[0].look) || { component: 'button', label: l.name } });
+
+function card(l) {
+ return `<li class="el-g-card"><a href="/gallery/${encodeURIComponent(l.slug)}">
+<span class="el-g-look">${lookOf(l)}</span>
+<span class="el-g-text"><span class="el-g-title"><b>${esc(l.name)}</b> ${kit.badge(l.kind === 'pack' ? 'Pack' : 'Element', l.official ? 'accent' : undefined)}</span>
+<small>${esc(l.author && l.author.name || 'A maker')} · ${l.installs} install${l.installs === 1 ? '' : 's'} · ${esc(ratingText(l.rating))}</small>
+<span class="el-g-summary">${esc(l.summary || '')}</span></span></a></li>`;
+}
+
+function galleryPage({ listings = [], count = 0, q = '', kind = '', sort = 'new', viewer = {}, notice = null } = {}) {
+ const signedIn = viewer.kind === 'user';
+ const body = `<section class="el-page el-gallery">
+${kit.head({ title: 'Elements and packs', sub: 'Free controls, buttons and stream widgets people made with AI. Install one on your channel, remix it, or try it first.', actions: [kit.button({ href: '/create', label: 'Make one' })] })}
+${notice ? `<p class="dv-ok" role="status">${esc(notice)}</p>` : ''}
+<div class="el-g-sec">
+<form class="el-g-search" method="get" action="/gallery" role="search">
+<input type="search" name="q" value="${esc(q)}" placeholder="Search elements and packs" aria-label="Search elements and packs">
+<select name="kind" aria-label="Kind"><option value="">All kinds</option><option value="element"${kind === 'element' ? ' selected' : ''}>Elements</option><option value="pack"${kind === 'pack' ? ' selected' : ''}>Packs</option></select>
+<select name="sort" aria-label="Sort"><option value="new"${sort === 'new' ? ' selected' : ''}>Newest</option><option value="installs"${sort === 'installs' ? ' selected' : ''}>Most installed</option><option value="rating"${sort === 'rating' ? ' selected' : ''}>Best rated</option></select>
+${kit.button({ label: 'Search', kind: 'quiet', type: 'submit' })}
+</form>
+<p class="dv-fine">${count} listing${count === 1 ? '' : 's'}, free to install.${signedIn ? ' Make one in <a href="/create">/create</a>, choose Element, then publish it from its preview.' : ''}</p>
+${listings.length ? `<ul class="el-g-list">${listings.map(card).join('')}</ul>` : kit.empty({ icon: 'spark', title: 'Nothing here yet', text: 'No listing matches that search.' })}
+</div></section>`;
+ return layout({ title: 'Elements and packs', description: 'Free buttons, controls and stream widgets people made with AI: install one on your channel, remix it, or try it first.', path: '/gallery', body, active: '/gallery' });
+}
+
+function itemList(l) {
+ if (l.kind !== 'pack' || !Array.isArray(l.document.items)) return '';
+ return `<ul class="el-g-items">${l.document.items.map((it) => `<li><b>${esc(it.name || 'Element')}</b>${it.description ? ` <small>${esc(it.description)}</small>` : ''}</li>`).join('')}</ul>`;
+}
+
+function listingPage({ listing: l, viewer = {}, installed = null, myRating = null, canRemix = false, notice = null, error = null } = {}) {
+ const signedIn = viewer.kind === 'user';
+ const next = `/gallery/${encodeURIComponent(l.slug)}`;
+ const installForm = !signedIn
+ ? kit.notice({ kind: 'info', text: `Sign in to install or rate this ${l.kind}.` }) + `<p>${kit.button({ href: `/auth/login?next=${encodeURIComponent(next)}`, label: 'Sign in with OpenVibe' })}</p>`
+ : installed
+ ? `<p class="dv-ok" role="status">${kit.badge('Installed', 'ok')} On your channel${installed.channel ? ` (${esc(installed.channel)})` : ''}${installed.library && installed.library.error ? `; Live said: ${esc(installed.library.error)}` : ''}.</p>`
+ : `<form method="post" action="${next}/install"><p>${kit.button({ label: 'Install', type: 'submit' })}</p><p class="dv-fine">Free. It goes to your Live channel and the maker is credited.</p></form>`;
+ const rateForm = signedIn && installed
+ ? `<form method="post" action="${next}/rating" class="el-g-rate"><fieldset><legend>Your rating</legend>
+<label>Stars <select name="stars" required>${[5, 4, 3, 2, 1].map((n) => `<option value="${n}"${myRating && myRating.stars === n ? ' selected' : ''}>${n} — ${stars(n)}</option>`).join('')}</select></label>
+<label>Note (optional) <input type="text" name="note" maxlength="400" value="${esc(myRating && myRating.note || '')}"></label>
+${kit.button({ label: myRating ? 'Update rating' : 'Rate it', type: 'submit', kind: 'quiet' })}</fieldset></form>`
+ : (signedIn ? '<p class="dv-fine">Install it to rate it.</p>' : '');
+ const remixForm = signedIn && canRemix
+ ? `<form method="post" action="${next}/remix" class="el-g-remix"><p>${kit.button({ label: 'Remix', type: 'submit', kind: 'quiet' })}</p><p class="dv-fine">A copy of your own that credits ${esc(l.author && l.author.name || 'the original')}.</p></form>`
+ : (signedIn ? '<p class="dv-fine">Your own element; other people can remix it and credit you.</p>' : `<p class="dv-fine"><a href="/auth/login?next=${encodeURIComponent(next)}">Sign in</a> to remix it.</p>`);
+ const reportForm = signedIn
+ ? `<details class="el-g-report"><summary>Report this ${l.kind}</summary><form method="post" action="${next}/report">
+<label>Reason <select name="reason"><option value="unsafe">Unsafe</option><option value="broken">Broken</option><option value="spam">Spam</option><option value="other">Other</option></select></label>
+<label>What is wrong? <textarea name="detail" maxlength="1000" rows="3"></textarea></label>
+${kit.button({ label: 'Send report', type: 'submit', kind: 'quiet' })}</form><p class="dv-fine">Staff read reports; nothing is hidden by a report itself.</p></details>`
+ : '';
+ return layout({
+ title: l.name, description: l.summary || `${l.name}, a free ${l.kind} for OpenVibe.`, path: next,
+ robots: l.status === 'published' ? 'index, follow' : 'noindex, nofollow',
+ active: '/gallery',
+ body: `<section class="el-page el-listing">
+${kit.head({ title: l.name, sub: `${l.kind === 'pack' ? 'Pack' : 'Element'} by ${l.author && l.author.name || 'a maker'} · ${l.installs} install${l.installs === 1 ? '' : 's'}`, back: { href: '/gallery', label: 'Elements and packs' }, actions: [kit.badge('Free', 'ok')] })}
+${notice ? `<p class="dv-ok" role="status">${esc(notice)}</p>` : ''}${error ? `<p class="dv-err" role="alert">${esc(error)}</p>` : ''}
+<div class="el-g-detail">
+<div class="el-g-preview">${lookOf(l)}</div>
+<div class="el-g-side">
+${l.summary ? `<p class="dv-prose">${esc(l.summary)}</p>` : ''}
+${l.remix_of ? `<p class="dv-fine">Remixed from <a href="/gallery/${encodeURIComponent(l.remix_of)}">an earlier element</a>.</p>` : ''}
+<h2>Install</h2>${installForm}
+<h2>Try it</h2><p><a class="ovk-btn ovk-btn--quiet" href="${next}/try">Try it</a></p>
+<h2>Remix</h2>${remixForm}
+<h2>Rating</h2><p>${esc(ratingText(l.rating))}</p>${rateForm}
+${itemList(l)}
+<h2>Report</h2>${reportForm}
+</div></div></section>`,
+ });
+}
+
+function moderationPage({ reports = [], notice = null } = {}) {
+ return layout({
+ title: 'Element reports', description: 'Staff reports on the Elements gallery.', path: '/gallery/moderation', robots: 'noindex, nofollow', active: '/gallery',
+ body: `<section class="el-page">
+${kit.head({ title: 'Element reports', sub: 'Pending reports from people. Accepting one hides the listing; rejecting one leaves it be.', back: { href: '/gallery', label: 'Elements and packs' } })}
+${notice ? `<p class="dv-ok" role="status">${esc(notice)}</p>` : ''}
+${reports.length ? `<ul class="el-g-reports">${reports.map((r) => `<li class="el-g-report-row">
+<p><a href="/gallery/${encodeURIComponent(r.slug)}"><b>${esc(r.name)}</b></a> ${kit.badge(r.listing_status)}</p>
+<p>${esc(r.reason)} · ${esc(new Date(r.created_at).toISOString().slice(0, 16).replace('T', ' '))}${r.detail ? ` — ${esc(r.detail)}` : ''}</p>
+<form method="post" action="/gallery/moderation/reports/${encodeURIComponent(r.id)}">
+${kit.button({ label: 'Accept: hide the listing', type: 'submit', kind: 'primary' })}
+${kit.button({ label: 'Reject', type: 'submit', kind: 'quiet', name: 'decision', value: 'reject' })}
+</form></li>`).join('')}</ul>` : kit.empty({ icon: 'shield', title: 'No pending reports', text: 'Nothing for staff to read right now.' })}
+</section>`,
+ });
+}
+
+module.exports = { galleryPage, listingPage, moderationPage };
@@ -84,7 +84,8 @@ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
@@ -84,7 +84,8 @@ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
// what is made, who designs it, and the recent designs as cards that say what each is.
// what is made, who designs it, and the recent designs as cards that say what each is.
const kindsShown = [...recent.text.matchAll(/name="kind" value="([a-z-]+)"/g)].map((m) => m[1]);
const kindsShown = [...recent.text.matchAll(/name="kind" value="([a-z-]+)"/g)].map((m) => m[1]);
assert.deepEqual(kindsShown.slice(0, 3), ['page', 'screen', 'component'], 'the things that can be made, the first three first');
assert.deepEqual(kindsShown.slice(0, 3), ['page', 'screen', 'component'], 'the things that can be made, the first three first');
- assert.ok(kindsShown.length === 3 || kindsShown.length === 7, 'three without the options, seven with them');
+ assert.ok(kindsShown.length === 3 || kindsShown.length === 8, 'three without the options, eight with them (Element included)');
+ if (kindsShown.length === 8) assert.ok(kindsShown.includes('element'), 'Element is one of the things that can be made');
assert.deepEqual([...recent.text.matchAll(/name="who" value="([a-z]+)"/g)].map((m) => m[1]), ['claude', 'sonnet', 'any', 'compare'], 'the four ways to pick who designs');
assert.deepEqual([...recent.text.matchAll(/name="who" value="([a-z]+)"/g)].map((m) => m[1]), ['claude', 'sonnet', 'any', 'compare'], 'the four ways to pick who designs');
assert.match(recent.text, /href="https:\/\/openvibe\.exchange\/models">Compare these models on OpenVibe Exchange<\/a>/, 'the models are compared on Exchange');
assert.match(recent.text, /href="https:\/\/openvibe\.exchange\/models">Compare these models on OpenVibe Exchange<\/a>/, 'the models are compared on Exchange');
assert.match(recent.text, /<a class="dv-rc" href="\/d\/[^"]+"><span class="dv-rc-k" aria-hidden="true"><(?:svg|img src="\/api\/create\/designs\/)/, 'a recent design is a card with its picture (or its kind drawn where there is none)');
assert.match(recent.text, /<a class="dv-rc" href="\/d\/[^"]+"><span class="dv-rc-k" aria-hidden="true"><(?:svg|img src="\/api\/create\/designs\/)/, 'a recent design is a card with its picture (or its kind drawn where there is none)');
new file mode 100644
new file mode 100644
@@ -0,0 +1,252 @@
@@ -0,0 +1,252 @@
+'use strict';
+/**
+ * Design /create's kind Element and the Elements and packs gallery beside it (owner correction, 2026-11-11:
+ * the gallery is on Design; Exchange is only the AI price catalogue). The AI writes a ui.element@1 look and
+ * recipe as JSON, a bad take is asked again once with its reasons, the picked element runs on sample data
+ * (never a robot), the generated sliders move size, tint and label, and the element is added to Live and
+ * published, installed, rated, remixed and reported on Design itself.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+
+const ELEMENT = {
+ name: 'Big red button',
+ description: 'A big red button that makes the robot spin, once a minute, for 100 points.',
+ look: { component: 'button', label: 'Spin', icon: 'spark', props: { size: 'large' }, style: { tint: 'accent', shape: 'pill', opacity: 1, show_label: true } },
+ recipe: {
+ triggers: [{ kind: 'press' }],
+ conditions: { role: 'everyone', cost: { points: 100 }, cooldown: { per_person_seconds: 60, overall_seconds: 60 } },
+ actions: [{ kind: 'robot.drive_pulse', product: 'bot', direction: 'spin_left', ms: 2000 }],
+ },
+ capabilities: ['robot.drive_pulse'],
+ initial_state: { spins: 0 },
+};
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const srv = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', async () => {
+ const body = Buffer.concat(chunks).toString('utf8');
+ let json = {};
+ try { json = JSON.parse(body || '{}'); } catch { /* empty */ }
+ const answer = (status, doc) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(doc)); };
+ try { await handler(req, json, answer); } catch (err) { answer(500, { detail: err.message }); }
+ });
+ });
+ srv.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${srv.address().port}`, close: () => new Promise((r) => srv.close(r)) }));
+ });
+}
+
+const fakeAi = (state, doc = ELEMENT) => ({
+ designPlan: async () => ({ slots: [{ provider: 'acme', model: 'alpha-1' }], prefer: { provider: 'acme', model: 'alpha-1' }, pin: true }),
+ text: async ({ prompt, system }) => {
+ state.prompts.push(prompt);
+ (state.systems = state.systems || []).push(system);
+ const answer = state.badFirst && state.prompts.length === 1 ? JSON.stringify({ name: 'Broken' }) : JSON.stringify(doc);
+ return { text: answer, model: 'alpha-1', provider: 'acme', run_id: `run_${state.prompts.length}`, cost_usd: 0.0012, ms: 300 };
+ },
+ exchangeCatalog: async () => ({ models: [{ slug: 'acme/alpha-1', name: 'Alpha', offers: [{ price: { in: 1, out: 2 } }] }] }),
+});
+
+(async () => {
+ const aiState = { prompts: [], systems: [], badFirst: false };
+ const ai = fakeAi(aiState);
+ const liveCalls = [];
+ const live = await listen((req, body, answer) => {
+ liveCalls.push({ url: req.url, auth: Boolean(req.headers.authorization), body });
+ answer(201, { id: 'el_LIVE0000000000000000000000', url: 'https://openvibe.live/elements/el_LIVE0000000000000000000000' });
+ });
+ const t = await boot({ ai, env: { OV_LIVE_URL: live.url } });
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const mod = t.network.addUser('mod', { role: 'global_mod' });
+ const token = (u) => ({ authorization: `Bearer ${t.network.userToken(u)}` });
+ const read = async (path, as = alice) => { for (let i = 0; i < 80; i += 1) { const r = await t.get(path, { as }); const d = r.json(); if (d.variants.every((v) => v.status === 'done' || v.status === 'failed')) return r; await new Promise((r) => setTimeout(r, 25)); } throw new Error('generation did not finish'); };
+
+ try {
+ await check('an Element design is generated as one JSON document, never HTML', async () => {
+ const r = await t.get('/api/v1/designs', { headers: token(alice), json: { prompt: 'a big red button that makes the robot spin, once a minute, costs 100 points', kind: 'element' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const id = r.json().design.id;
+ const data = (await read(`/api/v1/designs/${id}`)).json();
+ const v = data.variants[0];
+ assert.strictEqual(v.status, 'done', v.error || '');
+ assert.strictEqual(v.element.name, 'Big red button');
+ assert.strictEqual(v.element.look.component, 'button');
+ assert.strictEqual(v.element.recipe.actions[0].kind, 'robot.drive_pulse');
+ assert.ok(!v.html.includes('<'), 'an element is not an HTML document');
+ assert.ok(v.look.includes('dv-elx'), 'the look is drawn from the component');
+ t.designId = id;
+ t.elementVariant = v;
+ assert.ok(aiState.systems[0].includes('ui.element@1'), 'the prompt asks for the element contract');
+ assert.ok(aiState.systems[0].includes('robot.drive_pulse'), 'the prompt carries the action catalog');
+ });
+
+ await check('an invalid first take is asked once more with the reasons', async () => {
+ aiState.badFirst = true;
+ aiState.prompts = [];
+ const r = await t.get('/api/v1/designs', { headers: token(alice), json: { prompt: 'a big red button', kind: 'element' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const data = (await read(`/api/v1/designs/${r.json().design.id}`)).json();
+ assert.strictEqual(aiState.prompts.length, 2, 'one retry');
+ assert.match(aiState.prompts[1], /refused for these reasons/);
+ assert.strictEqual(data.variants[0].status, 'done');
+ aiState.badFirst = false;
+ });
+
+ await check('pick, then the generated sliders move size, tint and label', async () => {
+ const picked = await t.get(`/api/v1/designs/${t.designId}/pick`, { headers: token(alice), json: { variantIndex: 0 } });
+ assert.strictEqual(picked.status, 201, picked.text);
+ const page = await t.get(`/d/${t.designId}`, { as: alice });
+ assert.strictEqual(page.status, 200, page.text);
+ assert.ok(page.text.includes('Open the element preview'));
+ assert.ok(!page.text.includes('Edit with AI'), 'an element is changed with its sliders, not a chat edit');
+ const first = t.elementVariant;
+ const tweak = await t.get(`/api/v1/designs/${t.designId}/element/tweak`, { headers: token(alice), json: { size: 0, tint: 1, label: 'Spin it!' } });
+ assert.strictEqual(tweak.status, 200, tweak.text);
+ const v = tweak.json().variants[0];
+ assert.strictEqual(v.element.look.label, 'Spin it!');
+ assert.strictEqual(v.element.look.props.size, 'small');
+ assert.notStrictEqual(v.element.look.style.tint, first.element.look.style.tint);
+ assert.ok(v.tweaks.find((x) => x.name === 'size'));
+ });
+
+ await check('the preview runs the recipe on sample data: roles, cost, cooldown and a simulated robot', async () => {
+ const sim = await t.get(`/api/v1/designs/${t.designId}/element/simulate`, { as: alice });
+ assert.strictEqual(sim.status, 200, sim.text);
+ const out = sim.json();
+ assert.ok(out.events.some((e) => e.kind === 'press'));
+ assert.ok(out.events.some((e) => e.kind === 'cost' && e.text.includes('100')));
+ assert.ok(out.events.some((e) => e.kind === 'refused' && /cooldown/.test(e.text)));
+ assert.ok(out.events.some((e) => e.kind === 'robot' && /simulated/.test(e.text)));
+ assert.ok(!out.events.some((e) => e.kind === 'ai'), 'nothing calls AI');
+ const page = await t.get(`/d/${t.designId}/element/preview`, { as: alice });
+ assert.strictEqual(page.status, 200, page.text);
+ assert.ok(page.text.includes('On sample data'));
+ assert.ok(page.text.includes("Bot's simulated arena"));
+ assert.ok(page.text.includes('Build with Codes'));
+ assert.ok(page.text.includes('id="el-sim"'));
+ });
+
+ await check('Add to my channel and Publish to the gallery both use the person\'s own token', async () => {
+ const channel = await t.get(`/d/${t.designId}/element/channel`, { headers: token(alice), body: 'key=' });
+ assert.strictEqual(channel.status, 303, channel.text);
+ assert.strictEqual(liveCalls.length, 1);
+ assert.strictEqual(liveCalls[0].url, '/api/me/elements');
+ assert.strictEqual(liveCalls[0].body.author.name, 'alice');
+ assert.strictEqual(liveCalls[0].body.sharing, 'private');
+ const published = await t.get(`/d/${t.designId}/element/gallery`, { headers: token(alice), body: 'key=' });
+ assert.strictEqual(published.status, 303, published.text);
+ const page = await t.get(`/d/${t.designId}/element/preview`, { as: alice });
+ assert.ok(page.text.includes('On your channel'));
+ assert.ok(page.text.includes('In the gallery'));
+ const gallery = await t.get('/gallery');
+ assert.strictEqual(gallery.status, 200, gallery.text);
+ assert.ok(gallery.text.includes('Big red button'));
+ const listing = await t.get('/gallery/big-red-button');
+ assert.strictEqual(listing.status, 200, listing.text);
+ assert.ok(listing.text.includes('Free'));
+ assert.ok(listing.text.includes('Try it'));
+ });
+
+ await check('another person installs it, rates it and remixes it with credit', async () => {
+ const install = await t.get('/gallery/big-red-button/install', { headers: token(bob), body: 'key=' });
+ assert.strictEqual(install.status, 303, install.text);
+ assert.ok(liveCalls.some((c) => c.url === '/api/me/elements'), 'the install goes to Live too');
+ const page = await t.get('/gallery/big-red-button', { as: bob });
+ assert.ok(page.text.includes('Installed'));
+ const rate = await t.get('/gallery/big-red-button/rating', { headers: { ...token(bob), 'content-type': 'application/x-www-form-urlencoded' }, body: 'stars=5¬e=It+spins' });
+ assert.strictEqual(rate.status, 303, rate.text);
+ const rated = await t.get('/gallery/big-red-button');
+ assert.ok(rated.text.includes('5 / 5 from 1 rating'));
+ const remix = await t.get('/gallery/big-red-button/remix', { headers: token(bob), body: 'key=' });
+ assert.strictEqual(remix.status, 303, remix.text);
+ const remixId = /^\/d\/([^?]+)/.exec(remix.headers.get('location') || '')?.[1];
+ assert.ok(remixId, 'the remix opens in the canvas');
+ const copy = await t.get(`/d/${remixId}`, { as: bob });
+ assert.strictEqual(copy.status, 200, copy.text);
+ assert.ok(copy.text.includes('Open the element preview'));
+ const data = (await t.get(`/api/v1/designs/${remixId}`, { as: bob })).json();
+ const original = (await t.get(`/api/v1/designs/${t.designId}`, { as: alice })).json();
+ t.listingId = original.design.elementListing.id;
+ assert.strictEqual(data.design.elementRemixOf, t.listingId, 'the remix credits the original');
+ // Publishing the remix keeps the credit on the listing.
+ const published = await t.get(`/d/${remixId}/element/gallery`, { headers: token(bob), body: 'key=' });
+ assert.strictEqual(published.status, 303, published.text);
+ const remixListing = t.ctx.gallery.get('big-red-button-remix');
+ const copyListing = await t.get('/gallery/big-red-button-remix');
+ assert.strictEqual((await remixListing).remix_of, t.listingId, 'the published remix keeps the credit');
+ assert.strictEqual(copyListing.status, 200, copyListing.text);
+ });
+
+ await check('Try it runs the public listing; a report reaches staff and hides it', async () => {
+ const tryPage = await t.get('/gallery/big-red-button/try');
+ assert.strictEqual(tryPage.status, 200, tryPage.text);
+ assert.ok(tryPage.text.includes('On sample data'));
+ assert.ok(tryPage.text.includes('See it in the Elements and packs gallery'));
+ const report = await t.get('/gallery/big-red-button/report', { headers: { ...token(bob), 'content-type': 'application/x-www-form-urlencoded' }, body: 'reason=broken&detail=The+button+does+nothing' });
+ assert.strictEqual(report.status, 303, report.text);
+ const queue = await t.get('/gallery/moderation', { as: mod });
+ assert.strictEqual(queue.status, 200, queue.text);
+ assert.ok(queue.text.includes('The button does nothing'));
+ const accept = await t.get('/gallery/moderation/reports/' + /\/gallery\/moderation\/reports\/([^"]+)/.exec(queue.text)?.[1], { headers: token(mod), body: 'key=' });
+ assert.strictEqual(accept.status, 303, accept.text);
+ assert.strictEqual((await t.get('/gallery/big-red-button')).status, 404, 'a hidden listing is not public');
+ });
+
+ await check('a look prop cannot smuggle a colour or a URL into the gallery', async () => {
+ const bad = { ...ELEMENT, name: 'Prop colour', look: { ...ELEMENT.look, props: { size: 'large', colour: '#ff0000' } } };
+ const r = await t.get('/api/v1/gallery/listings', { headers: token(alice), json: { document: bad } });
+ assert.strictEqual(r.status, 422, r.text);
+ assert.match(r.json().detail, /literal colour/);
+ });
+
+ await check('the price line names the AI an element calls, from the live catalogue', async () => {
+ const elementLib = require('../server/create/element');
+ const priced = await elementLib.priceOf({ recipe: { actions: [{ kind: 'ai.ask', product: 'ai', prompt: 'Is it good?', model: 'acme/alpha-1' }] } }, ai);
+ assert.ok(priced.calls.length === 1);
+ assert.strictEqual(priced.calls[0].price_in, 1);
+ assert.strictEqual(priced.calls[0].price_out, 2);
+ assert.match(priced.text, /\$1\/M in/);
+ });
+
+ await check('Live can hand a validated element to the gallery with the person\'s own token', async () => {
+ const doc = { ...ELEMENT, name: 'Live sent button' };
+ const r = await t.get('/api/v1/gallery/listings', { headers: token(alice), json: { document: doc, source: 'live', source_id: 'live_1' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().slug, 'live-sent-button');
+ assert.strictEqual(r.json().source, 'live');
+ const one = await t.get('/api/v1/gallery/listings/live-sent-button');
+ assert.strictEqual(one.status, 200, one.text);
+ });
+
+ await check('the official LOOI pack is published by OpenVibe once', async () => {
+ const { seedOfficial } = require('../server/elements/official');
+ const first = await seedOfficial(t.ctx.gallery, { log() {}, warn() {} });
+ const again = await seedOfficial(t.ctx.gallery, { log() {}, warn() {} });
+ assert.ok(first && first.id);
+ assert.strictEqual(first.official, true);
+ assert.strictEqual(first.author.subject, null);
+ assert.strictEqual(first.kind, 'pack');
+ assert.strictEqual(again.id, first.id);
+ const page = await t.get('/gallery/looi-pack');
+ assert.strictEqual(page.status, 200, page.text);
+ assert.ok(page.text.includes('LOOI pack'));
+ assert.ok(page.text.includes('Headlights on'));
+ const remix = await t.get('/gallery/looi-pack/remix', { headers: token(bob), body: 'key=' });
+ assert.strictEqual(remix.status, 303, remix.text);
+ const copySlug = /\/gallery\/([^?]+)/.exec(remix.headers.get('location') || '')?.[1];
+ assert.strictEqual(copySlug, 'looi-pack-remix');
+ const copy = await t.ctx.gallery.get(copySlug);
+ assert.strictEqual(copy.remix_of, first.id, 'a pack remix credits the original');
+ assert.strictEqual(copy.kind, 'pack');
+ });
+ } finally {
+ await t.close();
+ await live.close();
+ }
+ return done();
+})();