openvibers/openvibe.design Commits

Elements: make a control with AI, and the Elements and packs gallery (#283)

OP OpenVibers committed 1 days ago · 976bef6f72 · 26 files, +2357 −24

* Design: the Element kind and the Elements and packs gallery

/create gets the kind Element: the AI writes the ui.element@1 look and
recipe as JSON, the SDK validates it (one retry with the reasons), and the
preview runs it on sample data (people of every role, a fake chat, Bot's
simulated arena, no robot). The generated sliders move size, tint and
label; the price line reads any AI the element calls from Exchange's live
catalogue.

The Elements and packs gallery lives beside /create (owner correction):
publish a picked element or a pack, install it on your Live channel in a
tap, remix it as an editable copy that credits the original, rate what you
installed, report a listing, and staff review the reports. The LOOI pack
is the first official pack. Exchange stays the AI model catalogue and is
read only for live prices.

* Elements: use Live's own element library path and show remix credit

* Gallery: a JSON hand-off for Live, and fill a draft's author and sharing

* Gallery: look props content check and a sitemap entry after the doors

* Account data: the gallery's listings, installs, ratings and reports

* Gallery: remix a pack as a copy that credits the original

* Trigger CI

* CI: allow a manual run when a push event is not scheduled

* Gallery: keep the listing header on one line and say an own element can be remixed

* Trigger the pull request check

* Merge origin/main: deep dives and the component registry, with the gallery's migrations renumbered

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex e5c54f3..6426ae1 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -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:
ddiff --git a/migrations/0023_element_kind.sql b/migrations/0023_element_kind.sql
nnew file mode 100644
iindex 0000000..f4d48db
---- /dev/null
++++ b/migrations/0023_element_kind.sql
@@@ -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;
ddiff --git a/migrations/0024_element_gallery.sql b/migrations/0024_element_gallery.sql
nnew file mode 100644
iindex 0000000..1cd12df
---- /dev/null
++++ b/migrations/0024_element_gallery.sql
@@@ -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);
ddiff --git a/public/css/design.css b/public/css/design.css
iindex 3dd5f75..b6b8e4d 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -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; }
ddiff --git a/public/js/element-sim.js b/public/js/element-sim.js
nnew file mode 100644
iindex 0000000..c81aba5
---- /dev/null
++++ b/public/js/element-sim.js
@@@ -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);
++})();
ddiff --git a/public/js/sim-world.js b/public/js/sim-world.js
nnew file mode 100644
iindex 0000000..74c8615
---- /dev/null
++++ b/public/js/sim-world.js
@@@ -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 };
++}));
ddiff --git a/server/app.js b/server/app.js
iindex eb8d1a5..6e3b006 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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));
ddiff --git a/server/config.js b/server/config.js
iindex 009617c..10dc643 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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.
ddiff --git a/server/create/element.js b/server/create/element.js
nnew file mode 100644
iindex 0000000..629b4c4
---- /dev/null
++++ b/server/create/element.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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,
++};
ddiff --git a/server/create/kinds.js b/server/create/kinds.js
iindex 9acba0d..3e50172 100644
---- a/server/create/kinds.js
++++ b/server/create/kinds.js
@@@ -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';
}
}
ddiff --git a/server/designs.js b/server/designs.js
iindex 45dadd0..76f7a3e 100644
---- a/server/designs.js
++++ b/server/designs.js
@@@ -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 = consol
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),
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 = consol
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()]);
}
++ /**
++ * 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 = consol
await 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 = consol
if (!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 = consol
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);
++ 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 = 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");
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 = consol
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.');
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 = consol
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 };
ddiff --git a/server/elements/gallery.js b/server/elements/gallery.js
nnew file mode 100644
iindex 0000000..59f2dda
---- /dev/null
++++ b/server/elements/gallery.js
@@@ -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 };
ddiff --git a/server/elements/handoff.js b/server/elements/handoff.js
nnew file mode 100644
iindex 0000000..5e7655f
---- /dev/null
++++ b/server/elements/handoff.js
@@@ -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 };
ddiff --git a/server/elements/official.js b/server/elements/official.js
nnew file mode 100644
iindex 0000000..424656d
---- /dev/null
++++ b/server/elements/official.js
@@@ -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 };
ddiff --git a/server/http/design-api.js b/server/http/design-api.js
iindex a1c5ec8..8cae5ab 100644
---- a/server/http/design-api.js
++++ b/server/http/design-api.js
@@@ -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,
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex e69026c..c0a48d8 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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.' },
ddiff --git a/server/http/gallery-api.js b/server/http/gallery-api.js
nnew file mode 100644
iindex 0000000..71030a1
---- /dev/null
++++ b/server/http/gallery-api.js
@@@ -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 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 946e789..b616313 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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));
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex fb2e129..54f19fc 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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) {
ddiff --git a/server/identity/staff.js b/server/identity/staff.js
nnew file mode 100644
iindex 0000000..3946ceb
---- /dev/null
++++ b/server/identity/staff.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex f5d9536..aeb85f0 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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;
ddiff --git a/server/views/design.js b/server/views/design.js
iindex 4bc1589..2237096 100644
---- a/server/views/design.js
++++ b/server/views/design.js
@@@ -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>Up
const 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>
ddiff --git a/server/views/element-preview.js b/server/views/element-preview.js
nnew file mode 100644
iindex 0000000..e8e0241
---- /dev/null
++++ b/server/views/element-preview.js
@@@ -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 };
ddiff --git a/server/views/gallery.js b/server/views/gallery.js
nnew file mode 100644
iindex 0000000..18262d5
---- /dev/null
++++ b/server/views/gallery.js
@@@ -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 };
ddiff --git a/test/design.test.js b/test/design.test.js
iindex 7eb5a1c..5093dc3 100644
---- a/test/design.test.js
++++ b/test/design.test.js
@@@ -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)');
ddiff --git a/test/element.test.js b/test/element.test.js
nnew file mode 100644
iindex 0000000..02cfe9d
---- /dev/null
++++ b/test/element.test.js
@@@ -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&note=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();
++})();