openvibers/openvibe.bot Commits
Bot: make your own controls, and the Arrange editor on the panel and the overlay (#405)
Changes
@@@ -3,6 +3,7 @@ on:push:branches: [main]pull_request:++ workflow_dispatch:jobs:# install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).test:@@@ -18,6 +18,15 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.- The front page, `GET /` (`server/web/home.js`, `public/home.css`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator, routines, webhooks and the agent switch), and data sections rendered on the server, each left out when it has nothing to show: numbers from the catalogue, the simulated rover and the listed robots with their online state, every catalogue model with its connection kind and controls, the connection kinds with their models, the first kit's parts list, robotics jobs from OpenVibe.Search (`server/web/network-reads.js`: OpenVibe.Work's documents, read anonymously with `BOT_SEARCH_URL`, cached 15 minutes) and the questions people ask first (JSON-LD WebSite, WebApplication, ItemList and FAQPage). It has its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.++- **Make your own controls.** The panel and the controls over a stream are arrangeable surfaces: Bot publishes++ `bot.controls` and `bot.overlay` as `ui.surface@1` catalogs built from the robot's own profile, and each person's++ `ui.layout@1` (kept with their OpenVibe.Network account, one per device class) is read server-side and drawn on the++ real parts before first paint. **Arrange** loads `openvibe-shared/ov-arrange.js` only then: drag to move, the corner++ to resize, arrows and Shift with the arrows, a long press for a part's visible/size/colour/shape/opacity/label and++ bindings, live validation through `openvibe-sdk/ui-layout` with the reason in plain words, Undo, Reset to default,++ Save and Share, and **Ask OpenVibe** previewed on the parts with Keep and Discard. Stop and the overlay's e-stop are++ required; a layout only arranges, and every command still passes the same gate and the robot's limits. The old++ browser store (`ov-bot-pad-prefs`: Stop's hand, pad size, readouts) is migrated into the person's first layout once.- The robot directory (`server/web/directory.js`, inside the OpenVibe Frame): `/robots` is public and lists the robots their owners list (online state, model, owner, who may drive), every model Bot drives and, signed in, your own robots; `/models` and `/models/:id` say how each model connects, its setup steps, its guide and the controls its panel gets; `/r/:id` is a listed robot's public page, watching it live through the embeddable panel; `/robots/new` adds one (name, what it is, who may drive). The owner lists a robot from its panel (`POST /robots/:id/listing`, migration 0009): listing turns on watching (`embed_public`), and turning watching off unlists it.- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, e-stop, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.- The owner's agent: the panel's **Your agent** card ("Let my OpenVibe.Actor agent drive this robot", `robots.agent_control`, migration 0012) lets `svc:actor`, acting for the robot's owner and nobody else, read the robot (`GET /api/v1/robots/:id/steps`: online, the e-stop, battery, sensors and the step lines it takes) and drive it with plain step lines (`POST …/steps`, at most 20 steps and 30 seconds, answered when they end) or one command (`POST …/commands`). Every step passes the same gate as the owner's own commands and is audited as the service. Off, or for anyone but the owner, it answers `403 bot.agent_not_allowed`.@@@ -146,7 +155,7 @@ Sitemap entries use the release commit time for these code-backed pages.- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).- OpenRestream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`; Bot mints its own Network service token for audience `openvibe.billing` with `billing.usage.record`, and `BOT_BILLING_TOKEN` overrides it); unset, they wait in `run_usage_outbox`.--- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.150.0 and `openvibe-shared` v4.211.0 (package.json).++- `openvibe-contracts` v0.410.0, `openvibe-sdk` v0.152.0 and `openvibe-shared` v4.214.0 (package.json).- Account export and deletion (ADR-033): Network grants `events.subscription.manage` (openvibe.events) for the twosubscriptions created at boot, then, last and once the release is live, `network.account.export.contribute` and`network.account.deletion.confirm` (openvibe.network).@@@ -236,7 +245,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras BotOpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.<!-- versions:start -->--- openvibe-contracts: v0.408.0--- openvibe-sdk: v0.150.0--- openvibe-shared: v4.211.0++- openvibe-contracts: v0.410.0++- openvibe-sdk: v0.152.0++- openvibe-shared: v4.214.0<!-- versions:end -->@@@ -5,13 +5,13 @@"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step","port": 4630,"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",-- "contracts": "openvibe-contracts v0.409.0",-- "sdk": "openvibe-sdk v0.150.0",-- "shared": "openvibe-shared v4.211.0",++ "contracts": "openvibe-contracts v0.410.0",++ "sdk": "openvibe-sdk v0.152.0",++ "shared": "openvibe-shared v4.214.0","packages": [-- "openvibe-contracts v0.409.0",-- "openvibe-sdk v0.150.0",-- "openvibe-shared v4.211.0"++ "openvibe-contracts v0.410.0",++ "openvibe-sdk v0.152.0",++ "openvibe-shared v4.214.0"],"capabilities": ["bot.robot.read",@@@ -65,7 +65,8 @@"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects","a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)","machines hub (plan L34): Bot stays the registry; the hub's routes (GET /api/v1/machines, GET /api/v1/machines/:id, POST /api/v1/machines/pairings, PATCH /api/v1/machines/:id, PUT /api/v1/machines/:id/roles, POST /api/v1/machines/:id/revoke) serve the person and the network's own service (svc:network, and only that one) acting for them (bot.machine.manage, X-OV-Subject), with the machine's roles (migration 0028) in the device's config frame and its online state reported to Network once per change",-- "watch links to a desk (plan L55): POST /api/v1/machines/:id/watch-links gives the owner's agent (svc:actor, bot.machine.watch_link) a link that shows the desk the agent works on, live, inside a chat (https://openvibe.bot/screen/<token>, no sign-in, framable on openvibe.chat and openvibe.actor); the machine is the one the named person owns, the link lives 5 to 240 minutes, and it carries the mouse and keyboard only when asked for"++ "watch links to a desk (plan L55): POST /api/v1/machines/:id/watch-links gives the owner's agent (svc:actor, bot.machine.watch_link) a link that shows the desk the agent works on, live, inside a chat (https://openvibe.bot/screen/<token>, no sign-in, framable on openvibe.chat and openvibe.actor); the machine is the one the named person owns, the link lives 5 to 240 minutes, and it carries the mouse and keyboard only when asked for",++ "make your own controls (stage 2): the panel's bot.controls and the overlay's bot.overlay are ui.surface@1 catalogs built from the robot's profile, each person's ui.layout@1 comes from OpenVibe.Network and is drawn server-side before first paint, Arrange (openvibe-shared/ov-arrange.js, loaded only on press) moves, resizes, styles and hides parts with live openvibe-sdk/ui-layout validation, Undo/Reset/Save/Share and an Ask OpenVibe suggestion previewed before it is kept; Stop and the e-stop are required and robot commands are untouched"],-- "updated": "2026-11-11"++ "updated": "2026-11-12"}@@@ -13,9 +13,9 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0","pg": "^8.23.0","qrcode-generator": "^2.0.4","ws": "^8.18.0"@@@ -1201,9 +1201,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.409.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",-- "integrity": "sha512-Yh8XcOEKIrYG7WV8ack03hmobZwMyZIeOogIrCeIWvuHKpvjYYL+UcOj7DrNalxC5fp0EnhVwRj89DTFm/AH2g==",++ "version": "0.410.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",++ "integrity": "sha512-rmmq2mebXim2fv+sip2YQ0PrrZLxGUhEfe1w+1EFfGdoVvLI5tp0/eavY2sW6/VU+rzs7cQMC/A0NZD5th5wrA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1217,9 +1217,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.150.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",-- "integrity": "sha512-5k2PovDQ2zhYXxLHtNNm1Q3rzPMXLpdh0lIYdl9kfa6D2/X2gYozT5W0CHRCIMAK4UhjbsZaXOSMM8GbkJWsNw==",++ "version": "0.152.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",++ "integrity": "sha512-/nZbjF9KTJPa3582v60tx238BBDa47tJAmxVkbgSWRReXgMHHGAhV8FGKqFmxPHOxLzvMgVp0Ei4mz4A4jsUvQ==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1245,9 +1245,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.211.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",-- "integrity": "sha512-3qMNLpByLNQPoCb9HGirVM5UTIPVYMLNshyhB185w1djaF3MLKnKahgwDpSlO3L8MAvhr+ZDzhyaedQdqTWcBQ==",++ "version": "4.214.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",++ "integrity": "sha512-HvaqQOZLLFEgwDaCkrhJFfwQfsMIlOmV05PhO796JFmC7A1NScxOqp86djT6sYsK1pARMqeo0kTua5mAdXiQFw==","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -25,9 +25,9 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0","pg": "^8.23.0","qrcode-generator": "^2.0.4","ws": "^8.18.0"@@@ -113,6 +113,22 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.online[data-state="connecting"] .dot { animation: pulse 1s ease-in-out infinite; }.notice { margin: 0 0 0.75rem; }++.arrange-btn { display: inline-flex; align-items: center; gap: 0.4rem; min-height: 40px; padding: 0 0.8rem; margin: 0.25rem 0 0.5rem; border: 1px solid var(--line); border-radius: 10px; background: var(--raised); color: var(--text); font: inherit; cursor: pointer; }++.arrange-btn:hover { border-color: color-mix(in srgb, var(--line) 65%, var(--text) 35%); }++.arrange-btn .icon { width: 1.1rem; height: 1.1rem; }++++/* The arranged panel: each part is a cell of the grid the layout names (make your own controls, stage 2). The++ drive section flattens so its stick, Stop and direction buttons are cells of the same grid; the old heading and++ key hint are decoration and step out of the way. */++.widgets[data-ov-arranged] { display: grid; }++[data-ov-flatten] { display: contents; }++[data-ov-flatten] > [data-ov-decor] { display: none; }++[data-ov-part][hidden] { display: none !important; }++.widgets[data-ov-arranged] > [data-ov-part] { overflow: auto; }++#panel :is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .stick { width: 100%; height: 100%; }++:is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .drive-pad { display: contents; }++#panel :is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .stop { width: 100%; height: 100%; border-radius: 50%; }++/* Widgets: one column on a phone held upright. The camera comes first and the drive pad right under it, so thepicture and the controls are on screen together; the readouts and the other controls follow. */.widgets { display: grid; gap: 0.75rem; grid-template-columns: minmax(0, 1fr); grid-auto-flow: dense; }@@@ -681,11 +697,28 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:url("/panel/lock.svg");}++/* A person's docked overlay: the four edges and their order. The default overlay keeps the rules above; this++ only takes over when the layout is the person's own (data-ov-custom). */++[data-ov-custom] [data-ov-part][data-ov-dock] { position: absolute; }++[data-ov-custom] [data-ov-part][data-ov-dock="left"] { left: calc(16px + env(safe-area-inset-left)); right: auto; }++[data-ov-custom] [data-ov-part][data-ov-dock="right"] { right: calc(16px + env(safe-area-inset-right)); left: auto; }++[data-ov-custom] [data-ov-part][data-ov-dock="top"] { top: calc(16px + env(safe-area-inset-top)); bottom: auto; }++[data-ov-custom] [data-ov-part][data-ov-dock="bottom"] { bottom: calc(16px + env(safe-area-inset-bottom)); top: auto; }++[data-ov-custom] [data-ov-part][data-ov-dock="left"][data-ov-order="0"],++[data-ov-custom] [data-ov-part][data-ov-dock="right"][data-ov-order="0"] { top: calc(16px + env(safe-area-inset-top)); bottom: auto; }++[data-ov-custom] [data-ov-part][data-ov-dock="left"][data-ov-order="1"],++[data-ov-custom] [data-ov-part][data-ov-dock="right"][data-ov-order="1"] { bottom: calc(16px + env(safe-area-inset-bottom)); top: auto; }++[data-ov-custom] [data-ov-part][data-ov-dock="left"][data-ov-order="2"],++[data-ov-custom] [data-ov-part][data-ov-dock="right"][data-ov-order="2"] { bottom: calc(80px + env(safe-area-inset-bottom)); top: auto; }++[data-ov-custom] [data-ov-part="more"][data-ov-dock="right"] { top: calc(68px + env(safe-area-inset-top)); bottom: auto; }++[data-ov-custom] [data-ov-part="slider"][data-ov-dock="bottom"] { left: 50%; transform: translateX(-50%); }++/* ── Controls v2 (ask 368, 2026-11-11: the owner circled the pad, the Stop, the head slider and the readouts:"redesign … polish … more consistent … per user"). One family on the full panel, in a channel's Robot tab andframed anywhere: the stick has its four marks and a knob that lights when it drives; Stop is a ringed red discbeside it (on the hand this person chose); a slider says its value with − and + to step it; battery and latency-- are slim readout rows. The pad's arrangement menu (the sliders icon) keeps the hand, the size and the readouts. ─── */++ are slim readout rows. The Arrange button (make your own controls) replaces the old arrangement menu; the pad's++ hand, size and readouts are now the person's ui.layout@1. ─── */#panel:not(.bot-surface) .widget-drive { position: relative; }#panel:not(.bot-surface) .drive-pad { grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 0.75rem 1.4rem; }#panel:not(.bot-surface) .drive-pad > :not(.stick):not(.stop) { grid-column: 1 / -1; }@@@ -731,21 +764,6 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:#panel:not(.bot-surface) .drive-pad .stop:active:not(:disabled) { transform: scale(0.94); }#panel:not(.bot-surface) .drive-pad .stop:disabled { opacity: 0.5; filter: saturate(0.5); }--/* The arrangement menu: a quiet icon in the drive card's corner, its choices as segmented rows. */--#panel:not(.bot-surface) .pad-prefs-btn { position: absolute; top: 0.45rem; right: 0.45rem; min-width: 36px; min-height: 36px; width: 36px; height: 36px; padding: 0; border-radius: 10px; display: grid; place-items: center; background: transparent; border-color: transparent; color: var(--muted); }--#panel:not(.bot-surface) .pad-prefs-btn:hover, #panel:not(.bot-surface) .pad-prefs-btn[aria-expanded="true"] { color: var(--text); background: var(--raised); }--#panel:not(.bot-surface) .pad-prefs-btn .icon { width: 1.15rem; height: 1.15rem; }--#panel:not(.bot-surface) .pad-prefs { position: absolute; top: 2.9rem; right: 0.45rem; z-index: 5; display: grid; gap: 0.6rem; width: min(17rem, calc(100% - 0.9rem)); padding: 0.75rem; border-radius: 14px; background: var(--surface); border: 1px solid var(--line); box-shadow: 0 16px 36px -12px rgba(0, 0, 0, 0.6); }--#panel:not(.bot-surface) .pad-prefs[hidden] { display: none; }--#panel:not(.bot-surface) .pad-pref { display: grid; gap: 0.3rem; }--#panel:not(.bot-surface) .pad-pref-l { font-size: 0.72rem; font-weight: 600; color: var(--muted); }--#panel:not(.bot-surface) .pad-pref-opts { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 3px; padding: 3px; border-radius: 10px; background: var(--bg); }--#panel:not(.bot-surface) .pad-pref label { position: relative; }--#panel:not(.bot-surface) .pad-pref input { position: absolute; inset: 0; opacity: 0; margin: 0; width: 100%; height: 100%; min-height: 0; cursor: pointer; }--#panel:not(.bot-surface) .pad-pref span { display: grid; place-items: center; min-height: 34px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; color: var(--muted); }--#panel:not(.bot-surface) .pad-pref input:checked + span { background: var(--raised); color: var(--text); box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05), 0 2px 6px rgba(0, 0, 0, 0.25); }--#panel:not(.bot-surface) .pad-pref input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 1px; }--/* A slider: its name and value on one line, − the track + on the next; the track fills to the value. */#panel:not(.bot-surface) .control.slider { display: grid; gap: 0.4rem; align-items: stretch; }#panel:not(.bot-surface) .slider-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; margin: 0; }@@@ -789,7 +807,6 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:#panel:not(.bot-surface) .notice[hidden] { display: none; }@media (prefers-reduced-motion: reduce) { #panel:not(.bot-surface) .stick, #panel:not(.bot-surface) .drive-pad .stop, #panel:not(.bot-surface) .stick-dirs { transition: none; } }--#panel:not(.bot-surface) .pad-prefs { text-align: left; }/* A wide panel lays the cards in columns: each card is as tall as what it holds (the battery was a tall empty box). */#panel:not(.bot-surface) .widgets { align-items: start; }@@@ -897,30 +897,120 @@el.dispatchEvent(new Event('input', { bubbles: true }));el.dispatchEvent(new Event('change', { bubbles: true }));}));-- // The pad's arrangement, this person's (kept in this browser): the Stop's hand, the pad's size, the readouts.++ // ── Make your own controls: the Arrange editor and the Controls v2 store's one migration ─────────────++ // The panel is drawn with the person's ui.layout@1 on the server (never a flash of the default). Arrange++ // loads openvibe-shared/ov-arrange.js only when pressed; the editor works on these real parts. The old++ // browser store (ov-bot-pad-prefs: Stop's hand, pad size, readouts) becomes a layout once, then goes.++ const arrangeData = () => {++ const el = document.getElementById('ov-arrange-data');++ if (!el) return null;++ try { return { el, data: JSON.parse(el.textContent) }; } catch { return null; }++ };(() => {-- const KEY = 'ov-bot-pad-prefs';-- const panelEl = document.getElementById('panel');-- const btn = document.querySelector('[data-pad-prefs]');-- const menu = document.querySelector('[data-pad-prefs-menu]');-- if (!panelEl) return;-- let prefs = { hand: 'right', size: 'm', readouts: 'on' };-- try { prefs = { ...prefs, ...(JSON.parse(localStorage.getItem(KEY) || '{}') || {}) }; } catch { /* private mode */ }-- const apply = () => {-- panelEl.dataset.hand = prefs.hand; panelEl.dataset.pad = prefs.size; panelEl.dataset.readouts = prefs.readouts;-- $$('[data-pad-pref]').forEach((r) => { r.checked = prefs[r.dataset.padPref] === r.value; });++ let raw = null;++ try { raw = localStorage.getItem('ov-bot-pad-prefs'); if (raw != null) localStorage.removeItem('ov-bot-pad-prefs'); } catch { /* private mode */ }++ if (raw == null) return;++ const got = arrangeData();++ if (!got || got.data.layoutSource === 'account') return;++ let prefs = {};++ try { prefs = JSON.parse(raw) || {}; } catch { return; }++ const { data } = got;++ const set = (id, fn) => { const e = (data.layout.parts || []).find((p) => p.id === id); if (e) fn(e); };++ if (prefs.hand === 'left') {++ const stick = (data.layout.parts || []).find((p) => p.id === 'stick');++ const stop = (data.layout.parts || []).find((p) => p.id === 'stop');++ if (stick && stop && stick.place && stop.place && stick.place.x != null && stop.place.x != null) {++ const x = stick.place.x; stick.place.x = stop.place.x; stop.place.x = x;++ }++ }++ if (prefs.size === 'l') {++ const cols = data.catalog && data.catalog.grid ? data.catalog.grid.columns[data.device] : 4;++ const stick = (data.layout.parts || []).find((p) => p.id === 'stick');++ const stop = (data.layout.parts || []).find((p) => p.id === 'stop');++ if (stick && stick.place && stick.place.x != null) {++ stick.place.w = Math.min(3, cols); stick.place.h = 3;++ stick.place.x = Math.max(0, Math.min(stick.place.x, cols - stick.place.w));++ if (stop && stop.place && stick.place.x < stop.place.x + stop.place.w && stick.place.x + stick.place.w > stop.place.x++ && stick.place.y < stop.place.y + stop.place.h && stick.place.y + stick.place.h > stop.place.y) {++ stick.place.x = 0; stick.place.y = stop.place.y + stop.place.h;++ }++ }++ }++ if (prefs.readouts === 'off') for (const id of ['battery', 'latency', 'telemetry']) set(id, (p) => { p.visible = false; });++ got.el.textContent = JSON.stringify(data);++ // Put it on this page's already-rendered parts too, so the person sees their old arrangement at once.++ const surfaceEl = document.querySelector('#panel .widgets[data-ov-surface]') || document.querySelector('#panel .bot-overlay[data-ov-surface]');++ if (surfaceEl && data.surface === 'bot.controls') {++ const cols = data.catalog && data.catalog.grid ? data.catalog.grid.columns[data.device] : 4;++ const cell = data.catalog && data.catalog.grid ? data.catalog.grid.cell_px[data.device] : 64;++ surfaceEl.setAttribute('data-ov-arranged', 'true');++ surfaceEl.style.setProperty('--ov-cols', String(cols));++ surfaceEl.style.setProperty('--ov-cell', `${cell}px`);++ surfaceEl.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;++ surfaceEl.style.gridAutoRows = `minmax(${cell}px, auto)`;++ }++ for (const e of data.layout.parts || []) {++ const el = document.querySelector(`[data-ov-part="${CSS.escape(e.id)}"]`);++ if (!el) continue;++ if (e.place && e.place.x != null) { el.style.gridColumn = `${e.place.x + 1} / span ${e.place.w}`; el.style.gridRow = `${e.place.y + 1} / span ${e.place.h}`; }++ el.hidden = e.visible === false;++ }++ if (data.signedIn && data.save) {++ fetch(data.save, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ surface: data.surface, device: data.device, catalog: data.catalog, layout: data.layout }) }).catch(() => {});++ }++ })();++++ (() => {++ const btn = document.querySelector('[data-arrange]');++ if (!btn) return;++ let opening = false;++ let saved = false;++ const post = async (url, body) => {++ const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });++ const out = await res.json().catch(() => ({}));++ if (!res.ok) throw new Error((out.reasons && out.reasons[0]) || out.detail || 'That did not work.');++ return out;};-- apply();-- if (!btn || !menu) return;-- const close = () => { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); };-- btn.addEventListener('click', (e) => { e.stopPropagation(); const open = menu.hidden; menu.hidden = !open; btn.setAttribute('aria-expanded', String(open)); });-- document.addEventListener('click', (e) => { if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close(); });-- document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !menu.hidden) { close(); btn.focus(); } });-- $$('[data-pad-pref]').forEach((r) => r.addEventListener('change', () => {-- prefs[r.dataset.padPref] = r.value;-- try { localStorage.setItem(KEY, JSON.stringify(prefs)); } catch { /* */ }-- apply();-- }));++ const launch = () => {++ const got = arrangeData();++ if (!got || !window.OVArrange) { opening = false; return; }++ const { data } = got;++ const surfaceEl = document.querySelector('#panel .widgets[data-ov-surface]') || document.querySelector('#panel .bot-overlay[data-ov-surface]') || document.querySelector('#panel .bot-compact[data-ov-surface]');++ if (!surfaceEl) { opening = false; return; }++ window.ovArrange = window.OVArrange.open({++ surface: surfaceEl, catalog: data.catalog, layout: data.layout, device: data.device,++ onSave: async (layout) => {++ const out = await post(data.save, { surface: data.surface, device: data.device, catalog: data.catalog, layout });++ data.layout = layout; got.el.textContent = JSON.stringify(data); saved = true;++ return out;++ },++ onAsk: async (words, request) => post(data.suggest, { surface: data.surface, device: data.device, words, layout: request.layout, catalog: data.catalog }),++ onShare: async (layout) => {++ const out = await post(data.share, { surface: data.surface, device: data.device, catalog: data.catalog, layout });++ data.layout = layout; got.el.textContent = JSON.stringify(data); saved = true;++ return out;++ },++ onReset: async () => {++ const out = await post(data.reset, { surface: data.surface, device: data.device });++ data.layout = window.ovArrange.layout(); data.layoutSource = 'default'; got.el.textContent = JSON.stringify(data); saved = true;++ return out;++ },++ onClose: () => { opening = false; if (saved) setTimeout(() => location.reload(), 60); },++ confirm: () => true,++ });++ };++ btn.addEventListener('click', () => {++ if (opening) return;++ opening = true;++ if (window.OVArrange) return launch();++ const src = btn.dataset.arrangeSrc;++ if (!src) { opening = false; return; }++ const sc = document.createElement('script');++ sc.src = src; sc.async = true;++ sc.onload = launch;++ sc.onerror = () => { opening = false; };++ document.head.appendChild(sc);++ });})();const stopRepeats = () => { [...repeats].forEach((s) => s()); };window.addEventListener('blur', stopRepeats);@@@ -1109,7 +1199,7 @@// The pad's arrangement is this person's own and works offline: it is never a control of the robot.// A custom action in the card (LOOI's headlights) has its own kind: never disable it from the actuator's// state. The join below enables it only when the panel may send headlights.-- for (const el of $$('button, input, select', section)) if (!el.closest('[data-pad-prefs-menu]') && !el.matches('[data-pad-prefs]') && !el.closest('[data-headlight]')) el.disabled = !ok;++ for (const el of $$('button, input, select', section)) if (!el.closest('[data-headlight]')) el.disabled = !ok;for (const el of $$('[data-joystick], [data-button-stick], [data-ptz-stick]', section)) { if (ok) el.removeAttribute('aria-disabled'); else el.setAttribute('aria-disabled', 'true'); }}announceControls();nnew file mode 100644@@@ -0,0 +1,79 @@++'use strict';++++/**++ * The person's ui.layout@1 from OpenVibe.Network, for one surface and one device class.++ *++ * The panel reads the active layout server-side before it renders, so a signed-in person never sees the default++ * flash and then their own arrangement. The browser's first-party session cookie is an OpenVibe.Network access++ * token, so the read is the person's own `GET /api/v1/me/layouts/:surface?device=` (the panel is not given any++ * extra account power). A Network that is slow or down leaves the default: the panel still opens and commands++ * still reach the robot. Saving and asking OpenVibe go through Bot's own thin proxies (routes.js), which carry++ * the same cookie, so neither ever reaches the page's script.++ */++const TIMEOUT_MS = 1200;++const WRITE_TIMEOUT_MS = 15000;++const SUGGEST_TIMEOUT_MS = 30000;++++function base(config) {++ return String(config.network.url || '').replace(/\/+$/, '');++}++++async function call(config, token, method, path, body, fetchImpl = globalThis.fetch, timeoutMs = TIMEOUT_MS) {++ const res = await fetchImpl(`${base(config)}${path}`, {++ method,++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },++ ...(body ? { body: JSON.stringify(body) } : {}),++ signal: AbortSignal.timeout(timeoutMs),++ });++ const out = await res.json().catch(() => null);++ return { status: res.status, ok: res.ok, out };++}++++/** The active layout, or null when the person has not made one (404) or Network cannot answer. */++async function activeLayout({ config, token, surface, device, fetchImpl }) {++ if (!token || !surface || !device) return null;++ try {++ const r = await call(config, token, 'GET', `/api/v1/me/layouts/${encodeURIComponent(surface)}?device=${encodeURIComponent(device)}`, null, fetchImpl);++ if (r.status === 404) return null;++ if (!r.ok) return null;++ const layout = r.out && (r.out.layout || (Array.isArray(r.out.parts) ? r.out : null));++ return layout && typeof layout === 'object' ? layout : null;++ } catch { return null; }++}++++/** PUT the active layout. Returns Network's answer ({ layout } or the layout) or throws with a plain reason. */++async function saveLayout({ config, token, surface, device, layout, catalog, fetchImpl }) {++ if (!catalog) throw new Error('the surface catalog is needed to save an arrangement');++ const r = await call(config, token, 'PUT', `/api/v1/me/layouts/${encodeURIComponent(surface)}?device=${encodeURIComponent(device)}`, { catalog, layout }, fetchImpl, WRITE_TIMEOUT_MS);++ if (!r.ok) throw new Error((r.out && (r.out.detail || r.out.error)) || `Network answered ${r.status}`);++ return r.out;++}++++/** POST the person's words; Network checks entitlement and forwards to OpenVibe.AI's ui.layout.suggest. */++async function suggestLayout({ config, token, surface, device, words, layout, catalog, fetchImpl }) {++ const body = { words, layout, catalog, device };++ const r = await call(config, token, 'POST', `/api/v1/me/layouts/${encodeURIComponent(surface)}/suggest?device=${encodeURIComponent(device)}`, body, fetchImpl, SUGGEST_TIMEOUT_MS);++ if (!r.ok) throw new Error((r.out && (r.out.detail || r.out.error)) || `Network answered ${r.status}`);++ return r.out;++}++++/** Save, then make a share link for that layout (Network's own public read route, made absolute). */++async function shareLayout({ config, token, surface, device, layout, catalog, fetchImpl }) {++ const saved = await saveLayout({ config, token, surface, device, layout, catalog, fetchImpl });++ if (!saved || !saved.id) throw new Error('Network did not return the saved layout');++ const r = await call(config, token, 'POST', `/api/v1/me/layouts/${encodeURIComponent(saved.id)}/share`, {}, fetchImpl, WRITE_TIMEOUT_MS);++ if (!r.ok) throw new Error((r.out && (r.out.detail || r.out.error)) || `Network answered ${r.status}`);++ const url = r.out && r.out.url;++ if (url) { try { r.out.url = new URL(url, base(config) + '/').toString(); } catch { /* relative is still a link */ } }++ return r.out;++}++++/** Clear the active layout for this surface/device: Network keeps the saved row, the product draws its default. */++async function resetLayout({ config, token, surface, device, fetchImpl }) {++ const r = await call(config, token, 'POST', `/api/v1/me/layouts/${encodeURIComponent(surface)}/reset?device=${encodeURIComponent(device)}`, {}, fetchImpl, WRITE_TIMEOUT_MS);++ if (r.status === 404) return { ok: true };++ if (!r.ok) throw new Error((r.out && (r.out.detail || r.out.error)) || `Network answered ${r.status}`);++ return { ok: true };++}++++module.exports = { activeLayout, saveLayout, suggestLayout, shareLayout, resetLayout, TIMEOUT_MS };@@@ -1,5 +1,6 @@'use strict';const kit = require('openvibe-shared/kit');++const sharedServe = require('openvibe-shared/serve');/*** The signed-in pages, rendered on the server from data alone (pure functions, testable without a server).@@@ -136,8 +137,12 @@ const DIRECTION_BUTTONS = [{ dir: 'right', label: 'right', names: ['turn_right', 'right', 'rotate_right'] },];--/** The drive pad: a touch joystick for two axes (pointer events, see panel.js), hold buttons for the rest. */--function drivePad(axes, enabled) {++/**++ * The drive pad: a touch joystick for two axes (pointer events, see panel.js), hold buttons for the rest.++ * Its stick, Stop and direction buttons are the panel's arrangeable parts (make your own controls, stage 2):++ * the section around them flattens (`display: contents`) so each is a cell of the panel's grid.++ */++function drivePad(axes, enabled, attrs = {}, flatten = false) {const x = STICK_X.find((a) => axes[a]);const y = STICK_Y.find((a) => axes[a]);const stick = {};@@@ -146,29 +151,13 @@ function drivePad(axes, enabled) {const rest = Object.fromEntries(Object.entries(axes).filter(([a]) => a !== x && a !== y));const label = [y && AXIS_NAME[y], x && AXIS_NAME[x]].filter(Boolean).join(' and ');const joystick = x || y-- ? `<div class="stick" data-joystick="${data(stick)}" role="img" aria-label="${esc(`Joystick: ${label}. Drag and hold to drive.`)}"${enabled ? '' : ' aria-disabled="true"'}>`++ ? `<div class="stick" data-joystick="${data(stick)}" data-ov-part="stick" ${attrs.stick || ''} role="img" aria-label="${esc(`Joystick: ${label}. Drag and hold to drive.`)}"${enabled ? '' : ' aria-disabled="true"'}>`+ '<span class="stick-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg>'+ `<span class="stick-knob" data-knob></span></div>`: '';-- return `<div class="drive-pad" data-axes="${data(axes)}">${joystick}${Object.keys(rest).length ? `<div class="pad">${axisButtons(rest, enabled)}</div>` : ''}`-- + `<button type="button" class="stop" data-stop${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`-- + padPrefs();--}----/**-- * How this person likes the pad (controls v2, ask 368: "make it … per user"): the hand the Stop sits on, the pad's size-- * and the readouts shown or folded. public/panel.js keeps the choice in this browser and sets data-hand, data-pad and-- * data-readouts on #panel; nothing here changes what the robot does.-- */--function padPrefs() {-- const group = (name, label, opts) => `<div class="pad-pref" role="radiogroup" aria-label="${esc(label)}"><span class="pad-pref-l">${esc(label)}</span><div class="pad-pref-opts">${opts.map(([v, t]) =>-- `<label><input type="radio" name="pad-${esc(name)}" value="${esc(v)}" data-pad-pref="${esc(name)}"><span>${esc(t)}</span></label>`).join('')}</div></div>`;-- return `<button type="button" class="pad-prefs-btn" data-pad-prefs aria-expanded="false" aria-controls="pad-prefs" title="Arrange the controls" aria-label="Arrange the controls">${icon('sliders')}</button>`-- + `<div class="pad-prefs" id="pad-prefs" data-pad-prefs-menu hidden>`-- + group('hand', 'Stop on the', [['right', 'Right'], ['left', 'Left']])-- + group('size', 'Pad', [['s', 'Small'], ['m', 'Medium'], ['l', 'Large']])-- + group('readouts', 'Readouts', [['on', 'Shown'], ['off', 'Folded']])-- + '</div>';++ const pad = Object.keys(rest).length ? `<div class="pad" data-ov-part="pad" ${attrs.pad || ''}>${axisButtons(rest, enabled)}</div>` : '';++ return `<div class="drive-pad" data-axes="${data(axes)}"${flatten ? ' data-ov-flatten="true"' : ''}>${joystick}${pad}`++ + `<button type="button" class="stop" data-stop data-ov-part="stop" ${attrs.stop || ''}${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`;}/** An actuator's name as a label: backpack_lights → Backpack lights. */@@@ -267,7 +256,7 @@ function cameraTiles(w, profile, signaling_url = null, pointEnabled = false) {}).join('');}--function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url = null } = {}) {++function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url = null, part = null, partAttrs = {}, flatten = false } = {}) {const commands = profile.commands || {};const kind = w.command ? w.command.kind : null;const enabled = !!kind && allowed_commands.includes(kind);@@@ -280,8 +269,8 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =// drive has a rotation axis, else nothing, and then the legend does not offer them.const headName = ((commands.actuator && commands.actuator.names) || {}).head;const qe = headName ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to tilt' : axes.rotation ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to turn' : '';-- inner = drivePad(axes, enabled)-- + `<p class="hint"><kbd>W A S D</kbd> or arrows to drive${qe} · <kbd>Space</kbd> to stop. `++ inner = drivePad(axes, enabled, partAttrs, flatten)++ + `<p class="hint" data-ov-decor><kbd>W A S D</kbd> or arrows to drive${qe} · <kbd>Space</kbd> to stop. `+ `<span class="pad-hint" data-pad-hint>${icon('gamepad')} A gamepad works too: left stick drives, A sounds, B stops, Start is the e-stop.</span></p>`;break;}@@@ -360,7 +349,11 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =inner = '';}const cmd = w.command ? ` data-command="${data(w.command)}"` : '';-- return `<section class="widget widget-${esc(w.type)}" data-widget="${esc(w.type)}"${cmd}><h2>${esc(label)}</h2>${inner}</section>`;++ if (w.type === 'drive') {++ return `<section class="widget widget-drive" data-widget="drive" data-ov-host${flatten ? ' data-ov-flatten="true"' : ''}${cmd}><h2 data-ov-decor>${esc(label)}</h2>${inner}</section>`;++ }++ const partAttr = part ? ` data-ov-part="${esc(part)}" ${partAttrs[part] || ''}` : '';++ return `<section class="widget widget-${esc(w.type)}" data-widget="${esc(w.type)}"${cmd}${partAttr}><h2>${esc(label)}</h2>${inner}</section>`;}/** One owner switch: a checkbox posted as name=on, after a hidden name=off so an unchecked box still says off. */@@@ -687,42 +680,47 @@ function overlaySlider(profile, allowed) {return null;}--const botPill = (robot) => `<span class="bot-pill bot-control" data-bot-pill data-online-pill data-state="connecting" data-bot-hit><span class="dot"></span><b class="bot-pill-name">${esc(robot.name)}</b><span class="bot-pill-state" data-bot-state>Connecting…</span></span>`;++const botPill = (robot, attrs = '') => `<span class="bot-pill bot-control" data-bot-pill data-online-pill data-state="connecting" data-bot-hit data-ov-part="pill" ${attrs}><span class="dot"></span><b class="bot-pill-name">${esc(robot.name)}</b><span class="bot-pill-state" data-bot-state>Connecting…</span></span>`;// A watcher (the anonymous embed on a stream) takes the controls with Drive, as on the full panel; the e-stop waits,// hidden, for the join that makes them an operator (public/panel.js onFrame), and the line under the controls says// what the page around answered (sign in there, or the owner decides who drives). Ask 353, 2026-11-11: on a phone over// the stream there was no way to start driving at all.--const botEstop = (role, canDrive = false) => (ESTOP_ROLES.has(role)-- ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit aria-label="E-stop" title="E-stop">${icon('stop')}</button>`-- : canDrive ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit aria-label="E-stop" title="E-stop" hidden>${icon('stop')}</button>` : '');--const botDrive = (canDrive) => (canDrive-- ? `<button type="button" class="bot-drive bot-control" data-drive data-drive-bar data-bot-hit title="Take the controls">${icon('gamepad')}<span>Drive</span></button>` : '');++const botEstop = (role, canDrive = false, attrs = '') => (ESTOP_ROLES.has(role)++ ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit data-ov-part="estop" ${attrs} aria-label="E-stop" title="E-stop">${icon('stop')}</button>`++ : canDrive ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit data-ov-part="estop" data-ov-role-hidden="true" ${attrs} aria-label="E-stop" title="E-stop" hidden>${icon('stop')}</button>` : '');++const botDrive = (canDrive, attrs = '') => (canDrive++ ? `<button type="button" class="bot-drive bot-control" data-drive data-drive-bar data-bot-hit data-ov-part="drive" ${attrs} title="Take the controls">${icon('gamepad')}<span>Drive</span></button>` : '');const botNotice = '<p class="bot-notice bot-control" data-notice data-bot-hit role="alert" hidden></p>';--const botMore = '<button type="button" class="bot-more bot-control" data-bot-more data-bot-hit aria-label="More controls" title="Open the full panel"><span aria-hidden="true">•••</span></button>';++const botMore = (attrs = '') => `<button type="button" class="bot-more bot-control" data-bot-more data-bot-hit data-ov-part="more" ${attrs} aria-label="More controls" title="Open the full panel"><span aria-hidden="true">•••</span></button>`;/** The overlay/compact page: a transparent surface with only the controls, no topbar, header or widgets. */--function renderBotSurface({ robot, profile, role, allowed_commands = [], holdResendMs = 150, view = 'overlay', parentOrigins = [], signedIn = true }) {++function renderBotSurface({ robot, profile, role, allowed_commands = [], holdResendMs = 150, view = 'overlay', parentOrigins = [], signedIn = true, arrange = null }) {++ const surface = require('./surface');++ const arrangeApply = !!(arrange && arrange.layoutSource === 'account');++ const attrs = (id) => (arrange ? surface.attrsFor(arrange.catalog, arrange.layout, arrange.device, id, { apply: arrangeApply }) : '');const stick = overlayStickMarkup(profile, allowed_commands);const slider = overlaySlider(profile, allowed_commands);const canDrive = role === 'watcher' && !signedIn;-- const stop = botEstop(role, canDrive) + botDrive(canDrive);++ const stop = botEstop(role, canDrive, attrs('estop')) + botDrive(canDrive, attrs('drive'));const sliderLabel = slider ? esc(humanName(slider.name)) : '';const sliderMarkup = slider-- ? `<div class="bot-slider bot-control" data-bot-hit><section class="widget bot-widget" data-command="${data(slider.widget.command)}"><label class="control bot-range-label"><span class="sr-only">${sliderLabel}</span>${numberRange(slider.name, slider.actuator, slider.enabled, profile.id)}</label></section></div>`++ ? `<div class="bot-slider bot-control" data-bot-hit data-ov-part="slider" ${attrs('slider')}><section class="widget bot-widget" data-command="${data(slider.widget.command)}"><label class="control bot-range-label"><span class="sr-only">${sliderLabel}</span>${numberRange(slider.name, slider.actuator, slider.enabled, profile.id)}</label></section></div>`: '';-- const stickMarkup = stick ? `<div class="bot-stick bot-control" data-bot-hit>${stick}</div>` : '';++ const stickMarkup = stick ? `<div class="bot-stick bot-control" data-bot-hit data-ov-part="stick" ${attrs('stick')}>${stick}</div>` : '';++ const surfaceAttrs = arrange ? surface.overlaySurfaceAttrs(arrange.catalog, arrange.layout, arrange.device, arrangeApply && view !== 'compact') : '';const body = view === 'compact'-- ? `<div class="bot-compact" data-bot-compact${slider ? '' : ' data-no-slider'}>${botPill(robot)}${stickMarkup}${sliderMarkup}${stop}${botMore}${botNotice}</div>`-- : `<div class="bot-overlay" data-bot-overlay>${botPill(robot)}${stickMarkup}${sliderMarkup}${stop}${botMore}${botNotice}</div>`;-- return `<main id="panel" class="bot-surface" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}" data-bot-view="${esc(view)}"${parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>${body}</main>`;++ ? `<div class="bot-compact" data-bot-compact${slider ? '' : ' data-no-slider'} ${surfaceAttrs}>${botPill(robot, attrs('pill'))}${stickMarkup}${sliderMarkup}${stop}${botMore(attrs('more'))}${botNotice}</div>`++ : `<div class="bot-overlay" data-bot-overlay ${surfaceAttrs}>${botPill(robot, attrs('pill'))}${stickMarkup}${sliderMarkup}${stop}${botMore(attrs('more'))}${botNotice}</div>`;++ const dataScript = arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringify({ catalog: arrange.catalog, layout: arrange.layout, device: arrange.device, surface: arrange.catalog.id, signedIn: arrange.signedIn, layoutSource: arrange.layoutSource || 'default', save: arrange.save || null, suggest: arrange.suggest || null, share: arrange.share || null, reset: arrange.reset || null, author: arrange.author || null }).replace(/</g, '\\u003c')}</script>` : '';++ return `<main id="panel" class="bot-surface" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}" data-bot-view="${esc(view)}"${parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>${body}${dataScript}</main>`;}--function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', view = 'panel', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null, limitsInfo = null }) {++function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', view = 'panel', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null, limitsInfo = null, arrange = null }) {const embed = mode === 'embed';const botView = embed ? botViewOf(view) : 'panel';if (embed && botView !== 'panel') {const [minWidth, minHeight] = BOT_VIEW_MIN[botView];-- return page(robot.name, renderBotSurface({ robot, profile, role, allowed_commands, holdResendMs, view: botView, parentOrigins, signedIn }), {++ return page(robot.name, renderBotSurface({ robot, profile, role, allowed_commands, holdResendMs, view: botView, parentOrigins, signedIn, arrange }), {scripts: panelScripts(profile, false, true),bodyClass: `embed-page bot-mode-${botView}`,htmlClass: `embed-page bot-mode-${botView}`,@@@ -733,13 +731,32 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMsconst minSize = BOT_VIEW_MIN.panel;const htmlAttrs = embed ? ` data-bot-view="panel" data-bot-min-width="${minSize[0]}" data-bot-min-height="${minSize[1]}"` : '';const latched = !!(robot.estop && robot.estop.latched);-- const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))-- .sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i))-- .map(({ w }) => renderWidget(w, { profile, allowed_commands, signaling_url }));++ const widgetOrder = (profile.widgets || []).map((w, i) => ({ w, i }))++ .sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i));++ // The arrangeable part ids each widget draws: the drive is split into its stick, Stop and direction buttons.++ const byWidget = new Map();++ for (const p of (arrange && arrange.catalog ? arrange.catalog.parts || [] : [])) {++ const source = (arrange.parts || []).find((x) => x.id === p.id);++ if (!source || source.widgetIndex == null) continue;++ if (!byWidget.has(source.widgetIndex)) byWidget.set(source.widgetIndex, []);++ byWidget.get(source.widgetIndex).push(p.id);++ }++ const widgetParts = (i) => byWidget.get(i) || [];++ const arrangeApply = !!(arrange && arrange.layoutSource === 'account');++ const attrsOf = (ids) => Object.fromEntries(ids.map((id) => [id, arrange ? require('./surface').attrsFor(arrange.catalog, arrange.layout, arrange.device, id, { apply: arrangeApply }) : '']));++ const widgets = widgetOrder.map(({ w, i }) => renderWidget(w, { profile, allowed_commands, signaling_url, part: widgetParts(i)[0] || null, partAttrs: attrsOf(widgetParts(i)), flatten: arrangeApply }));++ // A robot with no drive widget still gets its Stop: a required part of bot.controls, drawn on its own.++ const drawn = new Set([...byWidget.values()].flat());++ if (arrange && !drawn.has('stop') && require('./surface').partOf(arrange.catalog, 'stop')) {++ widgets.push(`<section class="widget widget-stop" data-widget="stop" data-ov-part="stop" ${attrsOf(['stop']).stop || ''}>`++ + `<h2>Stop</h2><button type="button" class="stop" data-stop>${icon('square')}<span>Stop</span></button></section>`);++ }// A framed watcher may take the controls with Drive; its e-stop waits, hidden, for an owner's or operator's join.const estop = ESTOP_ROLES.has(role) ? `<button type="button" class="estop" data-estop>${icon('stop')}<span>E-stop</span></button>`: (embed && role === 'watcher' ? `<button type="button" class="estop" data-estop hidden>${icon('stop')}<span>E-stop</span></button>` : '');const clear = role === 'owner' ? `<button type="button" class="estop-clear" data-estop-clear${latched ? '' : ' hidden'}>Clear e-stop</button>` : '';++ const arrangeButton = arrange && arrange.src++ ? `<button type="button" class="arrange-btn" data-arrange data-arrange-src="${esc(arrange.src || '')}" title="Arrange the controls">${icon('sliders')}<span>Arrange</span></button>` : '';const body = `<header class="estop-banner" data-estop-banner data-latched="${latched}">${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div>@@@ -749,15 +766,16 @@ ${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') :${kit.pageHead({ title: `${robot.name}`, icon: 'gear', back: embed ? null : { href: '/', label: 'Home' } })}<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>${turnStrip(robot, role)}++${arrangeButton}${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in"><button type="button" class="button primary" data-drive data-drive-bar>${icon('gamepad')}<span>Drive</span></button> <a class="button embed-open" href="/panel/${esc(robot.id)}" target="_blank" rel="noopener" title="Open on openvibe.bot">${icon('external')}<span>Open on openvibe.bot</span></a></p>` : ''}</div>${tryIt ? tryIntro() : ''}<p class="notice" data-device-fault role="status" hidden></p><p class="notice" data-notice role="alert" hidden></p>--<div class="widgets">++<div class="widgets"${arrange ? require('./surface').surfaceAttrs(arrange.catalog, arrange.layout, arrange.device, arrangeApply) : ''}>${widgets.join('\n')}</div>--${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${commissionForm(robot, profile)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}++${arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringify({ catalog: arrange.catalog, layout: arrange.layout, device: arrange.device, surface: arrange.catalog.id, signedIn: arrange.signedIn, layoutSource: arrange.layoutSource || 'default', save: arrange.save || null, suggest: arrange.suggest || null, share: arrange.share || null, reset: arrange.reset || null, author: arrange.author || null }).replace(/</g, '\\u003c')}</script>` : ''}${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${commissionForm(robot, profile)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}</main>`;return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {scripts: panelScripts(profile, tryIt, embed), bodyClass: embed ? 'embed-page' : 'panel-page',@@@ -1122,4 +1140,4 @@ function renderFirstRunPage({ rows = [] }) {return page('First run', body, { description: 'The guided first run of OpenVibe Link, measured step by step.', canonical: '/admin/first-run' });}--module.exports = { page, renderPanel, renderPhonePage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderPhoneLinkPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };++module.exports = { page, renderPanel, renderPhonePage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderPhoneLinkPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, overlayStickMarkup, overlaySlider, esc };@@@ -48,7 +48,11 @@ function linkApkUrl() {const v = String(process.env.LINK_ANDROID_APK_URL || '').trim();return /^https:\/\/(github\.com\/OpenVibers\/OpenVibe\.Link\/releases\/|openvibe\.link\/)[^\s"'<>]+$/.test(v) ? v : null;}--const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderPhoneLinkPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');++const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderPhoneLinkPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, overlayStickMarkup, overlaySlider } = require('./render');++const sharedServe = require('openvibe-shared/serve');++const layoutStore = require('./layouts');++const surface = require('./surface');++const uiLayout = require('openvibe-sdk/ui-layout');const { createMachineScreen } = require('../machine-screen');const { createScreenShares } = require('../screen-shares');const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');@@@ -688,7 +692,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi} catch (e) {if (e.status !== 422) throw e;res.status(422);-- return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { routineError: e.detail || e.message, values: { name: b.name, steps: b.steps, trigger: kind, every: b.every, at: b.at, days: many(b.days) } } });++ return sendPanel(req, res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { routineError: e.detail || e.message, values: { name: b.name, steps: b.steps, trigger: kind, every: b.every, at: b.at, days: many(b.days) } } });}return res.redirect(303, `/panel/${robot.id}#routines`);}));@@@ -710,11 +714,11 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limitry { made = await webhooks.create(robot, me, { url: req.body.url, events: many(req.body.events) }); } catch (e) {if (![409, 422].includes(e.status)) throw e;res.status(e.status);-- return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { webhookError: e.detail || e.message } });++ return sendPanel(req, res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { webhookError: e.detail || e.message } });}// The secret is shown on this page alone (never in a URL), so the answer is the panel itself.res.status(201);-- return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { webhookSecret: made.secret } });++ return sendPanel(req, res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { webhookSecret: made.secret } });}));r.post('/robots/:id/webhooks/:hid/remove', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {const { robot } = await ownRobot(req);@@@ -911,7 +915,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst role = await domain.members.roleOf(robot.id, me.subject);return role || (robot.access_policy === 'queue' ? 'queue' : null);}-- async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true, profileForm = {}, view = 'panel') {++ async function sendPanel(req, res, robot, profile, role, mode = 'page', signedIn = true, profileForm = {}, view = 'panel') {await attachInProcess(robot, profile);const effective = domain.control.effectiveLimits(robot, profile);const { maxCommandMs } = effective;@@@ -943,6 +947,54 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiautomation = { routines: await routines.list(robot.id), hooks, events: webhooks.EVENTS, describe: (t) => routines.describeTrigger(typeof t === 'string' ? JSON.parse(t) : t), ...(profileForm.automation || {}) };}const liveOrigin = (config.embed && config.embed.origins && config.embed.origins[0]) || null;++ // The person's arrangement for this surface and device class, read before the page is drawn (never a++ // flash of the default for a signed-in person). A Network that does not answer leaves the default.++ const device = surface.deviceClass(req);++ const token = req.cookies && req.cookies.ov_token;++ // A layout made on another robot may name parts this one does not have: keep the parts this catalog knows,++ // add the defaults of any it is missing, and use it only when the SDK accepts the result. Otherwise the++ // panel draws its default (a stored layout is never a broken page).++ const usable = (catalog, storedRaw, defaults) => {++ if (!storedRaw || !Array.isArray(storedRaw.parts)) return null;++ const known = new Set((catalog.parts || []).map((p) => p.id));++ const parts = storedRaw.parts.filter((p) => p && known.has(p.id));++ for (const d of defaults.parts || []) if (!parts.some((p) => p.id === d.id)) parts.push(d);++ const candidate = { ...storedRaw, parts };++ return uiLayout.validate(catalog, candidate).ok ? candidate : null;++ };++ const overlayView = mode === 'embed' && view !== 'panel';++ let arrange;++ if (overlayView) {++ const slider = overlaySlider(profile, role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile));++ const canDrive = role === 'watcher' && !signedIn;++ const catalog = surface.overlayCatalog(profile, {++ slider: slider ? { name: slider.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase()) } : null,++ hasStick: !!overlayStickMarkup(profile, role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile)),++ canDrive,++ });++ const storedRaw = signedIn && token ? await layoutStore.activeLayout({ config, token, surface: catalog.id, device }) : null;++ const fallbackLayout = surface.overlayDefaultLayout(catalog, device);++ const stored = usable(catalog, storedRaw, fallbackLayout);++ arrange = {++ catalog, layout: stored || fallbackLayout, device,++ signedIn: !!signedIn, layoutSource: stored ? 'account' : 'default',++ save: `/panel/${robot.id}/arrange`, suggest: `/panel/${robot.id}/arrange/suggest`, share: `/panel/${robot.id}/arrange/share`, reset: `/panel/${robot.id}/arrange/reset`,++ author: req.viewer ? { name: req.viewer.username || req.viewer.name || 'Me' } : null,++ };++ } else {++ const catalog = surface.controlsCatalog(profile);++ const storedRaw = signedIn && token ? await layoutStore.activeLayout({ config, token, surface: catalog.id, device }) : null;++ const fallbackLayout = surface.defaultLayout(catalog, device);++ const stored = usable(catalog, storedRaw, fallbackLayout);++ let src = null;++ try { src = sharedServe.url('ov-arrange.js'); } catch { /* an older openvibe-shared pin: no editor */ }++ arrange = {++ catalog, parts: surface.partsOf(profile), layout: stored || fallbackLayout, device,++ signedIn: !!signedIn, layoutSource: stored ? 'account' : 'default', src,++ save: `/panel/${robot.id}/arrange`, suggest: `/panel/${robot.id}/arrange/suggest`, share: `/panel/${robot.id}/arrange/share`, reset: `/panel/${robot.id}/arrange/reset`,++ author: req.viewer ? { name: req.viewer.username || req.viewer.name || 'Me' } : null,++ };++ }res.type('html').send(renderPanel({robot: presentForPage(robot), profile, role, mode, view, signedIn, streaming, people, signaling_url,profileForm, automation, limitsInfo,@@@ -955,6 +1007,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi// sixth of it (50 ms at the least). Half left about 90 ms of slack on LOOI, and ordinary Wi-Fi jitter expired// commands mid-hold (Maticus, 2026-11-11: four in 9 minutes, gaps of 171–257 ms).holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 6))),++ arrange,}));}@@@ -988,7 +1041,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiif (action !== 'save') {const local = await domain.robots.localProfile(robot.id);state.catalogueId = local ? local.source_profile_id : robot.profile_id;-- return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, state);++ return sendPanel(req, res, robot, await profileOf(robot), 'owner', 'page', true, state);}try {const buttons = {};@@@ -1009,7 +1062,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst local = await domain.robots.localProfile(robot.id);state.catalogueId = local ? local.source_profile_id : robot.profile_id;state.error = e.detail || e.message;-- return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, state);++ return sendPanel(req, res, robot, await profileOf(robot), 'owner', 'page', true, state);}return res.redirect(303, `/panel/${robot.id}`);}));@@@ -1040,7 +1093,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst profile = await profileOf(robot);if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');const local = role === 'owner' ? await domain.robots.localProfile(robot.id) : null;-- await sendPanel(res, robot, profile, role, 'page', true, { catalogueId: local ? local.source_profile_id : robot.profile_id });++ await sendPanel(req, res, robot, profile, role, 'page', true, { catalogueId: local ? local.source_profile_id : robot.profile_id });}));// The embed's Drive (server/control-tickets.js): the popup page, first-party, signs in if it must and then mints a@@@ -1086,9 +1139,76 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi// picture, or one row for a narrow card); panel is the full embed and the default. Anything else is panel.const asked = String(req.query.mode || '').toLowerCase();const view = ['overlay', 'compact'].includes(asked) ? asked : 'panel';-- return sendPanel(res, robot, profile, role, 'embed', !!req.viewer, {}, view);++ return sendPanel(req, res, robot, profile, role, 'embed', !!req.viewer, {}, view);}));++ // ── The Arrange editor's three calls (make your own controls, stage 2) ──────────────────────────────++ // The page never holds a Network token of its own: these carry the first-party session cookie to Network's++ // person routes. A layout only arranges; the robot gate and limits are untouched.++ const arrangeBody = express.json({ limit: '128kb' });++ const arrangeSurface = (v) => (v === 'bot.controls' || v === 'bot.overlay' ? v : null);++ const arrangeDevice = (v) => (['phone', 'tablet', 'computer'].includes(v) ? v : null);++ const arrangeToken = (req) => req.cookies && req.cookies.ov_token;++ const arrangeError = (res, e) => res.status(503).json({ ok: false, reasons: [String((e && (e.detail || e.message)) || 'Network is not reachable right now.')] });++ r.post('/panel/:id/arrange', arrangeBody, async (req, res) => {++ res.setHeader('Cache-Control', 'no-store');++ try {++ if (!sameOrigin(req)) return res.status(403).json({ ok: false, reasons: ['Cross-site saves are refused.'] });++ requireUser(req);++ const robot = await domain.robots.get(req.params.id);++ if (!robot) return res.status(404).json({ ok: false, reasons: ['No such robot.'] });++ const surfaceId = arrangeSurface(req.body && req.body.surface);++ const device = arrangeDevice(req.body && req.body.device);++ if (!surfaceId || !device || !req.body || !req.body.layout || !req.body.catalog) return res.status(422).json({ ok: false, reasons: ['Missing surface, device, catalog or layout.'] });++ await layoutStore.saveLayout({ config, token: arrangeToken(req), surface: surfaceId, device, layout: req.body.layout, catalog: req.body.catalog });++ res.json({ ok: true });++ } catch (e) { arrangeError(res, e); }++ });++ r.post('/panel/:id/arrange/suggest', arrangeBody, async (req, res) => {++ res.setHeader('Cache-Control', 'no-store');++ try {++ if (!sameOrigin(req)) return res.status(403).json({ ok: false, reasons: ['Cross-site asks are refused.'] });++ requireUser(req);++ const robot = await domain.robots.get(req.params.id);++ if (!robot) return res.status(404).json({ ok: false, reasons: ['No such robot.'] });++ const surfaceId = arrangeSurface(req.body && req.body.surface);++ const device = arrangeDevice(req.body && req.body.device);++ const words = String((req.body && req.body.words) || '').trim();++ if (!surfaceId || !device || !words) return res.status(422).json({ ok: false, reasons: ['Say how you want it first.'] });++ const out = await layoutStore.suggestLayout({ config, token: arrangeToken(req), surface: surfaceId, device, words, layout: req.body.layout || null, catalog: req.body.catalog || null });++ res.json(out);++ } catch (e) { arrangeError(res, e); }++ });++ r.post('/panel/:id/arrange/reset', arrangeBody, async (req, res) => {++ res.setHeader('Cache-Control', 'no-store');++ try {++ if (!sameOrigin(req)) return res.status(403).json({ ok: false, reasons: ['Cross-site resets are refused.'] });++ requireUser(req);++ const robot = await domain.robots.get(req.params.id);++ if (!robot) return res.status(404).json({ ok: false, reasons: ['No such robot.'] });++ const surfaceId = arrangeSurface(req.body && req.body.surface);++ const device = arrangeDevice(req.body && req.body.device);++ if (!surfaceId || !device) return res.status(422).json({ ok: false, reasons: ['Missing surface or device.'] });++ res.json(await layoutStore.resetLayout({ config, token: arrangeToken(req), surface: surfaceId, device }));++ } catch (e) { arrangeError(res, e); }++ });++ r.post('/panel/:id/arrange/share', arrangeBody, async (req, res) => {++ res.setHeader('Cache-Control', 'no-store');++ try {++ if (!sameOrigin(req)) return res.status(403).json({ ok: false, reasons: ['Cross-site shares are refused.'] });++ requireUser(req);++ const robot = await domain.robots.get(req.params.id);++ if (!robot) return res.status(404).json({ ok: false, reasons: ['No such robot.'] });++ const surfaceId = arrangeSurface(req.body && req.body.surface);++ const device = arrangeDevice(req.body && req.body.device);++ if (!surfaceId || !device || !req.body || !req.body.layout || !req.body.catalog) return res.status(422).json({ ok: false, reasons: ['Missing surface, device, catalog or layout.'] });++ const out = await layoutStore.shareLayout({ config, token: arrangeToken(req), surface: surfaceId, device, layout: req.body.layout, catalog: req.body.catalog });++ const url = out && (out.url || out.share_url || out.link);++ if (!url) return res.status(503).json({ ok: false, reasons: ['Network did not make a link.'] });++ res.json({ url });++ } catch (e) { arrangeError(res, e); }++ });++return r;}nnew file mode 100644@@@ -0,0 +1,299 @@++'use strict';++++/**++ * The panel's arrangeable surfaces (make your own controls, stage 2): the ui.surface@1 catalogs and the default++ * ui.layout@1 for the full panel (bot.controls) and the controls over a stream (bot.overlay), and the attributes++ * that put a person's layout on the parts the pages render.++ *++ * A catalog is built from the robot's profile: the parts a layout may place are exactly the controls this robot++ * has (the drive stick, Stop, a head slider, battery, latency, the camera, the buttons, ...). Stop and the++ * overlay's e-stop are required and carry their own minimum pixel size. The default layout keeps the panel's++ * familiar order: the camera, then the stick and Stop side by side, then the rest.++ *++ * A layout only arranges: every command still goes through the panel's one gate and the robot's limits.++ */++const THEME_TOKENS = ['surface', 'raised', 'bg', 'line', 'text', 'muted', 'accent', 'danger', 'ok', 'warn'];++const DEVICES = ['phone', 'tablet', 'computer'];++const GRID = { columns: { phone: 4, tablet: 8, computer: 12 }, cell_px: { phone: 72, tablet: 64, computer: 56 } };++const PRESSED = 44;++++/** phone, tablet or computer from the request (a header first, then the browser's own user agent). */++function deviceClass(req) {++ const header = (req && req.headers && (req.headers['sec-ch-ua-mobile'] || '')) || '';++ const ua = String((req && req.headers && req.headers['user-agent']) || '');++ if (/iPad|Tablet|PlayBook|Silk|Android(?!.*Mobile)/i.test(ua)) return 'tablet';++ if (header === '?1' || /Mobi|iPhone|iPod|Android|Windows Phone|Mobile/i.test(ua)) return 'phone';++ return 'computer';++}++++const STICK_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'w', 'a', 's', 'd'];++const STICK_X = ['steer', 'x', 'rotation'];++const STICK_Y = ['throttle', 'y'];++++function basePart(id, name, kind, extra = {}) {++ return {++ id, name, kind,++ required: false,++ pressed: kind === 'button' || kind === 'stick' || kind === 'slider',++ min: { w: 1, h: 1 },++ styles: { tints: ['surface', 'raised'], shapes: ['round', 'pill', 'square'], opacity: { min: 0.4, max: 1 }, label: true },++ bindings: { keys: [], gamepad: [] },++ ...extra,++ };++}++++/** The parts one widget contributes: the drive is split into its stick, Stop and direction buttons. */++function driveParts(profile, widget) {++ const kind = widget && widget.command ? widget.command.kind : 'drive';++ const command = (profile.commands || {})[kind];++ const axes = (command && command.axes) || {};++ const x = STICK_X.find((a) => axes[a]);++ const y = STICK_Y.find((a) => axes[a]);++ const rest = Object.keys(axes).filter((a) => a !== x && a !== y);++ const parts = [];++ if (x || y) parts.push([basePart('stick', 'Drive stick', 'stick', {++ pressed: true, min: { w: 2, h: 2 }, never_covers: ['stop'],++ styles: { tints: ['surface', 'accent'], shapes: ['round', 'square'], opacity: { min: 0.4, max: 1 }, label: false },++ bindings: { keys: STICK_KEYS, gamepad: ['left-stick', 'dpad'] },++ }), { widgetIndex: null }]);++ parts.push([basePart('stop', 'Stop', 'button', {++ required: true, pressed: true, min: { w: 2, h: 2, px: 68 },++ styles: { tints: ['danger', 'surface'], shapes: ['round', 'pill'], opacity: { min: 0.7, max: 1 }, label: true },++ bindings: { keys: ['Space'], gamepad: ['south'] },++ }), { widgetIndex: null }]);++ if (rest.length) parts.push([basePart('pad', 'Direction buttons', 'button', {++ pressed: true, min: { w: 2, h: 2 }, never_covers: ['stop'],++ styles: { tints: ['surface'], shapes: ['round', 'square'], opacity: { min: 0.4, max: 1 }, label: true },++ bindings: { keys: [], gamepad: [] },++ }), { widgetIndex: null }]);++ return parts;++}++++const ACTUATOR_WIDGETS = { 'pan-tilt': 'pan_tilt', servo: 'servo', head: 'head', lift: 'lift', lights: 'lights', horn: 'horn' };++const SIMPLE_WIDGETS = { battery: 'readout', latency: 'readout', telemetry: 'readout', camera: 'video', video_click: 'video', speaker: 'list', display: 'list', buttons: 'button', ptz: 'stick' };++++/** Every part this profile's full panel offers, in the order the page draws its widgets. */++function partsOf(profile) {++ const widgets = (profile && profile.widgets) || [];++ const hasCamera = widgets.some((w) => w.type === 'camera');++ const out = [];++ const seen = new Set();++ const add = (part, meta = {}) => {++ let id = part.id;++ for (let n = 2; seen.has(id); n++) id = `${part.id}.${n}`;++ seen.add(id);++ out.push({ ...part, id, widgetIndex: meta.widgetIndex == null ? null : meta.widgetIndex, widgetType: meta.widgetType || null });++ };++ let drive = false;++ widgets.forEach((w, i) => {++ if (w.type === 'drive') {++ for (const [part, meta] of driveParts(profile, w)) add(part, { ...meta, widgetIndex: i });++ drive = true;++ return;++ }++ if (w.type === 'ptz') {++ add(basePart('ptz', w.label || 'Camera stick', 'stick', {++ pressed: true, min: { w: 2, h: 2 }, never_covers: ['stop'],++ styles: { tints: ['surface', 'accent'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: true },++ bindings: { keys: [], gamepad: [] },++ }), { widgetIndex: i, widgetType: w.type });++ return;++ }++ if (ACTUATOR_WIDGETS[w.type]) {++ const kind = w.type === 'lights' || w.type === 'horn' ? 'button' : 'slider';++ add(basePart(ACTUATOR_WIDGETS[w.type], w.label || (w.type === 'pan-tilt' ? 'Pan and tilt' : w.type), kind, {++ min: { w: 2, h: kind === 'slider' ? 1 : 2 }, never_covers: ['stop'],++ styles: { tints: ['accent', 'surface'], shapes: ['pill', 'square', 'round'], opacity: { min: 0.4, max: 1 }, label: true },++ bindings: { keys: w.type === 'head' ? ['q', 'e'] : [], gamepad: [] },++ }), { widgetIndex: i, widgetType: w.type });++ return;++ }++ if (SIMPLE_WIDGETS[w.type]) {++ if (w.type === 'video_click' && hasCamera) return;++ const kind = SIMPLE_WIDGETS[w.type];++ add(basePart(w.type === 'video_click' ? 'camera' : w.type, w.label || (w.type === 'battery' ? 'Battery' : w.type === 'latency' ? 'Latency' : w.type === 'camera' || w.type === 'video_click' ? 'Camera' : w.type), kind, {++ pressed: kind === 'button' || kind === 'stick',++ min: w.type === 'latency' ? { w: 2, h: 1 } : w.type === 'camera' || w.type === 'video_click' ? { w: 2, h: 2 } : w.type === 'buttons' ? { w: 2, h: 2 } : { w: 1, h: 1 },++ never_covers: kind === 'button' || kind === 'stick' ? ['stop'] : [],++ styles: { tints: kind === 'readout' ? ['text', 'surface'] : ['surface', 'accent'], shapes: ['round', 'pill', 'square'], opacity: { min: 0.4, max: 1 }, label: true },++ bindings: { keys: [], gamepad: [] },++ }), { widgetIndex: i, widgetType: w.type });++ }++ });++ if (!drive) {++ // Every robot with a panel can be stopped, even one made only of a pan-tilt camera or buttons.++ if (!seen.has('stop')) add(basePart('stop', 'Stop', 'button', {++ required: true, pressed: true, min: { w: 2, h: 2, px: 68 },++ styles: { tints: ['danger', 'surface'], shapes: ['round', 'pill'], opacity: { min: 0.7, max: 1 }, label: true },++ bindings: { keys: ['Space'], gamepad: ['south'] },++ }));++ }++ return out;++}++++/** The full panel's catalog: bot.controls. */++function controlsCatalog(profile) {++ return {++ id: 'bot.controls', version: 1, name: 'Robot controls',++ description: 'The full OpenVibe.Bot panel: the drive stick, Stop, the head or camera slider, the buttons, the camera and the readouts.',++ devices: DEVICES, grid: GRID, theme_tokens: THEME_TOKENS,++ parts: partsOf(profile).map((p) => {++ const { widgetIndex, widgetType, ...part } = p; // eslint-disable-line no-unused-vars++ return part;++ }),++ };++}++++/** The overlay's catalog: the stick, the slider, the e-stop, more and the pill (plus Drive for a watcher). */++function overlayCatalog(profile, { slider = null, hasStick = false, canDrive = false } = {}) {++ const parts = [++ basePart('pill', 'The robot', 'readout', { pressed: false, min: { w: 1, h: 1 }, styles: { tints: ['surface'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true }, bindings: { keys: [], gamepad: [] } }),++ basePart('more', 'More', 'button', { pressed: true, min: { w: 1, h: 1 }, styles: { tints: ['surface'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false }, bindings: { keys: [], gamepad: [] } }),++ basePart('estop', 'E-stop', 'button', {++ required: true, pressed: true, min: { w: 2, h: 2, px: 56 }, never_covers: ['more', 'pill'],++ styles: { tints: ['danger'], shapes: ['round'], opacity: { min: 0.7, max: 1 }, label: true },++ bindings: { keys: ['Space'], gamepad: ['start'] },++ }),++ ];++ if (hasStick) parts.push(basePart('stick', 'Stick', 'stick', {++ pressed: true, min: { w: 2, h: 2 }, never_covers: ['estop', 'pill'],++ styles: { tints: ['surface', 'accent'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false },++ bindings: { keys: STICK_KEYS, gamepad: ['left-stick', 'dpad'] },++ }));++ if (slider) parts.push(basePart('slider', slider.name || 'Slider', 'slider', {++ pressed: true, min: { w: 2, h: 1 }, never_covers: ['estop'],++ styles: { tints: ['surface', 'accent'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true },++ bindings: { keys: [], gamepad: [] },++ }));++ if (canDrive) parts.push(basePart('drive', 'Drive', 'button', { pressed: true, min: { w: 2, h: 1 }, styles: { tints: ['accent'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true }, bindings: { keys: [], gamepad: [] } }));++ return {++ id: 'bot.overlay', version: 1, name: 'Robot over a stream',++ description: 'The controls over a robot’s stream: the stick, the slider, the e-stop, more and the robot’s pill.',++ devices: DEVICES, grid: GRID, theme_tokens: THEME_TOKENS, parts,++ };++}++++/** The catalog's part, or a stand-in when a stored layout names one this robot no longer has. */++function partOf(catalog, id) {++ return (catalog.parts || []).find((p) => p.id === id) || null;++}++++function entryOf(layout, id) {++ return ((layout && layout.parts) || []).find((p) => p.id === id) || null;++}++++function styleOf(part, entry) {++ const st = (entry && entry.style) || {};++ const tints = (part.styles && part.styles.tints) || ['surface'];++ const shapes = (part.styles && part.styles.shapes) || ['square'];++ return {++ tint: tints.includes(st.tint) ? st.tint : tints[0],++ shape: shapes.includes(st.shape) ? st.shape : shapes[0],++ opacity: typeof st.opacity === 'number' ? st.opacity : 1,++ label: typeof st.label === 'boolean' ? st.label : (part.styles && part.styles.label) !== false,++ };++}++++/** One part's entry in the layout shown now: the person's, else the default one. */++function resolvedEntry(catalog, layout, device, id) {++ const part = partOf(catalog, id);++ if (!part) return null;++ return entryOf(layout, id) || entryOf(defaultLayout(catalog, device), id) || null;++}++++/**++ * The default layout: the camera, then the stick and Stop, then the rest in the catalog's order. Phone rows are++ * full width; a computer gives the camera eight of its twelve columns and keeps the stick and Stop beside it.++ */++function defaultLayout(catalog, device = 'computer') {++ const d = DEVICES.includes(device) ? device : 'computer';++ const cols = GRID.columns[d];++ const order = [...(catalog.parts || [])].sort((a, b) => {++ const rank = (p) => (p.id === 'camera' ? -3 : p.id === 'stick' ? -2 : p.id === 'stop' ? -1 : p.id === 'pad' ? 0 : 1);++ return rank(a) - rank(b);++ });++ let x = 0; let y = 0; let rowH = 0;++ const parts = order.map((p) => {++ const camera = p.id === 'camera';++ const side = p.id === 'stick' || p.id === 'stop';++ let w = camera ? (d === 'computer' ? 8 : cols) : side ? 2 : Math.min(cols, Math.max(p.min.w, d === 'phone' ? cols : 4));++ if (p.kind === 'readout' && d !== 'phone') w = Math.min(cols, Math.max(2, p.min.w));++ w = Math.min(cols, w);++ let h = camera ? 3 : p.kind === 'readout' ? 1 : p.id === 'stop' || p.id === 'stick' ? 2 : Math.max(p.min.h, 2);++ if (p.kind === 'slider') h = Math.max(1, p.min.h);++ if (p.id === 'latency') h = 2;++ if (x + w > cols) { x = 0; y += rowH; rowH = 0; }++ const place = { x, y, w, h };++ x += w;++ rowH = Math.max(rowH, h);++ const st = styleOf(p, null);++ return { id: p.id, visible: true, place, style: st, bindings: { keys: [], gamepad: [] } };++ });++ return { surface: catalog.id, surface_version: catalog.version, device: d, name: 'Default', author: { name: 'OpenVibe' }, sharing: 'private', parts };++}++++/** The overlay's default layout: the pill and stick left, more and e-stop right, the slider at the bottom. */++function overlayDefaultLayout(catalog, device = 'computer') {++ const dockedOf = (id, dock, order, w = 1, h = 1) => {++ const p = partOf(catalog, id);++ if (!p) return null;++ return { id, visible: true, place: { dock, order, w: Math.max(p.min.w, w), h: Math.max(p.min.h, h) }, style: styleOf(p, null), bindings: { keys: [], gamepad: [] } };++ };++ const parts = [++ dockedOf('pill', 'left', 0, 2, 1),++ dockedOf('stick', 'left', 1, 2, 2),++ dockedOf('slider', 'bottom', 0, 2, 1),++ dockedOf('more', 'right', 0, 1, 1),++ dockedOf('estop', 'right', 2, 2, 2),++ dockedOf('drive', 'right', 0, 2, 1),++ ].filter(Boolean);++ return { surface: catalog.id, surface_version: catalog.version, device, name: 'Default', author: { name: 'OpenVibe' }, sharing: 'private', parts };++}++++const escAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');++++/**++ * The attributes that put one part's entry on the element: its grid area (or dock edge), its style variables++ * and whether this person hid it. `roleHidden` marks a part the page hides for a role reason (the e-stop before++ * a watcher takes the controls): the editor must not unhide it.++ */++function attrsFor(catalog, layout, device, id, { apply = true } = {}) {++ if (!apply) return '';++ const part = partOf(catalog, id);++ const e = resolvedEntry(catalog, layout, device, id);++ if (!part || !e) return '';++ const st = styleOf(part, e);++ const bits = [++ `data-ov-tint="${escAttr(st.tint)}"`, `data-ov-shape="${escAttr(st.shape)}"`, `data-ov-label="${st.label ? 'true' : 'false'}"`,++ ];++ const css = [`--ov-part-opacity:${st.opacity}`];++ if (e.place && e.place.dock) {++ bits.push(`data-ov-dock="${escAttr(e.place.dock)}"`, `data-ov-order="${Number(e.place.order) || 0}"`);++ bits.push(`data-ov-size-w="${Number(e.place.w) || part.min.w}"`, `data-ov-size-h="${Number(e.place.h) || part.min.h}"`);++ css.push(`--ov-part-w:${Number(e.place.w) || part.min.w}`, `--ov-part-h:${Number(e.place.h) || part.min.h}`);++ } else if (e.place) {++ bits.push(`data-ov-grid="${e.place.x},${e.place.y},${e.place.w},${e.place.h}"`);++ css.push(`grid-column:${e.place.x + 1} / span ${e.place.w}`, `grid-row:${e.place.y + 1} / span ${e.place.h}`);++ }++ if (e.visible === false) bits.push('hidden');++ bits.push(`style="${css.join(';')}"`);++ return bits.join(' ');++}++++/** The `.widgets` element's own attributes: the surface, its device class and its grid. */++function surfaceAttrs(catalog, layout, device, apply = true) {++ const cols = GRID.columns[device] || 12;++ const cell = GRID.cell_px[device] || 64;++ if (!apply) return `data-ov-surface="${escAttr(catalog.id)}" data-ov-device="${escAttr(device)}" data-ov-cols="${cols}" data-ov-cell="${cell}" style="--ov-cols:${cols};--ov-cell:${cell}px"`;++ return `data-ov-arranged="true" data-ov-surface="${escAttr(catalog.id)}" data-ov-device="${escAttr(device)}" data-ov-cols="${cols}" data-ov-cell="${cell}" style="--ov-cols:${cols};--ov-cell:${cell}px;grid-template-columns:repeat(${cols}, minmax(0, 1fr));grid-auto-rows:minmax(${cell}px, auto)"`;++}++++/** The overlay surface's attributes (no grid template: its parts dock to the edges). */++function overlaySurfaceAttrs(catalog, layout, device, apply = true) {++ const cols = GRID.columns[device] || 4;++ const cell = GRID.cell_px[device] || 64;++ if (!apply) return `data-ov-surface="${escAttr(catalog.id)}" data-ov-device="${escAttr(device)}" data-ov-cols="${cols}" data-ov-cell="${cell}" style="--ov-cols:${cols};--ov-cell:${cell}px"`;++ return `data-ov-arranged="true" data-ov-custom="true" data-ov-surface="${escAttr(catalog.id)}" data-ov-device="${escAttr(device)}" data-ov-cols="${cols}" data-ov-cell="${cell}" style="--ov-cols:${cols};--ov-cell:${cell}px"`;++}++++module.exports = { deviceClass, partsOf, controlsCatalog, overlayCatalog, defaultLayout, overlayDefaultLayout, attrsFor, surfaceAttrs, overlaySurfaceAttrs, styleOf, partOf, entryOf, THEME_TOKENS, GRID, DEVICES };nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++// The Arrange editor on the real panel, in headless Chrome (make your own controls, stage 2): the panel is drawn++// with its parts, Arrange loads openvibe-shared/ov-arrange.js only when pressed, Stop moves on the grid, Save posts++// the layout to the panel's own route, and Ask OpenVibe previews a suggestion before Keep. No robot and no++// Network: the page is rendered by renderPanel and the two arrange endpoints answer with a stub. Skipped without++// Chrome.++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const http = require('node:http');++const h = require('openvibe-shared/browser-harness');++const render = require('../server/web/render');++const surface = require('../server/web/surface');++const profile = require('../server/profiles/looi.json');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('arrange-browser: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++const sharedRoot = path.dirname(require.resolve('openvibe-shared/package.json'));++const catalog = surface.controlsCatalog(profile);++const defaults = surface.defaultLayout(catalog, 'phone');++const allowed = Object.keys(profile.commands);++const robot = { id: 'r1', name: 'Looi', profile_id: profile.id, access_policy: 'private', estop: { latched: false } };++const pageHtml = render.renderPanel({++ robot, profile, role: 'owner', allowed_commands: allowed,++ arrange: {++ catalog, parts: surface.partsOf(profile), layout: defaults, device: 'phone', signedIn: true, layoutSource: 'default',++ src: '/shared/ov-arrange.js', save: '/panel/r1/arrange', suggest: '/panel/r1/arrange/suggest', share: '/panel/r1/arrange/share',++ },++});++const calls = { save: null, suggest: null };++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ if (req.method === 'POST' && (u.pathname === '/panel/r1/arrange' || u.pathname === '/panel/r1/arrange/suggest')) {++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ const got = JSON.parse(body || '{}');++ res.writeHead(200, { 'content-type': 'application/json' });++ if (u.pathname.endsWith('/suggest')) {++ calls.suggest = got;++ const layout = JSON.parse(JSON.stringify(defaults));++ const stop = layout.parts.find((p) => p.id === 'stop');++ stop.place = { x: 0, y: 0, w: 2, h: 2 };++ const stick = layout.parts.find((p) => p.id === 'stick');++ stick.place = { x: 2, y: 0, w: 2, h: 2 };++ return res.end(JSON.stringify({ ok: true, layout, summary: 'Stop is first, beside the stick.', reasons: [] }));++ }++ calls.save = got;++ return res.end('{"ok":true}');++ });++ return;++ }++ if (u.pathname === '/panel/r1') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(pageHtml); }++ if (u.pathname.startsWith('/panel/')) {++ const file = path.join(__dirname, '..', 'public', u.pathname.slice('/panel/'.length));++ if (fs.existsSync(file)) { res.writeHead(200, { 'content-type': file.endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(file)); }++ }++ if (u.pathname.startsWith('/shared/')) {++ const file = path.join(sharedRoot, u.pathname.slice('/shared/'.length));++ if (fs.existsSync(file) && fs.statSync(file).isFile()) { res.writeHead(200, { 'content-type': file.endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(file)); }++ }++ res.writeHead(404); res.end('no');++});++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-arrange-bot-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ try {++ const p = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800 });++ await p.goto(base + '/panel/r1', { maxMs: 8000 });++ const until = async (expr) => { const end = Date.now() + 8000; while (Date.now() < end) { const v = await p.evaluate(expr); if (v) return v; await wait(50); } return false; };++ const evalv = (expr) => p.evaluate(expr);++ assert.ok(await until(`!!document.querySelector('[data-arrange]')`), 'the Arrange button is there');++ assert.ok(await evalv(`!window.OVArrange`), 'the editor is not loaded before Arrange is pressed');++ await evalv(`document.querySelector('[data-arrange]').click()`);++ assert.ok(await until(`!!window.OVArrange && !!window.ovArrange`), 'Arrange loads the editor and opens it');++ if (!await until(`!!document.querySelector('[data-ov-custom]')`)) console.log('DEBUG', await evalv(`JSON.stringify({custom: !!document.querySelector('[data-ov-custom]'), surfaces: [...document.querySelectorAll('[data-ov-surface]')].map(e=>e.className+':'+e.getAttribute('data-ov-custom')), bar: !!document.querySelector('.ov-arrange-bar'), status: document.querySelector('.ov-arrange-status') && document.querySelector('.ov-arrange-status').textContent, err: window.__err})`));++ assert.ok(await until(`!!document.querySelector('[data-ov-custom]')`), 'the editor applies the layout to the real surface');++ // The stick and Stop are in their own cells; the old pad wrapper flattens.++ assert.ok(await evalv(`getComputedStyle(document.querySelector('.widget-drive')).display === 'contents'`), 'the drive section flattens so its parts are grid cells');++ const before = await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stop').place.x`);++ for (let i = 0; i < 2; i++) await evalv(`(() => { const el = document.querySelector('[data-ov-part="stick"]'); el.focus(); el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); })()`);++ await evalv(`(() => { const el = document.querySelector('[data-ov-part="stop"]'); el.focus(); el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); })()`);++ assert.equal(await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stop').place.x`), before - 1, 'Stop moved one cell left');++ await evalv(`document.querySelector('[data-save]').click()`);++ assert.ok(calls.save && calls.save.layout, 'Save posted the layout to the panel route');++ // Ask OpenVibe: previewed, then kept.++ await evalv(`(() => { const f = document.querySelector('form[data-ask]'); f.querySelector('input').value = 'put Stop first'; f.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); })()`);++ assert.ok(await until(`!!document.querySelector('.ov-arrange-preview')`), 'the suggestion is previewed');++ await evalv(`document.querySelector('.ov-arrange-preview [data-keep]').click()`);++ assert.equal(await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stop').place.x`), 0, 'Keep applied the suggestion');++ assert.ok(calls.suggest && calls.suggest.words === 'put Stop first', 'the words reached the panel route');++ // The Controls v2 store becomes the person's first layout once, then is gone.++ const ctx2 = await browser.newContext();++ const p2 = await h.openPage(browser, ctx2, { width: 390, height: 800 });++ const p2until = async (expr) => { const end = Date.now() + 8000; while (Date.now() < end) { const v = await p2.evaluate(expr); if (v) return v; await wait(50); } return false; };++ await p2.goto(base + '/panel/r1', { maxMs: 8000 });++ await p2.evaluate(`localStorage.setItem('ov-bot-pad-prefs', JSON.stringify({ hand: 'left', size: 'l', readouts: 'off' }))`);++ await p2.goto(base + '/panel/r1', { maxMs: 8000 });++ assert.ok(await p2until(`localStorage.getItem('ov-bot-pad-prefs') === null`), 'the old store is retired after one migration');++ const migrated = JSON.parse(await p2.evaluate(`document.getElementById('ov-arrange-data').textContent`));++ assert.deepEqual(migrated.layout.parts.find((p) => p.id === 'stop').place, { x: 0, y: 0, w: 2, h: 2 }, 'Stop takes the left hand');++ assert.deepEqual(migrated.layout.parts.find((p) => p.id === 'stick').place, { x: 0, y: 2, w: 3, h: 3 }, 'the pad takes its larger size, moved clear of Stop on a phone');++ assert.equal(migrated.layout.parts.find((p) => p.id === 'battery').visible, false, 'the readouts are folded');++ assert.ok(await p2until(`!!document.querySelector('.widgets[data-ov-arranged]')`), 'the migrated layout is drawn on the parts');++ assert.equal(JSON.parse(await p2.evaluate(`document.getElementById('ov-arrange-data').textContent`)).layoutSource, 'default', 'the page was drawn from the default before the migration');++ await p2.close();++++ console.log('arrange-browser: Arrange on the panel, grid move, Save to the panel route, AI preview and Keep, and the pad store migrated once');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++// The panel's Network client for a person's layout (make your own controls, stage 2): the read is the person's++// own token, a 404 or an unreachable Network leaves the default, and save/suggest/share speak the person routes.++// No network: a fake fetch records the request.++const assert = require('node:assert/strict');++const layouts = require('../server/web/layouts');++++const config = { network: { url: 'https://network.example' } };++const layout = { surface: 'bot.controls', surface_version: 1, device: 'phone', name: 'Mine', author: { name: 'Ana' }, sharing: 'private', parts: [] };++const catalog = { id: 'bot.controls', version: 1, name: 'Robot controls', devices: ['phone'], grid: { columns: { phone: 4 }, cell_px: { phone: 64 } }, theme_tokens: ['surface'], parts: [] };++const seen = [];++const answer = (status, body) => async (url, init) => {++ seen.push({ url, method: init.method, auth: init.headers.Authorization, body: init.body ? JSON.parse(init.body) : null });++ return { status, ok: status >= 200 && status < 300, json: async () => body };++};++++(async () => {++ assert.equal(await layouts.activeLayout({ config, token: null, surface: 'bot.controls', device: 'phone', fetchImpl: async () => { throw new Error('must not call'); } }), null, 'signed out never calls Network');++ assert.equal(await layouts.activeLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', fetchImpl: answer(404, { error: 'none' }) }), null, 'no layout is the default');++ const got = await layouts.activeLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', fetchImpl: answer(200, { layout }) });++ assert.deepEqual(got, layout);++ assert.match(seen.at(-1).url, /\/api\/v1\/me\/layouts\/bot\.controls\?device=phone$/);++ assert.equal(seen.at(-1).auth, 'Bearer t', 'the person’s own token');++++ await layouts.saveLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', layout, catalog, fetchImpl: answer(200, { ...layout, id: 'lay_x' }) });++ assert.equal(seen.at(-1).method, 'PUT');++ assert.deepEqual(seen.at(-1).body, { catalog, layout });++++ const sug = await layouts.suggestLayout({ config, token: 't', surface: 'bot.overlay', device: 'phone', words: 'put Stop left', layout, catalog, fetchImpl: answer(200, { ok: true, layout, summary: 'Stop is left.', reasons: [] }) });++ assert.equal(sug.ok, true);++ assert.match(seen.at(-1).url, /\/api\/v1\/me\/layouts\/bot\.overlay\/suggest\?device=phone$/);++ assert.equal(seen.at(-1).body.words, 'put Stop left');++++ await assert.rejects(() => layouts.saveLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', layout, catalog, fetchImpl: answer(422, { detail: 'Stop has to stay visible.' }) }), /Stop has to stay visible\./);++++ assert.deepEqual(await layouts.resetLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', fetchImpl: answer(204, null) }), { ok: true });++ assert.match(seen.at(-1).url, /\/api\/v1\/me\/layouts\/bot\.controls\/reset\?device=phone$/);++ assert.deepEqual(await layouts.resetLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', fetchImpl: answer(404, { code: 'layout.none' }) }), { ok: true }, 'nothing to reset is the default already');++++ const shared = await layouts.shareLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', layout, catalog, fetchImpl: (url, init) => {++ if (init.method === 'PUT') return answer(200, { ...layout, id: 'lay_abc' })(url, init);++ return answer(200, { url: '/api/v1/layouts/shared/tok_1' })(url, init);++ } });++ assert.match(seen.at(-1).url, /\/api\/v1\/me\/layouts\/lay_abc\/share$/);++ assert.equal(shared.url, 'https://network.example/api/v1/layouts/shared/tok_1');++ console.log('arrange-layouts: read, 404, save, suggest and a refusal');++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++// The panel pages carry the Arrange surface (make your own controls, stage 2): the person's layout is drawn on the++// real parts server-side, the editor itself is not loaded until Arrange is pressed, the old pad menu is gone, and++// the overlay marks its own parts. Nothing here changes a robot command.++const assert = require('node:assert/strict');++const render = require('../server/web/render');++const surface = require('../server/web/surface');++const profile = require('../server/profiles/looi.json');++const { validate } = require('openvibe-sdk/ui-layout');++++const robot = { id: 'r1', name: 'Looi', profile_id: profile.id, access_policy: 'private', estop: { latched: false } };++const allowed = Object.keys(profile.commands);++const catalog = surface.controlsCatalog(profile);++const defaults = surface.defaultLayout(catalog, 'phone');++++function panel(extra = {}) {++ return render.renderPanel({++ robot, profile, role: 'owner', allowed_commands: allowed,++ arrange: {++ catalog, parts: surface.partsOf(profile), layout: defaults, device: 'phone', signedIn: true,++ layoutSource: 'default', src: '/shared/ov-arrange.js?v=abc', save: '/panel/r1/arrange',++ suggest: '/panel/r1/arrange/suggest', share: '/panel/r1/arrange/share', ...extra,++ },++ });++}++++const html = panel();++assert.ok(html.includes('data-arrange'), 'the Arrange button is in the panel head');++assert.ok(/data-arrange-src="\/shared\/ov-arrange\.js\?v=abc"/.test(html), 'it names the shared editor to load when pressed');++assert.ok(!/<script[^>]+ov-arrange\.js/.test(html), 'the editor is not loaded until Arrange is pressed');++assert.ok(html.includes('id="ov-arrange-data"'), 'the catalog and layout reach the client as data');++assert.ok(!html.includes('pad-prefs'), 'the old sliders-icon arrangement menu is gone');++assert.ok(html.includes('data-ov-part="stop"') && html.includes('data-ov-part="stick"') && html.includes('data-ov-part="head"'), 'the real parts are marked');++assert.ok(html.includes('data-ov-surface="bot.controls"'), 'the panel names its surface');++assert.ok(!html.includes('data-ov-arranged="true"'), 'without a stored layout the page keeps its own look (the editor applies the grid when opened)');++++const stored = surface.defaultLayout(catalog, 'phone');++stored.parts.find((p) => p.id === 'battery').visible = false;++const withLayout = panel({ layout: stored, layoutSource: 'account' });++assert.ok(/data-ov-arranged="true"/.test(withLayout), 'a stored layout draws the arranged grid on the server');++assert.ok(/grid-column:1 \/ span 2/.test(withLayout), 'the stick is drawn in its cells');++assert.ok(/data-ov-part="battery"[^>]*hidden/.test(withLayout) || /hidden[^>]*data-ov-part="battery"/.test(withLayout), 'a hidden readout is hidden before first paint');++++const overlayCatalog = surface.overlayCatalog(profile, { slider: { name: 'head' }, hasStick: true, canDrive: false });++const overlay = render.renderPanel({++ robot, profile, role: 'watcher', allowed_commands: [], mode: 'embed', view: 'overlay', signedIn: false,++ arrange: {++ catalog: overlayCatalog, layout: surface.overlayDefaultLayout(overlayCatalog, 'phone'), device: 'phone',++ signedIn: false, layoutSource: 'default', save: '/panel/r1/arrange', suggest: '/panel/r1/arrange/suggest', share: '/panel/r1/arrange/share',++ },++});++for (const id of ['pill', 'stick', 'slider', 'estop', 'more']) assert.ok(overlay.includes(`data-ov-part="${id}"`), `the overlay marks ${id}`);++assert.ok(overlay.includes('data-ov-role-hidden="true"'), 'the watcher’s e-stop stays role-hidden until Drive');++assert.ok(overlay.includes('id="ov-arrange-data"'), 'the overlay carries its catalog and layout too');++assert.ok(overlayCatalog.parts.find((p) => p.id === 'estop').required, 'the overlay catalog makes the e-stop required');++++// The default layouts are what the SDK would accept, the one check every surface shares.++assert.deepEqual(validate(catalog, defaults), { ok: true, reasons: [] });++assert.deepEqual(validate(overlayCatalog, surface.overlayDefaultLayout(overlayCatalog, 'phone')), { ok: true, reasons: [] });++console.log('arrange-panel: parts, server-side layout, lazy editor, no old menu, stored layout and overlay');nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++// The panel's arrangeable surfaces (make your own controls, stage 2): bot.controls is built from the robot's own++// widgets, Stop and the overlay's e-stop are required, the default layouts pass openvibe-sdk/ui-layout, and the++// attributes put a person's layout on the real parts.++const assert = require('node:assert/strict');++const { validate } = require('openvibe-sdk/ui-layout');++const surface = require('../server/web/surface');++const looi = require('../server/profiles/looi.json');++const rover = require('../server/profiles/sim.rover.json');++++const ids = (catalog) => catalog.parts.map((p) => p.id);++const cat = surface.controlsCatalog(looi);++assert.deepEqual(ids(cat), ['stick', 'stop', 'head', 'battery', 'latency'], 'the LOOI panel: the stick, Stop, the head slider and the readouts');++assert.equal(cat.parts.find((p) => p.id === 'stop').required, true, 'Stop is required');++assert.equal(cat.parts.find((p) => p.id === 'stick').styles.label, false, 'the stick has no label');++for (const device of ['phone', 'tablet', 'computer']) {++ const layout = surface.defaultLayout(cat, device);++ assert.deepEqual(validate(cat, layout), { ok: true, reasons: [] }, `the ${device} default layout is valid`);++}++const roverCat = surface.controlsCatalog(rover);++assert.ok(ids(roverCat).includes('camera') && ids(roverCat).includes('stick') && ids(roverCat).includes('lights'), 'the rover: the camera, the drive stick, Stop and its controls');++assert.ok(ids(roverCat).includes('telemetry.2'), 'a second widget of one type gets its own part');++assert.deepEqual(validate(roverCat, surface.defaultLayout(roverCat, 'phone')), { ok: true, reasons: [] });++++const overlay = surface.overlayCatalog(looi, { slider: { name: 'head' }, hasStick: true, canDrive: true });++assert.deepEqual(ids(overlay), ['pill', 'more', 'estop', 'stick', 'slider', 'drive']);++assert.equal(overlay.parts.find((p) => p.id === 'estop').required, true, 'the overlay e-stop is required');++assert.deepEqual(validate(overlay, surface.overlayDefaultLayout(overlay, 'phone')), { ok: true, reasons: [] }, 'the overlay default is valid');++++// A hidden readout and a moved Stop reach the element's attributes; a default (not the person's own) leaves the++// page's own look alone and only names the surface.++const layout = surface.defaultLayout(cat, 'phone');++layout.parts.find((p) => p.id === 'battery').visible = false;++assert.match(surface.attrsFor(cat, layout, 'phone', 'battery'), /hidden/, 'a hidden part carries hidden');++const stop = surface.attrsFor(cat, layout, 'phone', 'stop');++assert.match(stop, /grid-column:3 \/ span 2/, 'Stop is beside the stick');++assert.equal((stop.match(/style=/g) || []).length, 1, 'one style attribute');++assert.match(stop, /--ov-part-opacity:1/, 'its opacity comes from the layout');++assert.equal(surface.attrsFor(cat, layout, 'phone', 'stop', { apply: false }), '', 'the default is the page, not the layout');++assert.match(surface.surfaceAttrs(cat, layout, 'phone'), /data-ov-arranged="true"/);++assert.match(surface.surfaceAttrs(cat, layout, 'phone', false), /data-ov-surface="bot.controls"/);++assert.doesNotMatch(surface.surfaceAttrs(cat, layout, 'phone', false), /data-ov-arranged/);++++assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' } }), 'phone');++assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)' } }), 'tablet');++assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)' } }), 'computer');++console.log('surface: bot.controls and bot.overlay catalogs, required safety parts, valid defaults and part attributes');
@@ -3,6 +3,7 @@ on:
@@ -3,6 +3,7 @@ on:
push:
push:
branches: [main]
branches: [main]
pull_request:
pull_request:
+ workflow_dispatch:
jobs:
jobs:
# install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
# install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
test:
test:
@@ -18,6 +18,15 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -18,6 +18,15 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
- The front page, `GET /` (`server/web/home.js`, `public/home.css`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator, routines, webhooks and the agent switch), and data sections rendered on the server, each left out when it has nothing to show: numbers from the catalogue, the simulated rover and the listed robots with their online state, every catalogue model with its connection kind and controls, the connection kinds with their models, the first kit's parts list, robotics jobs from OpenVibe.Search (`server/web/network-reads.js`: OpenVibe.Work's documents, read anonymously with `BOT_SEARCH_URL`, cached 15 minutes) and the questions people ask first (JSON-LD WebSite, WebApplication, ItemList and FAQPage). It has its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.
- The front page, `GET /` (`server/web/home.js`, `public/home.css`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator, routines, webhooks and the agent switch), and data sections rendered on the server, each left out when it has nothing to show: numbers from the catalogue, the simulated rover and the listed robots with their online state, every catalogue model with its connection kind and controls, the connection kinds with their models, the first kit's parts list, robotics jobs from OpenVibe.Search (`server/web/network-reads.js`: OpenVibe.Work's documents, read anonymously with `BOT_SEARCH_URL`, cached 15 minutes) and the questions people ask first (JSON-LD WebSite, WebApplication, ItemList and FAQPage). It has its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.
- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.
- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.
+- **Make your own controls.** The panel and the controls over a stream are arrangeable surfaces: Bot publishes
+ `bot.controls` and `bot.overlay` as `ui.surface@1` catalogs built from the robot's own profile, and each person's
+ `ui.layout@1` (kept with their OpenVibe.Network account, one per device class) is read server-side and drawn on the
+ real parts before first paint. **Arrange** loads `openvibe-shared/ov-arrange.js` only then: drag to move, the corner
+ to resize, arrows and Shift with the arrows, a long press for a part's visible/size/colour/shape/opacity/label and
+ bindings, live validation through `openvibe-sdk/ui-layout` with the reason in plain words, Undo, Reset to default,
+ Save and Share, and **Ask OpenVibe** previewed on the parts with Keep and Discard. Stop and the overlay's e-stop are
+ required; a layout only arranges, and every command still passes the same gate and the robot's limits. The old
+ browser store (`ov-bot-pad-prefs`: Stop's hand, pad size, readouts) is migrated into the person's first layout once.
- The robot directory (`server/web/directory.js`, inside the OpenVibe Frame): `/robots` is public and lists the robots their owners list (online state, model, owner, who may drive), every model Bot drives and, signed in, your own robots; `/models` and `/models/:id` say how each model connects, its setup steps, its guide and the controls its panel gets; `/r/:id` is a listed robot's public page, watching it live through the embeddable panel; `/robots/new` adds one (name, what it is, who may drive). The owner lists a robot from its panel (`POST /robots/:id/listing`, migration 0009): listing turns on watching (`embed_public`), and turning watching off unlists it.
- The robot directory (`server/web/directory.js`, inside the OpenVibe Frame): `/robots` is public and lists the robots their owners list (online state, model, owner, who may drive), every model Bot drives and, signed in, your own robots; `/models` and `/models/:id` say how each model connects, its setup steps, its guide and the controls its panel gets; `/r/:id` is a listed robot's public page, watching it live through the embeddable panel; `/robots/new` adds one (name, what it is, who may drive). The owner lists a robot from its panel (`POST /robots/:id/listing`, migration 0009): listing turns on watching (`embed_public`), and turning watching off unlists it.
- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, e-stop, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.
- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, e-stop, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.
- The owner's agent: the panel's **Your agent** card ("Let my OpenVibe.Actor agent drive this robot", `robots.agent_control`, migration 0012) lets `svc:actor`, acting for the robot's owner and nobody else, read the robot (`GET /api/v1/robots/:id/steps`: online, the e-stop, battery, sensors and the step lines it takes) and drive it with plain step lines (`POST …/steps`, at most 20 steps and 30 seconds, answered when they end) or one command (`POST …/commands`). Every step passes the same gate as the owner's own commands and is audited as the service. Off, or for anyone but the owner, it answers `403 bot.agent_not_allowed`.
- The owner's agent: the panel's **Your agent** card ("Let my OpenVibe.Actor agent drive this robot", `robots.agent_control`, migration 0012) lets `svc:actor`, acting for the robot's owner and nobody else, read the robot (`GET /api/v1/robots/:id/steps`: online, the e-stop, battery, sensors and the step lines it takes) and drive it with plain step lines (`POST …/steps`, at most 20 steps and 30 seconds, answered when they end) or one command (`POST …/commands`). Every step passes the same gate as the owner's own commands and is audited as the service. Off, or for anyone but the owner, it answers `403 bot.agent_not_allowed`.
@@ -146,7 +155,7 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -146,7 +155,7 @@ Sitemap entries use the release commit time for these code-backed pages.
- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).
- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).
- OpenRestream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
- OpenRestream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`; Bot mints its own Network service token for audience `openvibe.billing` with `billing.usage.record`, and `BOT_BILLING_TOKEN` overrides it); unset, they wait in `run_usage_outbox`.
- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`; Bot mints its own Network service token for audience `openvibe.billing` with `billing.usage.record`, and `BOT_BILLING_TOKEN` overrides it); unset, they wait in `run_usage_outbox`.
-- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.150.0 and `openvibe-shared` v4.211.0 (package.json).
+- `openvibe-contracts` v0.410.0, `openvibe-sdk` v0.152.0 and `openvibe-shared` v4.214.0 (package.json).
- Account export and deletion (ADR-033): Network grants `events.subscription.manage` (openvibe.events) for the two
- Account export and deletion (ADR-033): Network grants `events.subscription.manage` (openvibe.events) for the two
subscriptions created at boot, then, last and once the release is live, `network.account.export.contribute` and
subscriptions created at boot, then, last and once the release is live, `network.account.export.contribute` and
`network.account.deletion.confirm` (openvibe.network).
`network.account.deletion.confirm` (openvibe.network).
@@ -236,7 +245,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
@@ -236,7 +245,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.408.0
-- openvibe-sdk: v0.150.0
-- openvibe-shared: v4.211.0
+- openvibe-contracts: v0.410.0
+- openvibe-sdk: v0.152.0
+- openvibe-shared: v4.214.0
<!-- versions:end -->
<!-- versions:end -->
@@ -5,13 +5,13 @@
@@ -5,13 +5,13 @@
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"port": 4630,
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
- "contracts": "openvibe-contracts v0.409.0",
- "sdk": "openvibe-sdk v0.150.0",
- "shared": "openvibe-shared v4.211.0",
+ "contracts": "openvibe-contracts v0.410.0",
+ "sdk": "openvibe-sdk v0.152.0",
+ "shared": "openvibe-shared v4.214.0",
"packages": [
"packages": [
- "openvibe-contracts v0.409.0",
- "openvibe-sdk v0.150.0",
- "openvibe-shared v4.211.0"
+ "openvibe-contracts v0.410.0",
+ "openvibe-sdk v0.152.0",
+ "openvibe-shared v4.214.0"
],
],
"capabilities": [
"capabilities": [
"bot.robot.read",
"bot.robot.read",
@@ -65,7 +65,8 @@
@@ -65,7 +65,8 @@
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects",
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects",
"a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)",
"a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)",
"machines hub (plan L34): Bot stays the registry; the hub's routes (GET /api/v1/machines, GET /api/v1/machines/:id, POST /api/v1/machines/pairings, PATCH /api/v1/machines/:id, PUT /api/v1/machines/:id/roles, POST /api/v1/machines/:id/revoke) serve the person and the network's own service (svc:network, and only that one) acting for them (bot.machine.manage, X-OV-Subject), with the machine's roles (migration 0028) in the device's config frame and its online state reported to Network once per change",
"machines hub (plan L34): Bot stays the registry; the hub's routes (GET /api/v1/machines, GET /api/v1/machines/:id, POST /api/v1/machines/pairings, PATCH /api/v1/machines/:id, PUT /api/v1/machines/:id/roles, POST /api/v1/machines/:id/revoke) serve the person and the network's own service (svc:network, and only that one) acting for them (bot.machine.manage, X-OV-Subject), with the machine's roles (migration 0028) in the device's config frame and its online state reported to Network once per change",
- "watch links to a desk (plan L55): POST /api/v1/machines/:id/watch-links gives the owner's agent (svc:actor, bot.machine.watch_link) a link that shows the desk the agent works on, live, inside a chat (https://openvibe.bot/screen/<token>, no sign-in, framable on openvibe.chat and openvibe.actor); the machine is the one the named person owns, the link lives 5 to 240 minutes, and it carries the mouse and keyboard only when asked for"
+ "watch links to a desk (plan L55): POST /api/v1/machines/:id/watch-links gives the owner's agent (svc:actor, bot.machine.watch_link) a link that shows the desk the agent works on, live, inside a chat (https://openvibe.bot/screen/<token>, no sign-in, framable on openvibe.chat and openvibe.actor); the machine is the one the named person owns, the link lives 5 to 240 minutes, and it carries the mouse and keyboard only when asked for",
+ "make your own controls (stage 2): the panel's bot.controls and the overlay's bot.overlay are ui.surface@1 catalogs built from the robot's profile, each person's ui.layout@1 comes from OpenVibe.Network and is drawn server-side before first paint, Arrange (openvibe-shared/ov-arrange.js, loaded only on press) moves, resizes, styles and hides parts with live openvibe-sdk/ui-layout validation, Undo/Reset/Save/Share and an Ask OpenVibe suggestion previewed before it is kept; Stop and the e-stop are required and robot commands are untouched"
],
],
- "updated": "2026-11-11"
+ "updated": "2026-11-12"
}
}
@@ -13,9 +13,9 @@
@@ -13,9 +13,9 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"qrcode-generator": "^2.0.4",
"qrcode-generator": "^2.0.4",
"ws": "^8.18.0"
"ws": "^8.18.0"
@@ -1201,9 +1201,9 @@
@@ -1201,9 +1201,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.409.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",
- "integrity": "sha512-Yh8XcOEKIrYG7WV8ack03hmobZwMyZIeOogIrCeIWvuHKpvjYYL+UcOj7DrNalxC5fp0EnhVwRj89DTFm/AH2g==",
+ "version": "0.410.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
+ "integrity": "sha512-rmmq2mebXim2fv+sip2YQ0PrrZLxGUhEfe1w+1EFfGdoVvLI5tp0/eavY2sW6/VU+rzs7cQMC/A0NZD5th5wrA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1217,9 +1217,9 @@
@@ -1217,9 +1217,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.150.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
- "integrity": "sha512-5k2PovDQ2zhYXxLHtNNm1Q3rzPMXLpdh0lIYdl9kfa6D2/X2gYozT5W0CHRCIMAK4UhjbsZaXOSMM8GbkJWsNw==",
+ "version": "0.152.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
+ "integrity": "sha512-/nZbjF9KTJPa3582v60tx238BBDa47tJAmxVkbgSWRReXgMHHGAhV8FGKqFmxPHOxLzvMgVp0Ei4mz4A4jsUvQ==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1245,9 +1245,9 @@
@@ -1245,9 +1245,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.211.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
- "integrity": "sha512-3qMNLpByLNQPoCb9HGirVM5UTIPVYMLNshyhB185w1djaF3MLKnKahgwDpSlO3L8MAvhr+ZDzhyaedQdqTWcBQ==",
+ "version": "4.214.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
+ "integrity": "sha512-HvaqQOZLLFEgwDaCkrhJFfwQfsMIlOmV05PhO796JFmC7A1NScxOqp86djT6sYsK1pARMqeo0kTua5mAdXiQFw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -25,9 +25,9 @@
@@ -25,9 +25,9 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"qrcode-generator": "^2.0.4",
"qrcode-generator": "^2.0.4",
"ws": "^8.18.0"
"ws": "^8.18.0"
@@ -113,6 +113,22 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -113,6 +113,22 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.online[data-state="connecting"] .dot { animation: pulse 1s ease-in-out infinite; }
.online[data-state="connecting"] .dot { animation: pulse 1s ease-in-out infinite; }
.notice { margin: 0 0 0.75rem; }
.notice { margin: 0 0 0.75rem; }
+.arrange-btn { display: inline-flex; align-items: center; gap: 0.4rem; min-height: 40px; padding: 0 0.8rem; margin: 0.25rem 0 0.5rem; border: 1px solid var(--line); border-radius: 10px; background: var(--raised); color: var(--text); font: inherit; cursor: pointer; }
+.arrange-btn:hover { border-color: color-mix(in srgb, var(--line) 65%, var(--text) 35%); }
+.arrange-btn .icon { width: 1.1rem; height: 1.1rem; }
+
+/* The arranged panel: each part is a cell of the grid the layout names (make your own controls, stage 2). The
+ drive section flattens so its stick, Stop and direction buttons are cells of the same grid; the old heading and
+ key hint are decoration and step out of the way. */
+.widgets[data-ov-arranged] { display: grid; }
+[data-ov-flatten] { display: contents; }
+[data-ov-flatten] > [data-ov-decor] { display: none; }
+[data-ov-part][hidden] { display: none !important; }
+.widgets[data-ov-arranged] > [data-ov-part] { overflow: auto; }
+#panel :is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .stick { width: 100%; height: 100%; }
+:is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .drive-pad { display: contents; }
+#panel :is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .stop { width: 100%; height: 100%; border-radius: 50%; }
+
/* Widgets: one column on a phone held upright. The camera comes first and the drive pad right under it, so the
/* Widgets: one column on a phone held upright. The camera comes first and the drive pad right under it, so the
picture and the controls are on screen together; the readouts and the other controls follow. */
picture and the controls are on screen together; the readouts and the other controls follow. */
.widgets { display: grid; gap: 0.75rem; grid-template-columns: minmax(0, 1fr); grid-auto-flow: dense; }
.widgets { display: grid; gap: 0.75rem; grid-template-columns: minmax(0, 1fr); grid-auto-flow: dense; }
@@ -681,11 +697,28 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
@@ -681,11 +697,28 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
url("/panel/lock.svg");
url("/panel/lock.svg");
}
}
+/* A person's docked overlay: the four edges and their order. The default overlay keeps the rules above; this
+ only takes over when the layout is the person's own (data-ov-custom). */
+[data-ov-custom] [data-ov-part][data-ov-dock] { position: absolute; }
+[data-ov-custom] [data-ov-part][data-ov-dock="left"] { left: calc(16px + env(safe-area-inset-left)); right: auto; }
+[data-ov-custom] [data-ov-part][data-ov-dock="right"] { right: calc(16px + env(safe-area-inset-right)); left: auto; }
+[data-ov-custom] [data-ov-part][data-ov-dock="top"] { top: calc(16px + env(safe-area-inset-top)); bottom: auto; }
+[data-ov-custom] [data-ov-part][data-ov-dock="bottom"] { bottom: calc(16px + env(safe-area-inset-bottom)); top: auto; }
+[data-ov-custom] [data-ov-part][data-ov-dock="left"][data-ov-order="0"],
+[data-ov-custom] [data-ov-part][data-ov-dock="right"][data-ov-order="0"] { top: calc(16px + env(safe-area-inset-top)); bottom: auto; }
+[data-ov-custom] [data-ov-part][data-ov-dock="left"][data-ov-order="1"],
+[data-ov-custom] [data-ov-part][data-ov-dock="right"][data-ov-order="1"] { bottom: calc(16px + env(safe-area-inset-bottom)); top: auto; }
+[data-ov-custom] [data-ov-part][data-ov-dock="left"][data-ov-order="2"],
+[data-ov-custom] [data-ov-part][data-ov-dock="right"][data-ov-order="2"] { bottom: calc(80px + env(safe-area-inset-bottom)); top: auto; }
+[data-ov-custom] [data-ov-part="more"][data-ov-dock="right"] { top: calc(68px + env(safe-area-inset-top)); bottom: auto; }
+[data-ov-custom] [data-ov-part="slider"][data-ov-dock="bottom"] { left: 50%; transform: translateX(-50%); }
+
/* ── Controls v2 (ask 368, 2026-11-11: the owner circled the pad, the Stop, the head slider and the readouts:
/* ── Controls v2 (ask 368, 2026-11-11: the owner circled the pad, the Stop, the head slider and the readouts:
"redesign … polish … more consistent … per user"). One family on the full panel, in a channel's Robot tab and
"redesign … polish … more consistent … per user"). One family on the full panel, in a channel's Robot tab and
framed anywhere: the stick has its four marks and a knob that lights when it drives; Stop is a ringed red disc
framed anywhere: the stick has its four marks and a knob that lights when it drives; Stop is a ringed red disc
beside it (on the hand this person chose); a slider says its value with − and + to step it; battery and latency
beside it (on the hand this person chose); a slider says its value with − and + to step it; battery and latency
- are slim readout rows. The pad's arrangement menu (the sliders icon) keeps the hand, the size and the readouts. ─── */
+ are slim readout rows. The Arrange button (make your own controls) replaces the old arrangement menu; the pad's
+ hand, size and readouts are now the person's ui.layout@1. ─── */
#panel:not(.bot-surface) .widget-drive { position: relative; }
#panel:not(.bot-surface) .widget-drive { position: relative; }
#panel:not(.bot-surface) .drive-pad { grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 0.75rem 1.4rem; }
#panel:not(.bot-surface) .drive-pad { grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 0.75rem 1.4rem; }
#panel:not(.bot-surface) .drive-pad > :not(.stick):not(.stop) { grid-column: 1 / -1; }
#panel:not(.bot-surface) .drive-pad > :not(.stick):not(.stop) { grid-column: 1 / -1; }
@@ -731,21 +764,6 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
@@ -731,21 +764,6 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
#panel:not(.bot-surface) .drive-pad .stop:active:not(:disabled) { transform: scale(0.94); }
#panel:not(.bot-surface) .drive-pad .stop:active:not(:disabled) { transform: scale(0.94); }
#panel:not(.bot-surface) .drive-pad .stop:disabled { opacity: 0.5; filter: saturate(0.5); }
#panel:not(.bot-surface) .drive-pad .stop:disabled { opacity: 0.5; filter: saturate(0.5); }
-/* The arrangement menu: a quiet icon in the drive card's corner, its choices as segmented rows. */
-#panel:not(.bot-surface) .pad-prefs-btn { position: absolute; top: 0.45rem; right: 0.45rem; min-width: 36px; min-height: 36px; width: 36px; height: 36px; padding: 0; border-radius: 10px; display: grid; place-items: center; background: transparent; border-color: transparent; color: var(--muted); }
-#panel:not(.bot-surface) .pad-prefs-btn:hover, #panel:not(.bot-surface) .pad-prefs-btn[aria-expanded="true"] { color: var(--text); background: var(--raised); }
-#panel:not(.bot-surface) .pad-prefs-btn .icon { width: 1.15rem; height: 1.15rem; }
-#panel:not(.bot-surface) .pad-prefs { position: absolute; top: 2.9rem; right: 0.45rem; z-index: 5; display: grid; gap: 0.6rem; width: min(17rem, calc(100% - 0.9rem)); padding: 0.75rem; border-radius: 14px; background: var(--surface); border: 1px solid var(--line); box-shadow: 0 16px 36px -12px rgba(0, 0, 0, 0.6); }
-#panel:not(.bot-surface) .pad-prefs[hidden] { display: none; }
-#panel:not(.bot-surface) .pad-pref { display: grid; gap: 0.3rem; }
-#panel:not(.bot-surface) .pad-pref-l { font-size: 0.72rem; font-weight: 600; color: var(--muted); }
-#panel:not(.bot-surface) .pad-pref-opts { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 3px; padding: 3px; border-radius: 10px; background: var(--bg); }
-#panel:not(.bot-surface) .pad-pref label { position: relative; }
-#panel:not(.bot-surface) .pad-pref input { position: absolute; inset: 0; opacity: 0; margin: 0; width: 100%; height: 100%; min-height: 0; cursor: pointer; }
-#panel:not(.bot-surface) .pad-pref span { display: grid; place-items: center; min-height: 34px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; color: var(--muted); }
-#panel:not(.bot-surface) .pad-pref input:checked + span { background: var(--raised); color: var(--text); box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05), 0 2px 6px rgba(0, 0, 0, 0.25); }
-#panel:not(.bot-surface) .pad-pref input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 1px; }
-
/* A slider: its name and value on one line, − the track + on the next; the track fills to the value. */
/* A slider: its name and value on one line, − the track + on the next; the track fills to the value. */
#panel:not(.bot-surface) .control.slider { display: grid; gap: 0.4rem; align-items: stretch; }
#panel:not(.bot-surface) .control.slider { display: grid; gap: 0.4rem; align-items: stretch; }
#panel:not(.bot-surface) .slider-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; margin: 0; }
#panel:not(.bot-surface) .slider-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; margin: 0; }
@@ -789,7 +807,6 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
@@ -789,7 +807,6 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
#panel:not(.bot-surface) .notice[hidden] { display: none; }
#panel:not(.bot-surface) .notice[hidden] { display: none; }
@media (prefers-reduced-motion: reduce) { #panel:not(.bot-surface) .stick, #panel:not(.bot-surface) .drive-pad .stop, #panel:not(.bot-surface) .stick-dirs { transition: none; } }
@media (prefers-reduced-motion: reduce) { #panel:not(.bot-surface) .stick, #panel:not(.bot-surface) .drive-pad .stop, #panel:not(.bot-surface) .stick-dirs { transition: none; } }
-#panel:not(.bot-surface) .pad-prefs { text-align: left; }
/* A wide panel lays the cards in columns: each card is as tall as what it holds (the battery was a tall empty box). */
/* A wide panel lays the cards in columns: each card is as tall as what it holds (the battery was a tall empty box). */
#panel:not(.bot-surface) .widgets { align-items: start; }
#panel:not(.bot-surface) .widgets { align-items: start; }
@@ -897,30 +897,120 @@
@@ -897,30 +897,120 @@
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}));
}));
- // The pad's arrangement, this person's (kept in this browser): the Stop's hand, the pad's size, the readouts.
+ // ── Make your own controls: the Arrange editor and the Controls v2 store's one migration ─────────────
+ // The panel is drawn with the person's ui.layout@1 on the server (never a flash of the default). Arrange
+ // loads openvibe-shared/ov-arrange.js only when pressed; the editor works on these real parts. The old
+ // browser store (ov-bot-pad-prefs: Stop's hand, pad size, readouts) becomes a layout once, then goes.
+ const arrangeData = () => {
+ const el = document.getElementById('ov-arrange-data');
+ if (!el) return null;
+ try { return { el, data: JSON.parse(el.textContent) }; } catch { return null; }
+ };
(() => {
(() => {
- const KEY = 'ov-bot-pad-prefs';
- const panelEl = document.getElementById('panel');
- const btn = document.querySelector('[data-pad-prefs]');
- const menu = document.querySelector('[data-pad-prefs-menu]');
- if (!panelEl) return;
- let prefs = { hand: 'right', size: 'm', readouts: 'on' };
- try { prefs = { ...prefs, ...(JSON.parse(localStorage.getItem(KEY) || '{}') || {}) }; } catch { /* private mode */ }
- const apply = () => {
- panelEl.dataset.hand = prefs.hand; panelEl.dataset.pad = prefs.size; panelEl.dataset.readouts = prefs.readouts;
- $$('[data-pad-pref]').forEach((r) => { r.checked = prefs[r.dataset.padPref] === r.value; });
+ let raw = null;
+ try { raw = localStorage.getItem('ov-bot-pad-prefs'); if (raw != null) localStorage.removeItem('ov-bot-pad-prefs'); } catch { /* private mode */ }
+ if (raw == null) return;
+ const got = arrangeData();
+ if (!got || got.data.layoutSource === 'account') return;
+ let prefs = {};
+ try { prefs = JSON.parse(raw) || {}; } catch { return; }
+ const { data } = got;
+ const set = (id, fn) => { const e = (data.layout.parts || []).find((p) => p.id === id); if (e) fn(e); };
+ if (prefs.hand === 'left') {
+ const stick = (data.layout.parts || []).find((p) => p.id === 'stick');
+ const stop = (data.layout.parts || []).find((p) => p.id === 'stop');
+ if (stick && stop && stick.place && stop.place && stick.place.x != null && stop.place.x != null) {
+ const x = stick.place.x; stick.place.x = stop.place.x; stop.place.x = x;
+ }
+ }
+ if (prefs.size === 'l') {
+ const cols = data.catalog && data.catalog.grid ? data.catalog.grid.columns[data.device] : 4;
+ const stick = (data.layout.parts || []).find((p) => p.id === 'stick');
+ const stop = (data.layout.parts || []).find((p) => p.id === 'stop');
+ if (stick && stick.place && stick.place.x != null) {
+ stick.place.w = Math.min(3, cols); stick.place.h = 3;
+ stick.place.x = Math.max(0, Math.min(stick.place.x, cols - stick.place.w));
+ if (stop && stop.place && stick.place.x < stop.place.x + stop.place.w && stick.place.x + stick.place.w > stop.place.x
+ && stick.place.y < stop.place.y + stop.place.h && stick.place.y + stick.place.h > stop.place.y) {
+ stick.place.x = 0; stick.place.y = stop.place.y + stop.place.h;
+ }
+ }
+ }
+ if (prefs.readouts === 'off') for (const id of ['battery', 'latency', 'telemetry']) set(id, (p) => { p.visible = false; });
+ got.el.textContent = JSON.stringify(data);
+ // Put it on this page's already-rendered parts too, so the person sees their old arrangement at once.
+ const surfaceEl = document.querySelector('#panel .widgets[data-ov-surface]') || document.querySelector('#panel .bot-overlay[data-ov-surface]');
+ if (surfaceEl && data.surface === 'bot.controls') {
+ const cols = data.catalog && data.catalog.grid ? data.catalog.grid.columns[data.device] : 4;
+ const cell = data.catalog && data.catalog.grid ? data.catalog.grid.cell_px[data.device] : 64;
+ surfaceEl.setAttribute('data-ov-arranged', 'true');
+ surfaceEl.style.setProperty('--ov-cols', String(cols));
+ surfaceEl.style.setProperty('--ov-cell', `${cell}px`);
+ surfaceEl.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
+ surfaceEl.style.gridAutoRows = `minmax(${cell}px, auto)`;
+ }
+ for (const e of data.layout.parts || []) {
+ const el = document.querySelector(`[data-ov-part="${CSS.escape(e.id)}"]`);
+ if (!el) continue;
+ if (e.place && e.place.x != null) { el.style.gridColumn = `${e.place.x + 1} / span ${e.place.w}`; el.style.gridRow = `${e.place.y + 1} / span ${e.place.h}`; }
+ el.hidden = e.visible === false;
+ }
+ if (data.signedIn && data.save) {
+ fetch(data.save, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ surface: data.surface, device: data.device, catalog: data.catalog, layout: data.layout }) }).catch(() => {});
+ }
+ })();
+
+ (() => {
+ const btn = document.querySelector('[data-arrange]');
+ if (!btn) return;
+ let opening = false;
+ let saved = false;
+ const post = async (url, body) => {
+ const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ const out = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error((out.reasons && out.reasons[0]) || out.detail || 'That did not work.');
+ return out;
};
};
- apply();
- if (!btn || !menu) return;
- const close = () => { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); };
- btn.addEventListener('click', (e) => { e.stopPropagation(); const open = menu.hidden; menu.hidden = !open; btn.setAttribute('aria-expanded', String(open)); });
- document.addEventListener('click', (e) => { if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close(); });
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !menu.hidden) { close(); btn.focus(); } });
- $$('[data-pad-pref]').forEach((r) => r.addEventListener('change', () => {
- prefs[r.dataset.padPref] = r.value;
- try { localStorage.setItem(KEY, JSON.stringify(prefs)); } catch { /* */ }
- apply();
- }));
+ const launch = () => {
+ const got = arrangeData();
+ if (!got || !window.OVArrange) { opening = false; return; }
+ const { data } = got;
+ const surfaceEl = document.querySelector('#panel .widgets[data-ov-surface]') || document.querySelector('#panel .bot-overlay[data-ov-surface]') || document.querySelector('#panel .bot-compact[data-ov-surface]');
+ if (!surfaceEl) { opening = false; return; }
+ window.ovArrange = window.OVArrange.open({
+ surface: surfaceEl, catalog: data.catalog, layout: data.layout, device: data.device,
+ onSave: async (layout) => {
+ const out = await post(data.save, { surface: data.surface, device: data.device, catalog: data.catalog, layout });
+ data.layout = layout; got.el.textContent = JSON.stringify(data); saved = true;
+ return out;
+ },
+ onAsk: async (words, request) => post(data.suggest, { surface: data.surface, device: data.device, words, layout: request.layout, catalog: data.catalog }),
+ onShare: async (layout) => {
+ const out = await post(data.share, { surface: data.surface, device: data.device, catalog: data.catalog, layout });
+ data.layout = layout; got.el.textContent = JSON.stringify(data); saved = true;
+ return out;
+ },
+ onReset: async () => {
+ const out = await post(data.reset, { surface: data.surface, device: data.device });
+ data.layout = window.ovArrange.layout(); data.layoutSource = 'default'; got.el.textContent = JSON.stringify(data); saved = true;
+ return out;
+ },
+ onClose: () => { opening = false; if (saved) setTimeout(() => location.reload(), 60); },
+ confirm: () => true,
+ });
+ };
+ btn.addEventListener('click', () => {
+ if (opening) return;
+ opening = true;
+ if (window.OVArrange) return launch();
+ const src = btn.dataset.arrangeSrc;
+ if (!src) { opening = false; return; }
+ const sc = document.createElement('script');
+ sc.src = src; sc.async = true;
+ sc.onload = launch;
+ sc.onerror = () => { opening = false; };
+ document.head.appendChild(sc);
+ });
})();
})();
const stopRepeats = () => { [...repeats].forEach((s) => s()); };
const stopRepeats = () => { [...repeats].forEach((s) => s()); };
window.addEventListener('blur', stopRepeats);
window.addEventListener('blur', stopRepeats);
@@ -1109,7 +1199,7 @@
@@ -1109,7 +1199,7 @@
// The pad's arrangement is this person's own and works offline: it is never a control of the robot.
// The pad's arrangement is this person's own and works offline: it is never a control of the robot.
// A custom action in the card (LOOI's headlights) has its own kind: never disable it from the actuator's
// A custom action in the card (LOOI's headlights) has its own kind: never disable it from the actuator's
// state. The join below enables it only when the panel may send headlights.
// state. The join below enables it only when the panel may send headlights.
- for (const el of $$('button, input, select', section)) if (!el.closest('[data-pad-prefs-menu]') && !el.matches('[data-pad-prefs]') && !el.closest('[data-headlight]')) el.disabled = !ok;
+ for (const el of $$('button, input, select', section)) if (!el.closest('[data-headlight]')) el.disabled = !ok;
for (const el of $$('[data-joystick], [data-button-stick], [data-ptz-stick]', section)) { if (ok) el.removeAttribute('aria-disabled'); else el.setAttribute('aria-disabled', 'true'); }
for (const el of $$('[data-joystick], [data-button-stick], [data-ptz-stick]', section)) { if (ok) el.removeAttribute('aria-disabled'); else el.setAttribute('aria-disabled', 'true'); }
}
}
announceControls();
announceControls();
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+'use strict';
+
+/**
+ * The person's ui.layout@1 from OpenVibe.Network, for one surface and one device class.
+ *
+ * The panel reads the active layout server-side before it renders, so a signed-in person never sees the default
+ * flash and then their own arrangement. The browser's first-party session cookie is an OpenVibe.Network access
+ * token, so the read is the person's own `GET /api/v1/me/layouts/:surface?device=` (the panel is not given any
+ * extra account power). A Network that is slow or down leaves the default: the panel still opens and commands
+ * still reach the robot. Saving and asking OpenVibe go through Bot's own thin proxies (routes.js), which carry
+ * the same cookie, so neither ever reaches the page's script.
+ */
+const TIMEOUT_MS = 1200;
+const WRITE_TIMEOUT_MS = 15000;
+const SUGGEST_TIMEOUT_MS = 30000;
+
+function base(config) {
+ return String(config.network.url || '').replace(/\/+$/, '');
+}
+
+async function call(config, token, method, path, body, fetchImpl = globalThis.fetch, timeoutMs = TIMEOUT_MS) {
+ const res = await fetchImpl(`${base(config)}${path}`, {
+ method,
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ const out = await res.json().catch(() => null);
+ return { status: res.status, ok: res.ok, out };
+}
+
+/** The active layout, or null when the person has not made one (404) or Network cannot answer. */
+async function activeLayout({ config, token, surface, device, fetchImpl }) {
+ if (!token || !surface || !device) return null;
+ try {
+ const r = await call(config, token, 'GET', `/api/v1/me/layouts/${encodeURIComponent(surface)}?device=${encodeURIComponent(device)}`, null, fetchImpl);
+ if (r.status === 404) return null;
+ if (!r.ok) return null;
+ const layout = r.out && (r.out.layout || (Array.isArray(r.out.parts) ? r.out : null));
+ return layout && typeof layout === 'object' ? layout : null;
+ } catch { return null; }
+}
+
+/** PUT the active layout. Returns Network's answer ({ layout } or the layout) or throws with a plain reason. */
+async function saveLayout({ config, token, surface, device, layout, catalog, fetchImpl }) {
+ if (!catalog) throw new Error('the surface catalog is needed to save an arrangement');
+ const r = await call(config, token, 'PUT', `/api/v1/me/layouts/${encodeURIComponent(surface)}?device=${encodeURIComponent(device)}`, { catalog, layout }, fetchImpl, WRITE_TIMEOUT_MS);
+ if (!r.ok) throw new Error((r.out && (r.out.detail || r.out.error)) || `Network answered ${r.status}`);
+ return r.out;
+}
+
+/** POST the person's words; Network checks entitlement and forwards to OpenVibe.AI's ui.layout.suggest. */
+async function suggestLayout({ config, token, surface, device, words, layout, catalog, fetchImpl }) {
+ const body = { words, layout, catalog, device };
+ const r = await call(config, token, 'POST', `/api/v1/me/layouts/${encodeURIComponent(surface)}/suggest?device=${encodeURIComponent(device)}`, body, fetchImpl, SUGGEST_TIMEOUT_MS);
+ if (!r.ok) throw new Error((r.out && (r.out.detail || r.out.error)) || `Network answered ${r.status}`);
+ return r.out;
+}
+
+/** Save, then make a share link for that layout (Network's own public read route, made absolute). */
+async function shareLayout({ config, token, surface, device, layout, catalog, fetchImpl }) {
+ const saved = await saveLayout({ config, token, surface, device, layout, catalog, fetchImpl });
+ if (!saved || !saved.id) throw new Error('Network did not return the saved layout');
+ const r = await call(config, token, 'POST', `/api/v1/me/layouts/${encodeURIComponent(saved.id)}/share`, {}, fetchImpl, WRITE_TIMEOUT_MS);
+ if (!r.ok) throw new Error((r.out && (r.out.detail || r.out.error)) || `Network answered ${r.status}`);
+ const url = r.out && r.out.url;
+ if (url) { try { r.out.url = new URL(url, base(config) + '/').toString(); } catch { /* relative is still a link */ } }
+ return r.out;
+}
+
+/** Clear the active layout for this surface/device: Network keeps the saved row, the product draws its default. */
+async function resetLayout({ config, token, surface, device, fetchImpl }) {
+ const r = await call(config, token, 'POST', `/api/v1/me/layouts/${encodeURIComponent(surface)}/reset?device=${encodeURIComponent(device)}`, {}, fetchImpl, WRITE_TIMEOUT_MS);
+ if (r.status === 404) return { ok: true };
+ if (!r.ok) throw new Error((r.out && (r.out.detail || r.out.error)) || `Network answered ${r.status}`);
+ return { ok: true };
+}
+
+module.exports = { activeLayout, saveLayout, suggestLayout, shareLayout, resetLayout, TIMEOUT_MS };
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
'use strict';
'use strict';
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const sharedServe = require('openvibe-shared/serve');
/**
/**
* The signed-in pages, rendered on the server from data alone (pure functions, testable without a server).
* The signed-in pages, rendered on the server from data alone (pure functions, testable without a server).
@@ -136,8 +137,12 @@ const DIRECTION_BUTTONS = [
@@ -136,8 +137,12 @@ const DIRECTION_BUTTONS = [
{ dir: 'right', label: 'right', names: ['turn_right', 'right', 'rotate_right'] },
{ dir: 'right', label: 'right', names: ['turn_right', 'right', 'rotate_right'] },
];
];
-/** The drive pad: a touch joystick for two axes (pointer events, see panel.js), hold buttons for the rest. */
-function drivePad(axes, enabled) {
+/**
+ * The drive pad: a touch joystick for two axes (pointer events, see panel.js), hold buttons for the rest.
+ * Its stick, Stop and direction buttons are the panel's arrangeable parts (make your own controls, stage 2):
+ * the section around them flattens (`display: contents`) so each is a cell of the panel's grid.
+ */
+function drivePad(axes, enabled, attrs = {}, flatten = false) {
const x = STICK_X.find((a) => axes[a]);
const x = STICK_X.find((a) => axes[a]);
const y = STICK_Y.find((a) => axes[a]);
const y = STICK_Y.find((a) => axes[a]);
const stick = {};
const stick = {};
@@ -146,29 +151,13 @@ function drivePad(axes, enabled) {
@@ -146,29 +151,13 @@ function drivePad(axes, enabled) {
const rest = Object.fromEntries(Object.entries(axes).filter(([a]) => a !== x && a !== y));
const rest = Object.fromEntries(Object.entries(axes).filter(([a]) => a !== x && a !== y));
const label = [y && AXIS_NAME[y], x && AXIS_NAME[x]].filter(Boolean).join(' and ');
const label = [y && AXIS_NAME[y], x && AXIS_NAME[x]].filter(Boolean).join(' and ');
const joystick = x || y
const joystick = x || y
- ? `<div class="stick" data-joystick="${data(stick)}" role="img" aria-label="${esc(`Joystick: ${label}. Drag and hold to drive.`)}"${enabled ? '' : ' aria-disabled="true"'}>`
+ ? `<div class="stick" data-joystick="${data(stick)}" data-ov-part="stick" ${attrs.stick || ''} role="img" aria-label="${esc(`Joystick: ${label}. Drag and hold to drive.`)}"${enabled ? '' : ' aria-disabled="true"'}>`
+ '<span class="stick-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg>'
+ '<span class="stick-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg>'
+ `<span class="stick-knob" data-knob></span></div>`
+ `<span class="stick-knob" data-knob></span></div>`
: '';
: '';
- return `<div class="drive-pad" data-axes="${data(axes)}">${joystick}${Object.keys(rest).length ? `<div class="pad">${axisButtons(rest, enabled)}</div>` : ''}`
- + `<button type="button" class="stop" data-stop${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`
- + padPrefs();
-}
-
-/**
- * How this person likes the pad (controls v2, ask 368: "make it … per user"): the hand the Stop sits on, the pad's size
- * and the readouts shown or folded. public/panel.js keeps the choice in this browser and sets data-hand, data-pad and
- * data-readouts on #panel; nothing here changes what the robot does.
- */
-function padPrefs() {
- const group = (name, label, opts) => `<div class="pad-pref" role="radiogroup" aria-label="${esc(label)}"><span class="pad-pref-l">${esc(label)}</span><div class="pad-pref-opts">${opts.map(([v, t]) =>
- `<label><input type="radio" name="pad-${esc(name)}" value="${esc(v)}" data-pad-pref="${esc(name)}"><span>${esc(t)}</span></label>`).join('')}</div></div>`;
- return `<button type="button" class="pad-prefs-btn" data-pad-prefs aria-expanded="false" aria-controls="pad-prefs" title="Arrange the controls" aria-label="Arrange the controls">${icon('sliders')}</button>`
- + `<div class="pad-prefs" id="pad-prefs" data-pad-prefs-menu hidden>`
- + group('hand', 'Stop on the', [['right', 'Right'], ['left', 'Left']])
- + group('size', 'Pad', [['s', 'Small'], ['m', 'Medium'], ['l', 'Large']])
- + group('readouts', 'Readouts', [['on', 'Shown'], ['off', 'Folded']])
- + '</div>';
+ const pad = Object.keys(rest).length ? `<div class="pad" data-ov-part="pad" ${attrs.pad || ''}>${axisButtons(rest, enabled)}</div>` : '';
+ return `<div class="drive-pad" data-axes="${data(axes)}"${flatten ? ' data-ov-flatten="true"' : ''}>${joystick}${pad}`
+ + `<button type="button" class="stop" data-stop data-ov-part="stop" ${attrs.stop || ''}${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`;
}
}
/** An actuator's name as a label: backpack_lights → Backpack lights. */
/** An actuator's name as a label: backpack_lights → Backpack lights. */
@@ -267,7 +256,7 @@ function cameraTiles(w, profile, signaling_url = null, pointEnabled = false) {
@@ -267,7 +256,7 @@ function cameraTiles(w, profile, signaling_url = null, pointEnabled = false) {
}).join('');
}).join('');
}
}
-function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url = null } = {}) {
+function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url = null, part = null, partAttrs = {}, flatten = false } = {}) {
const commands = profile.commands || {};
const commands = profile.commands || {};
const kind = w.command ? w.command.kind : null;
const kind = w.command ? w.command.kind : null;
const enabled = !!kind && allowed_commands.includes(kind);
const enabled = !!kind && allowed_commands.includes(kind);
@@ -280,8 +269,8 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
@@ -280,8 +269,8 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
// drive has a rotation axis, else nothing, and then the legend does not offer them.
// drive has a rotation axis, else nothing, and then the legend does not offer them.
const headName = ((commands.actuator && commands.actuator.names) || {}).head;
const headName = ((commands.actuator && commands.actuator.names) || {}).head;
const qe = headName ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to tilt' : axes.rotation ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to turn' : '';
const qe = headName ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to tilt' : axes.rotation ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to turn' : '';
- inner = drivePad(axes, enabled)
- + `<p class="hint"><kbd>W A S D</kbd> or arrows to drive${qe} · <kbd>Space</kbd> to stop. `
+ inner = drivePad(axes, enabled, partAttrs, flatten)
+ + `<p class="hint" data-ov-decor><kbd>W A S D</kbd> or arrows to drive${qe} · <kbd>Space</kbd> to stop. `
+ `<span class="pad-hint" data-pad-hint>${icon('gamepad')} A gamepad works too: left stick drives, A sounds, B stops, Start is the e-stop.</span></p>`;
+ `<span class="pad-hint" data-pad-hint>${icon('gamepad')} A gamepad works too: left stick drives, A sounds, B stops, Start is the e-stop.</span></p>`;
break;
break;
}
}
@@ -360,7 +349,11 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
@@ -360,7 +349,11 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
inner = '';
inner = '';
}
}
const cmd = w.command ? ` data-command="${data(w.command)}"` : '';
const cmd = w.command ? ` data-command="${data(w.command)}"` : '';
- return `<section class="widget widget-${esc(w.type)}" data-widget="${esc(w.type)}"${cmd}><h2>${esc(label)}</h2>${inner}</section>`;
+ if (w.type === 'drive') {
+ return `<section class="widget widget-drive" data-widget="drive" data-ov-host${flatten ? ' data-ov-flatten="true"' : ''}${cmd}><h2 data-ov-decor>${esc(label)}</h2>${inner}</section>`;
+ }
+ const partAttr = part ? ` data-ov-part="${esc(part)}" ${partAttrs[part] || ''}` : '';
+ return `<section class="widget widget-${esc(w.type)}" data-widget="${esc(w.type)}"${cmd}${partAttr}><h2>${esc(label)}</h2>${inner}</section>`;
}
}
/** One owner switch: a checkbox posted as name=on, after a hidden name=off so an unchecked box still says off. */
/** One owner switch: a checkbox posted as name=on, after a hidden name=off so an unchecked box still says off. */
@@ -687,42 +680,47 @@ function overlaySlider(profile, allowed) {
@@ -687,42 +680,47 @@ function overlaySlider(profile, allowed) {
return null;
return null;
}
}
-const botPill = (robot) => `<span class="bot-pill bot-control" data-bot-pill data-online-pill data-state="connecting" data-bot-hit><span class="dot"></span><b class="bot-pill-name">${esc(robot.name)}</b><span class="bot-pill-state" data-bot-state>Connecting…</span></span>`;
+const botPill = (robot, attrs = '') => `<span class="bot-pill bot-control" data-bot-pill data-online-pill data-state="connecting" data-bot-hit data-ov-part="pill" ${attrs}><span class="dot"></span><b class="bot-pill-name">${esc(robot.name)}</b><span class="bot-pill-state" data-bot-state>Connecting…</span></span>`;
// A watcher (the anonymous embed on a stream) takes the controls with Drive, as on the full panel; the e-stop waits,
// A watcher (the anonymous embed on a stream) takes the controls with Drive, as on the full panel; the e-stop waits,
// hidden, for the join that makes them an operator (public/panel.js onFrame), and the line under the controls says
// hidden, for the join that makes them an operator (public/panel.js onFrame), and the line under the controls says
// what the page around answered (sign in there, or the owner decides who drives). Ask 353, 2026-11-11: on a phone over
// what the page around answered (sign in there, or the owner decides who drives). Ask 353, 2026-11-11: on a phone over
// the stream there was no way to start driving at all.
// the stream there was no way to start driving at all.
-const botEstop = (role, canDrive = false) => (ESTOP_ROLES.has(role)
- ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit aria-label="E-stop" title="E-stop">${icon('stop')}</button>`
- : canDrive ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit aria-label="E-stop" title="E-stop" hidden>${icon('stop')}</button>` : '');
-const botDrive = (canDrive) => (canDrive
- ? `<button type="button" class="bot-drive bot-control" data-drive data-drive-bar data-bot-hit title="Take the controls">${icon('gamepad')}<span>Drive</span></button>` : '');
+const botEstop = (role, canDrive = false, attrs = '') => (ESTOP_ROLES.has(role)
+ ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit data-ov-part="estop" ${attrs} aria-label="E-stop" title="E-stop">${icon('stop')}</button>`
+ : canDrive ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit data-ov-part="estop" data-ov-role-hidden="true" ${attrs} aria-label="E-stop" title="E-stop" hidden>${icon('stop')}</button>` : '');
+const botDrive = (canDrive, attrs = '') => (canDrive
+ ? `<button type="button" class="bot-drive bot-control" data-drive data-drive-bar data-bot-hit data-ov-part="drive" ${attrs} title="Take the controls">${icon('gamepad')}<span>Drive</span></button>` : '');
const botNotice = '<p class="bot-notice bot-control" data-notice data-bot-hit role="alert" hidden></p>';
const botNotice = '<p class="bot-notice bot-control" data-notice data-bot-hit role="alert" hidden></p>';
-const botMore = '<button type="button" class="bot-more bot-control" data-bot-more data-bot-hit aria-label="More controls" title="Open the full panel"><span aria-hidden="true">•••</span></button>';
+const botMore = (attrs = '') => `<button type="button" class="bot-more bot-control" data-bot-more data-bot-hit data-ov-part="more" ${attrs} aria-label="More controls" title="Open the full panel"><span aria-hidden="true">•••</span></button>`;
/** The overlay/compact page: a transparent surface with only the controls, no topbar, header or widgets. */
/** The overlay/compact page: a transparent surface with only the controls, no topbar, header or widgets. */
-function renderBotSurface({ robot, profile, role, allowed_commands = [], holdResendMs = 150, view = 'overlay', parentOrigins = [], signedIn = true }) {
+function renderBotSurface({ robot, profile, role, allowed_commands = [], holdResendMs = 150, view = 'overlay', parentOrigins = [], signedIn = true, arrange = null }) {
+ const surface = require('./surface');
+ const arrangeApply = !!(arrange && arrange.layoutSource === 'account');
+ const attrs = (id) => (arrange ? surface.attrsFor(arrange.catalog, arrange.layout, arrange.device, id, { apply: arrangeApply }) : '');
const stick = overlayStickMarkup(profile, allowed_commands);
const stick = overlayStickMarkup(profile, allowed_commands);
const slider = overlaySlider(profile, allowed_commands);
const slider = overlaySlider(profile, allowed_commands);
const canDrive = role === 'watcher' && !signedIn;
const canDrive = role === 'watcher' && !signedIn;
- const stop = botEstop(role, canDrive) + botDrive(canDrive);
+ const stop = botEstop(role, canDrive, attrs('estop')) + botDrive(canDrive, attrs('drive'));
const sliderLabel = slider ? esc(humanName(slider.name)) : '';
const sliderLabel = slider ? esc(humanName(slider.name)) : '';
const sliderMarkup = slider
const sliderMarkup = slider
- ? `<div class="bot-slider bot-control" data-bot-hit><section class="widget bot-widget" data-command="${data(slider.widget.command)}"><label class="control bot-range-label"><span class="sr-only">${sliderLabel}</span>${numberRange(slider.name, slider.actuator, slider.enabled, profile.id)}</label></section></div>`
+ ? `<div class="bot-slider bot-control" data-bot-hit data-ov-part="slider" ${attrs('slider')}><section class="widget bot-widget" data-command="${data(slider.widget.command)}"><label class="control bot-range-label"><span class="sr-only">${sliderLabel}</span>${numberRange(slider.name, slider.actuator, slider.enabled, profile.id)}</label></section></div>`
: '';
: '';
- const stickMarkup = stick ? `<div class="bot-stick bot-control" data-bot-hit>${stick}</div>` : '';
+ const stickMarkup = stick ? `<div class="bot-stick bot-control" data-bot-hit data-ov-part="stick" ${attrs('stick')}>${stick}</div>` : '';
+ const surfaceAttrs = arrange ? surface.overlaySurfaceAttrs(arrange.catalog, arrange.layout, arrange.device, arrangeApply && view !== 'compact') : '';
const body = view === 'compact'
const body = view === 'compact'
- ? `<div class="bot-compact" data-bot-compact${slider ? '' : ' data-no-slider'}>${botPill(robot)}${stickMarkup}${sliderMarkup}${stop}${botMore}${botNotice}</div>`
- : `<div class="bot-overlay" data-bot-overlay>${botPill(robot)}${stickMarkup}${sliderMarkup}${stop}${botMore}${botNotice}</div>`;
- return `<main id="panel" class="bot-surface" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}" data-bot-view="${esc(view)}"${parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>${body}</main>`;
+ ? `<div class="bot-compact" data-bot-compact${slider ? '' : ' data-no-slider'} ${surfaceAttrs}>${botPill(robot, attrs('pill'))}${stickMarkup}${sliderMarkup}${stop}${botMore(attrs('more'))}${botNotice}</div>`
+ : `<div class="bot-overlay" data-bot-overlay ${surfaceAttrs}>${botPill(robot, attrs('pill'))}${stickMarkup}${sliderMarkup}${stop}${botMore(attrs('more'))}${botNotice}</div>`;
+ const dataScript = arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringify({ catalog: arrange.catalog, layout: arrange.layout, device: arrange.device, surface: arrange.catalog.id, signedIn: arrange.signedIn, layoutSource: arrange.layoutSource || 'default', save: arrange.save || null, suggest: arrange.suggest || null, share: arrange.share || null, reset: arrange.reset || null, author: arrange.author || null }).replace(/</g, '\\u003c')}</script>` : '';
+ return `<main id="panel" class="bot-surface" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}" data-bot-view="${esc(view)}"${parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>${body}${dataScript}</main>`;
}
}
-function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', view = 'panel', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null, limitsInfo = null }) {
+function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', view = 'panel', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null, limitsInfo = null, arrange = null }) {
const embed = mode === 'embed';
const embed = mode === 'embed';
const botView = embed ? botViewOf(view) : 'panel';
const botView = embed ? botViewOf(view) : 'panel';
if (embed && botView !== 'panel') {
if (embed && botView !== 'panel') {
const [minWidth, minHeight] = BOT_VIEW_MIN[botView];
const [minWidth, minHeight] = BOT_VIEW_MIN[botView];
- return page(robot.name, renderBotSurface({ robot, profile, role, allowed_commands, holdResendMs, view: botView, parentOrigins, signedIn }), {
+ return page(robot.name, renderBotSurface({ robot, profile, role, allowed_commands, holdResendMs, view: botView, parentOrigins, signedIn, arrange }), {
scripts: panelScripts(profile, false, true),
scripts: panelScripts(profile, false, true),
bodyClass: `embed-page bot-mode-${botView}`,
bodyClass: `embed-page bot-mode-${botView}`,
htmlClass: `embed-page bot-mode-${botView}`,
htmlClass: `embed-page bot-mode-${botView}`,
@@ -733,13 +731,32 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
@@ -733,13 +731,32 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
const minSize = BOT_VIEW_MIN.panel;
const minSize = BOT_VIEW_MIN.panel;
const htmlAttrs = embed ? ` data-bot-view="panel" data-bot-min-width="${minSize[0]}" data-bot-min-height="${minSize[1]}"` : '';
const htmlAttrs = embed ? ` data-bot-view="panel" data-bot-min-width="${minSize[0]}" data-bot-min-height="${minSize[1]}"` : '';
const latched = !!(robot.estop && robot.estop.latched);
const latched = !!(robot.estop && robot.estop.latched);
- const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
- .sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i))
- .map(({ w }) => renderWidget(w, { profile, allowed_commands, signaling_url }));
+ const widgetOrder = (profile.widgets || []).map((w, i) => ({ w, i }))
+ .sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i));
+ // The arrangeable part ids each widget draws: the drive is split into its stick, Stop and direction buttons.
+ const byWidget = new Map();
+ for (const p of (arrange && arrange.catalog ? arrange.catalog.parts || [] : [])) {
+ const source = (arrange.parts || []).find((x) => x.id === p.id);
+ if (!source || source.widgetIndex == null) continue;
+ if (!byWidget.has(source.widgetIndex)) byWidget.set(source.widgetIndex, []);
+ byWidget.get(source.widgetIndex).push(p.id);
+ }
+ const widgetParts = (i) => byWidget.get(i) || [];
+ const arrangeApply = !!(arrange && arrange.layoutSource === 'account');
+ const attrsOf = (ids) => Object.fromEntries(ids.map((id) => [id, arrange ? require('./surface').attrsFor(arrange.catalog, arrange.layout, arrange.device, id, { apply: arrangeApply }) : '']));
+ const widgets = widgetOrder.map(({ w, i }) => renderWidget(w, { profile, allowed_commands, signaling_url, part: widgetParts(i)[0] || null, partAttrs: attrsOf(widgetParts(i)), flatten: arrangeApply }));
+ // A robot with no drive widget still gets its Stop: a required part of bot.controls, drawn on its own.
+ const drawn = new Set([...byWidget.values()].flat());
+ if (arrange && !drawn.has('stop') && require('./surface').partOf(arrange.catalog, 'stop')) {
+ widgets.push(`<section class="widget widget-stop" data-widget="stop" data-ov-part="stop" ${attrsOf(['stop']).stop || ''}>`
+ + `<h2>Stop</h2><button type="button" class="stop" data-stop>${icon('square')}<span>Stop</span></button></section>`);
+ }
// A framed watcher may take the controls with Drive; its e-stop waits, hidden, for an owner's or operator's join.
// A framed watcher may take the controls with Drive; its e-stop waits, hidden, for an owner's or operator's join.
const estop = ESTOP_ROLES.has(role) ? `<button type="button" class="estop" data-estop>${icon('stop')}<span>E-stop</span></button>`
const estop = ESTOP_ROLES.has(role) ? `<button type="button" class="estop" data-estop>${icon('stop')}<span>E-stop</span></button>`
: (embed && role === 'watcher' ? `<button type="button" class="estop" data-estop hidden>${icon('stop')}<span>E-stop</span></button>` : '');
: (embed && role === 'watcher' ? `<button type="button" class="estop" data-estop hidden>${icon('stop')}<span>E-stop</span></button>` : '');
const clear = role === 'owner' ? `<button type="button" class="estop-clear" data-estop-clear${latched ? '' : ' hidden'}>Clear e-stop</button>` : '';
const clear = role === 'owner' ? `<button type="button" class="estop-clear" data-estop-clear${latched ? '' : ' hidden'}>Clear e-stop</button>` : '';
+ const arrangeButton = arrange && arrange.src
+ ? `<button type="button" class="arrange-btn" data-arrange data-arrange-src="${esc(arrange.src || '')}" title="Arrange the controls">${icon('sliders')}<span>Arrange</span></button>` : '';
const body = `<header class="estop-banner" data-estop-banner data-latched="${latched}">
const body = `<header class="estop-banner" data-estop-banner data-latched="${latched}">
${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div>
<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div>
@@ -749,15 +766,16 @@ ${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') :
@@ -749,15 +766,16 @@ ${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') :
${kit.pageHead({ title: `${robot.name}`, icon: 'gear', back: embed ? null : { href: '/', label: 'Home' } })}
${kit.pageHead({ title: `${robot.name}`, icon: 'gear', back: embed ? null : { href: '/', label: 'Home' } })}
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
${turnStrip(robot, role)}
${turnStrip(robot, role)}
+${arrangeButton}
${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in"><button type="button" class="button primary" data-drive data-drive-bar>${icon('gamepad')}<span>Drive</span></button> <a class="button embed-open" href="/panel/${esc(robot.id)}" target="_blank" rel="noopener" title="Open on openvibe.bot">${icon('external')}<span>Open on openvibe.bot</span></a></p>` : ''}
${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in"><button type="button" class="button primary" data-drive data-drive-bar>${icon('gamepad')}<span>Drive</span></button> <a class="button embed-open" href="/panel/${esc(robot.id)}" target="_blank" rel="noopener" title="Open on openvibe.bot">${icon('external')}<span>Open on openvibe.bot</span></a></p>` : ''}
</div>
</div>
${tryIt ? tryIntro() : ''}
${tryIt ? tryIntro() : ''}
<p class="notice" data-device-fault role="status" hidden></p>
<p class="notice" data-device-fault role="status" hidden></p>
<p class="notice" data-notice role="alert" hidden></p>
<p class="notice" data-notice role="alert" hidden></p>
-<div class="widgets">
+<div class="widgets"${arrange ? require('./surface').surfaceAttrs(arrange.catalog, arrange.layout, arrange.device, arrangeApply) : ''}>
${widgets.join('\n')}
${widgets.join('\n')}
</div>
</div>
-${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${commissionForm(robot, profile)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
+${arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringify({ catalog: arrange.catalog, layout: arrange.layout, device: arrange.device, surface: arrange.catalog.id, signedIn: arrange.signedIn, layoutSource: arrange.layoutSource || 'default', save: arrange.save || null, suggest: arrange.suggest || null, share: arrange.share || null, reset: arrange.reset || null, author: arrange.author || null }).replace(/</g, '\\u003c')}</script>` : ''}${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${commissionForm(robot, profile)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
</main>`;
</main>`;
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
scripts: panelScripts(profile, tryIt, embed), bodyClass: embed ? 'embed-page' : 'panel-page',
scripts: panelScripts(profile, tryIt, embed), bodyClass: embed ? 'embed-page' : 'panel-page',
@@ -1122,4 +1140,4 @@ function renderFirstRunPage({ rows = [] }) {
@@ -1122,4 +1140,4 @@ function renderFirstRunPage({ rows = [] }) {
return page('First run', body, { description: 'The guided first run of OpenVibe Link, measured step by step.', canonical: '/admin/first-run' });
return page('First run', body, { description: 'The guided first run of OpenVibe Link, measured step by step.', canonical: '/admin/first-run' });
}
}
-module.exports = { page, renderPanel, renderPhonePage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderPhoneLinkPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };
+module.exports = { page, renderPanel, renderPhonePage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderPhoneLinkPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, overlayStickMarkup, overlaySlider, esc };
@@ -48,7 +48,11 @@ function linkApkUrl() {
@@ -48,7 +48,11 @@ function linkApkUrl() {
const v = String(process.env.LINK_ANDROID_APK_URL || '').trim();
const v = String(process.env.LINK_ANDROID_APK_URL || '').trim();
return /^https:\/\/(github\.com\/OpenVibers\/OpenVibe\.Link\/releases\/|openvibe\.link\/)[^\s"'<>]+$/.test(v) ? v : null;
return /^https:\/\/(github\.com\/OpenVibers\/OpenVibe\.Link\/releases\/|openvibe\.link\/)[^\s"'<>]+$/.test(v) ? v : null;
}
}
-const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderPhoneLinkPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
+const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderPhoneLinkPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, overlayStickMarkup, overlaySlider } = require('./render');
+const sharedServe = require('openvibe-shared/serve');
+const layoutStore = require('./layouts');
+const surface = require('./surface');
+const uiLayout = require('openvibe-sdk/ui-layout');
const { createMachineScreen } = require('../machine-screen');
const { createMachineScreen } = require('../machine-screen');
const { createScreenShares } = require('../screen-shares');
const { createScreenShares } = require('../screen-shares');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
@@ -688,7 +692,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -688,7 +692,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
} catch (e) {
} catch (e) {
if (e.status !== 422) throw e;
if (e.status !== 422) throw e;
res.status(422);
res.status(422);
- return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { routineError: e.detail || e.message, values: { name: b.name, steps: b.steps, trigger: kind, every: b.every, at: b.at, days: many(b.days) } } });
+ return sendPanel(req, res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { routineError: e.detail || e.message, values: { name: b.name, steps: b.steps, trigger: kind, every: b.every, at: b.at, days: many(b.days) } } });
}
}
return res.redirect(303, `/panel/${robot.id}#routines`);
return res.redirect(303, `/panel/${robot.id}#routines`);
}));
}));
@@ -710,11 +714,11 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -710,11 +714,11 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
try { made = await webhooks.create(robot, me, { url: req.body.url, events: many(req.body.events) }); } catch (e) {
try { made = await webhooks.create(robot, me, { url: req.body.url, events: many(req.body.events) }); } catch (e) {
if (![409, 422].includes(e.status)) throw e;
if (![409, 422].includes(e.status)) throw e;
res.status(e.status);
res.status(e.status);
- return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { webhookError: e.detail || e.message } });
+ return sendPanel(req, res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { webhookError: e.detail || e.message } });
}
}
// The secret is shown on this page alone (never in a URL), so the answer is the panel itself.
// The secret is shown on this page alone (never in a URL), so the answer is the panel itself.
res.status(201);
res.status(201);
- return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { webhookSecret: made.secret } });
+ return sendPanel(req, res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { webhookSecret: made.secret } });
}));
}));
r.post('/robots/:id/webhooks/:hid/remove', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
r.post('/robots/:id/webhooks/:hid/remove', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const { robot } = await ownRobot(req);
const { robot } = await ownRobot(req);
@@ -911,7 +915,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -911,7 +915,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const role = await domain.members.roleOf(robot.id, me.subject);
const role = await domain.members.roleOf(robot.id, me.subject);
return role || (robot.access_policy === 'queue' ? 'queue' : null);
return role || (robot.access_policy === 'queue' ? 'queue' : null);
}
}
- async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true, profileForm = {}, view = 'panel') {
+ async function sendPanel(req, res, robot, profile, role, mode = 'page', signedIn = true, profileForm = {}, view = 'panel') {
await attachInProcess(robot, profile);
await attachInProcess(robot, profile);
const effective = domain.control.effectiveLimits(robot, profile);
const effective = domain.control.effectiveLimits(robot, profile);
const { maxCommandMs } = effective;
const { maxCommandMs } = effective;
@@ -943,6 +947,54 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -943,6 +947,54 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
automation = { routines: await routines.list(robot.id), hooks, events: webhooks.EVENTS, describe: (t) => routines.describeTrigger(typeof t === 'string' ? JSON.parse(t) : t), ...(profileForm.automation || {}) };
automation = { routines: await routines.list(robot.id), hooks, events: webhooks.EVENTS, describe: (t) => routines.describeTrigger(typeof t === 'string' ? JSON.parse(t) : t), ...(profileForm.automation || {}) };
}
}
const liveOrigin = (config.embed && config.embed.origins && config.embed.origins[0]) || null;
const liveOrigin = (config.embed && config.embed.origins && config.embed.origins[0]) || null;
+ // The person's arrangement for this surface and device class, read before the page is drawn (never a
+ // flash of the default for a signed-in person). A Network that does not answer leaves the default.
+ const device = surface.deviceClass(req);
+ const token = req.cookies && req.cookies.ov_token;
+ // A layout made on another robot may name parts this one does not have: keep the parts this catalog knows,
+ // add the defaults of any it is missing, and use it only when the SDK accepts the result. Otherwise the
+ // panel draws its default (a stored layout is never a broken page).
+ const usable = (catalog, storedRaw, defaults) => {
+ if (!storedRaw || !Array.isArray(storedRaw.parts)) return null;
+ const known = new Set((catalog.parts || []).map((p) => p.id));
+ const parts = storedRaw.parts.filter((p) => p && known.has(p.id));
+ for (const d of defaults.parts || []) if (!parts.some((p) => p.id === d.id)) parts.push(d);
+ const candidate = { ...storedRaw, parts };
+ return uiLayout.validate(catalog, candidate).ok ? candidate : null;
+ };
+ const overlayView = mode === 'embed' && view !== 'panel';
+ let arrange;
+ if (overlayView) {
+ const slider = overlaySlider(profile, role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile));
+ const canDrive = role === 'watcher' && !signedIn;
+ const catalog = surface.overlayCatalog(profile, {
+ slider: slider ? { name: slider.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase()) } : null,
+ hasStick: !!overlayStickMarkup(profile, role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile)),
+ canDrive,
+ });
+ const storedRaw = signedIn && token ? await layoutStore.activeLayout({ config, token, surface: catalog.id, device }) : null;
+ const fallbackLayout = surface.overlayDefaultLayout(catalog, device);
+ const stored = usable(catalog, storedRaw, fallbackLayout);
+ arrange = {
+ catalog, layout: stored || fallbackLayout, device,
+ signedIn: !!signedIn, layoutSource: stored ? 'account' : 'default',
+ save: `/panel/${robot.id}/arrange`, suggest: `/panel/${robot.id}/arrange/suggest`, share: `/panel/${robot.id}/arrange/share`, reset: `/panel/${robot.id}/arrange/reset`,
+ author: req.viewer ? { name: req.viewer.username || req.viewer.name || 'Me' } : null,
+ };
+ } else {
+ const catalog = surface.controlsCatalog(profile);
+ const storedRaw = signedIn && token ? await layoutStore.activeLayout({ config, token, surface: catalog.id, device }) : null;
+ const fallbackLayout = surface.defaultLayout(catalog, device);
+ const stored = usable(catalog, storedRaw, fallbackLayout);
+ let src = null;
+ try { src = sharedServe.url('ov-arrange.js'); } catch { /* an older openvibe-shared pin: no editor */ }
+ arrange = {
+ catalog, parts: surface.partsOf(profile), layout: stored || fallbackLayout, device,
+ signedIn: !!signedIn, layoutSource: stored ? 'account' : 'default', src,
+ save: `/panel/${robot.id}/arrange`, suggest: `/panel/${robot.id}/arrange/suggest`, share: `/panel/${robot.id}/arrange/share`, reset: `/panel/${robot.id}/arrange/reset`,
+ author: req.viewer ? { name: req.viewer.username || req.viewer.name || 'Me' } : null,
+ };
+ }
res.type('html').send(renderPanel({
res.type('html').send(renderPanel({
robot: presentForPage(robot), profile, role, mode, view, signedIn, streaming, people, signaling_url,
robot: presentForPage(robot), profile, role, mode, view, signedIn, streaming, people, signaling_url,
profileForm, automation, limitsInfo,
profileForm, automation, limitsInfo,
@@ -955,6 +1007,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -955,6 +1007,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
// sixth of it (50 ms at the least). Half left about 90 ms of slack on LOOI, and ordinary Wi-Fi jitter expired
// sixth of it (50 ms at the least). Half left about 90 ms of slack on LOOI, and ordinary Wi-Fi jitter expired
// commands mid-hold (Maticus, 2026-11-11: four in 9 minutes, gaps of 171–257 ms).
// commands mid-hold (Maticus, 2026-11-11: four in 9 minutes, gaps of 171–257 ms).
holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 6))),
holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 6))),
+ arrange,
}));
}));
}
}
@@ -988,7 +1041,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -988,7 +1041,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
if (action !== 'save') {
if (action !== 'save') {
const local = await domain.robots.localProfile(robot.id);
const local = await domain.robots.localProfile(robot.id);
state.catalogueId = local ? local.source_profile_id : robot.profile_id;
state.catalogueId = local ? local.source_profile_id : robot.profile_id;
- return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, state);
+ return sendPanel(req, res, robot, await profileOf(robot), 'owner', 'page', true, state);
}
}
try {
try {
const buttons = {};
const buttons = {};
@@ -1009,7 +1062,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -1009,7 +1062,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const local = await domain.robots.localProfile(robot.id);
const local = await domain.robots.localProfile(robot.id);
state.catalogueId = local ? local.source_profile_id : robot.profile_id;
state.catalogueId = local ? local.source_profile_id : robot.profile_id;
state.error = e.detail || e.message;
state.error = e.detail || e.message;
- return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, state);
+ return sendPanel(req, res, robot, await profileOf(robot), 'owner', 'page', true, state);
}
}
return res.redirect(303, `/panel/${robot.id}`);
return res.redirect(303, `/panel/${robot.id}`);
}));
}));
@@ -1040,7 +1093,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -1040,7 +1093,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const profile = await profileOf(robot);
const profile = await profileOf(robot);
if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');
if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');
const local = role === 'owner' ? await domain.robots.localProfile(robot.id) : null;
const local = role === 'owner' ? await domain.robots.localProfile(robot.id) : null;
- await sendPanel(res, robot, profile, role, 'page', true, { catalogueId: local ? local.source_profile_id : robot.profile_id });
+ await sendPanel(req, res, robot, profile, role, 'page', true, { catalogueId: local ? local.source_profile_id : robot.profile_id });
}));
}));
// The embed's Drive (server/control-tickets.js): the popup page, first-party, signs in if it must and then mints a
// The embed's Drive (server/control-tickets.js): the popup page, first-party, signs in if it must and then mints a
@@ -1086,9 +1139,76 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -1086,9 +1139,76 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
// picture, or one row for a narrow card); panel is the full embed and the default. Anything else is panel.
// picture, or one row for a narrow card); panel is the full embed and the default. Anything else is panel.
const asked = String(req.query.mode || '').toLowerCase();
const asked = String(req.query.mode || '').toLowerCase();
const view = ['overlay', 'compact'].includes(asked) ? asked : 'panel';
const view = ['overlay', 'compact'].includes(asked) ? asked : 'panel';
- return sendPanel(res, robot, profile, role, 'embed', !!req.viewer, {}, view);
+ return sendPanel(req, res, robot, profile, role, 'embed', !!req.viewer, {}, view);
}));
}));
+ // ── The Arrange editor's three calls (make your own controls, stage 2) ──────────────────────────────
+ // The page never holds a Network token of its own: these carry the first-party session cookie to Network's
+ // person routes. A layout only arranges; the robot gate and limits are untouched.
+ const arrangeBody = express.json({ limit: '128kb' });
+ const arrangeSurface = (v) => (v === 'bot.controls' || v === 'bot.overlay' ? v : null);
+ const arrangeDevice = (v) => (['phone', 'tablet', 'computer'].includes(v) ? v : null);
+ const arrangeToken = (req) => req.cookies && req.cookies.ov_token;
+ const arrangeError = (res, e) => res.status(503).json({ ok: false, reasons: [String((e && (e.detail || e.message)) || 'Network is not reachable right now.')] });
+ r.post('/panel/:id/arrange', arrangeBody, async (req, res) => {
+ res.setHeader('Cache-Control', 'no-store');
+ try {
+ if (!sameOrigin(req)) return res.status(403).json({ ok: false, reasons: ['Cross-site saves are refused.'] });
+ requireUser(req);
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) return res.status(404).json({ ok: false, reasons: ['No such robot.'] });
+ const surfaceId = arrangeSurface(req.body && req.body.surface);
+ const device = arrangeDevice(req.body && req.body.device);
+ if (!surfaceId || !device || !req.body || !req.body.layout || !req.body.catalog) return res.status(422).json({ ok: false, reasons: ['Missing surface, device, catalog or layout.'] });
+ await layoutStore.saveLayout({ config, token: arrangeToken(req), surface: surfaceId, device, layout: req.body.layout, catalog: req.body.catalog });
+ res.json({ ok: true });
+ } catch (e) { arrangeError(res, e); }
+ });
+ r.post('/panel/:id/arrange/suggest', arrangeBody, async (req, res) => {
+ res.setHeader('Cache-Control', 'no-store');
+ try {
+ if (!sameOrigin(req)) return res.status(403).json({ ok: false, reasons: ['Cross-site asks are refused.'] });
+ requireUser(req);
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) return res.status(404).json({ ok: false, reasons: ['No such robot.'] });
+ const surfaceId = arrangeSurface(req.body && req.body.surface);
+ const device = arrangeDevice(req.body && req.body.device);
+ const words = String((req.body && req.body.words) || '').trim();
+ if (!surfaceId || !device || !words) return res.status(422).json({ ok: false, reasons: ['Say how you want it first.'] });
+ const out = await layoutStore.suggestLayout({ config, token: arrangeToken(req), surface: surfaceId, device, words, layout: req.body.layout || null, catalog: req.body.catalog || null });
+ res.json(out);
+ } catch (e) { arrangeError(res, e); }
+ });
+ r.post('/panel/:id/arrange/reset', arrangeBody, async (req, res) => {
+ res.setHeader('Cache-Control', 'no-store');
+ try {
+ if (!sameOrigin(req)) return res.status(403).json({ ok: false, reasons: ['Cross-site resets are refused.'] });
+ requireUser(req);
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) return res.status(404).json({ ok: false, reasons: ['No such robot.'] });
+ const surfaceId = arrangeSurface(req.body && req.body.surface);
+ const device = arrangeDevice(req.body && req.body.device);
+ if (!surfaceId || !device) return res.status(422).json({ ok: false, reasons: ['Missing surface or device.'] });
+ res.json(await layoutStore.resetLayout({ config, token: arrangeToken(req), surface: surfaceId, device }));
+ } catch (e) { arrangeError(res, e); }
+ });
+ r.post('/panel/:id/arrange/share', arrangeBody, async (req, res) => {
+ res.setHeader('Cache-Control', 'no-store');
+ try {
+ if (!sameOrigin(req)) return res.status(403).json({ ok: false, reasons: ['Cross-site shares are refused.'] });
+ requireUser(req);
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) return res.status(404).json({ ok: false, reasons: ['No such robot.'] });
+ const surfaceId = arrangeSurface(req.body && req.body.surface);
+ const device = arrangeDevice(req.body && req.body.device);
+ if (!surfaceId || !device || !req.body || !req.body.layout || !req.body.catalog) return res.status(422).json({ ok: false, reasons: ['Missing surface, device, catalog or layout.'] });
+ const out = await layoutStore.shareLayout({ config, token: arrangeToken(req), surface: surfaceId, device, layout: req.body.layout, catalog: req.body.catalog });
+ const url = out && (out.url || out.share_url || out.link);
+ if (!url) return res.status(503).json({ ok: false, reasons: ['Network did not make a link.'] });
+ res.json({ url });
+ } catch (e) { arrangeError(res, e); }
+ });
+
return r;
return r;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,299 @@
@@ -0,0 +1,299 @@
+'use strict';
+
+/**
+ * The panel's arrangeable surfaces (make your own controls, stage 2): the ui.surface@1 catalogs and the default
+ * ui.layout@1 for the full panel (bot.controls) and the controls over a stream (bot.overlay), and the attributes
+ * that put a person's layout on the parts the pages render.
+ *
+ * A catalog is built from the robot's profile: the parts a layout may place are exactly the controls this robot
+ * has (the drive stick, Stop, a head slider, battery, latency, the camera, the buttons, ...). Stop and the
+ * overlay's e-stop are required and carry their own minimum pixel size. The default layout keeps the panel's
+ * familiar order: the camera, then the stick and Stop side by side, then the rest.
+ *
+ * A layout only arranges: every command still goes through the panel's one gate and the robot's limits.
+ */
+const THEME_TOKENS = ['surface', 'raised', 'bg', 'line', 'text', 'muted', 'accent', 'danger', 'ok', 'warn'];
+const DEVICES = ['phone', 'tablet', 'computer'];
+const GRID = { columns: { phone: 4, tablet: 8, computer: 12 }, cell_px: { phone: 72, tablet: 64, computer: 56 } };
+const PRESSED = 44;
+
+/** phone, tablet or computer from the request (a header first, then the browser's own user agent). */
+function deviceClass(req) {
+ const header = (req && req.headers && (req.headers['sec-ch-ua-mobile'] || '')) || '';
+ const ua = String((req && req.headers && req.headers['user-agent']) || '');
+ if (/iPad|Tablet|PlayBook|Silk|Android(?!.*Mobile)/i.test(ua)) return 'tablet';
+ if (header === '?1' || /Mobi|iPhone|iPod|Android|Windows Phone|Mobile/i.test(ua)) return 'phone';
+ return 'computer';
+}
+
+const STICK_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'w', 'a', 's', 'd'];
+const STICK_X = ['steer', 'x', 'rotation'];
+const STICK_Y = ['throttle', 'y'];
+
+function basePart(id, name, kind, extra = {}) {
+ return {
+ id, name, kind,
+ required: false,
+ pressed: kind === 'button' || kind === 'stick' || kind === 'slider',
+ min: { w: 1, h: 1 },
+ styles: { tints: ['surface', 'raised'], shapes: ['round', 'pill', 'square'], opacity: { min: 0.4, max: 1 }, label: true },
+ bindings: { keys: [], gamepad: [] },
+ ...extra,
+ };
+}
+
+/** The parts one widget contributes: the drive is split into its stick, Stop and direction buttons. */
+function driveParts(profile, widget) {
+ const kind = widget && widget.command ? widget.command.kind : 'drive';
+ const command = (profile.commands || {})[kind];
+ const axes = (command && command.axes) || {};
+ const x = STICK_X.find((a) => axes[a]);
+ const y = STICK_Y.find((a) => axes[a]);
+ const rest = Object.keys(axes).filter((a) => a !== x && a !== y);
+ const parts = [];
+ if (x || y) parts.push([basePart('stick', 'Drive stick', 'stick', {
+ pressed: true, min: { w: 2, h: 2 }, never_covers: ['stop'],
+ styles: { tints: ['surface', 'accent'], shapes: ['round', 'square'], opacity: { min: 0.4, max: 1 }, label: false },
+ bindings: { keys: STICK_KEYS, gamepad: ['left-stick', 'dpad'] },
+ }), { widgetIndex: null }]);
+ parts.push([basePart('stop', 'Stop', 'button', {
+ required: true, pressed: true, min: { w: 2, h: 2, px: 68 },
+ styles: { tints: ['danger', 'surface'], shapes: ['round', 'pill'], opacity: { min: 0.7, max: 1 }, label: true },
+ bindings: { keys: ['Space'], gamepad: ['south'] },
+ }), { widgetIndex: null }]);
+ if (rest.length) parts.push([basePart('pad', 'Direction buttons', 'button', {
+ pressed: true, min: { w: 2, h: 2 }, never_covers: ['stop'],
+ styles: { tints: ['surface'], shapes: ['round', 'square'], opacity: { min: 0.4, max: 1 }, label: true },
+ bindings: { keys: [], gamepad: [] },
+ }), { widgetIndex: null }]);
+ return parts;
+}
+
+const ACTUATOR_WIDGETS = { 'pan-tilt': 'pan_tilt', servo: 'servo', head: 'head', lift: 'lift', lights: 'lights', horn: 'horn' };
+const SIMPLE_WIDGETS = { battery: 'readout', latency: 'readout', telemetry: 'readout', camera: 'video', video_click: 'video', speaker: 'list', display: 'list', buttons: 'button', ptz: 'stick' };
+
+/** Every part this profile's full panel offers, in the order the page draws its widgets. */
+function partsOf(profile) {
+ const widgets = (profile && profile.widgets) || [];
+ const hasCamera = widgets.some((w) => w.type === 'camera');
+ const out = [];
+ const seen = new Set();
+ const add = (part, meta = {}) => {
+ let id = part.id;
+ for (let n = 2; seen.has(id); n++) id = `${part.id}.${n}`;
+ seen.add(id);
+ out.push({ ...part, id, widgetIndex: meta.widgetIndex == null ? null : meta.widgetIndex, widgetType: meta.widgetType || null });
+ };
+ let drive = false;
+ widgets.forEach((w, i) => {
+ if (w.type === 'drive') {
+ for (const [part, meta] of driveParts(profile, w)) add(part, { ...meta, widgetIndex: i });
+ drive = true;
+ return;
+ }
+ if (w.type === 'ptz') {
+ add(basePart('ptz', w.label || 'Camera stick', 'stick', {
+ pressed: true, min: { w: 2, h: 2 }, never_covers: ['stop'],
+ styles: { tints: ['surface', 'accent'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: true },
+ bindings: { keys: [], gamepad: [] },
+ }), { widgetIndex: i, widgetType: w.type });
+ return;
+ }
+ if (ACTUATOR_WIDGETS[w.type]) {
+ const kind = w.type === 'lights' || w.type === 'horn' ? 'button' : 'slider';
+ add(basePart(ACTUATOR_WIDGETS[w.type], w.label || (w.type === 'pan-tilt' ? 'Pan and tilt' : w.type), kind, {
+ min: { w: 2, h: kind === 'slider' ? 1 : 2 }, never_covers: ['stop'],
+ styles: { tints: ['accent', 'surface'], shapes: ['pill', 'square', 'round'], opacity: { min: 0.4, max: 1 }, label: true },
+ bindings: { keys: w.type === 'head' ? ['q', 'e'] : [], gamepad: [] },
+ }), { widgetIndex: i, widgetType: w.type });
+ return;
+ }
+ if (SIMPLE_WIDGETS[w.type]) {
+ if (w.type === 'video_click' && hasCamera) return;
+ const kind = SIMPLE_WIDGETS[w.type];
+ add(basePart(w.type === 'video_click' ? 'camera' : w.type, w.label || (w.type === 'battery' ? 'Battery' : w.type === 'latency' ? 'Latency' : w.type === 'camera' || w.type === 'video_click' ? 'Camera' : w.type), kind, {
+ pressed: kind === 'button' || kind === 'stick',
+ min: w.type === 'latency' ? { w: 2, h: 1 } : w.type === 'camera' || w.type === 'video_click' ? { w: 2, h: 2 } : w.type === 'buttons' ? { w: 2, h: 2 } : { w: 1, h: 1 },
+ never_covers: kind === 'button' || kind === 'stick' ? ['stop'] : [],
+ styles: { tints: kind === 'readout' ? ['text', 'surface'] : ['surface', 'accent'], shapes: ['round', 'pill', 'square'], opacity: { min: 0.4, max: 1 }, label: true },
+ bindings: { keys: [], gamepad: [] },
+ }), { widgetIndex: i, widgetType: w.type });
+ }
+ });
+ if (!drive) {
+ // Every robot with a panel can be stopped, even one made only of a pan-tilt camera or buttons.
+ if (!seen.has('stop')) add(basePart('stop', 'Stop', 'button', {
+ required: true, pressed: true, min: { w: 2, h: 2, px: 68 },
+ styles: { tints: ['danger', 'surface'], shapes: ['round', 'pill'], opacity: { min: 0.7, max: 1 }, label: true },
+ bindings: { keys: ['Space'], gamepad: ['south'] },
+ }));
+ }
+ return out;
+}
+
+/** The full panel's catalog: bot.controls. */
+function controlsCatalog(profile) {
+ return {
+ id: 'bot.controls', version: 1, name: 'Robot controls',
+ description: 'The full OpenVibe.Bot panel: the drive stick, Stop, the head or camera slider, the buttons, the camera and the readouts.',
+ devices: DEVICES, grid: GRID, theme_tokens: THEME_TOKENS,
+ parts: partsOf(profile).map((p) => {
+ const { widgetIndex, widgetType, ...part } = p; // eslint-disable-line no-unused-vars
+ return part;
+ }),
+ };
+}
+
+/** The overlay's catalog: the stick, the slider, the e-stop, more and the pill (plus Drive for a watcher). */
+function overlayCatalog(profile, { slider = null, hasStick = false, canDrive = false } = {}) {
+ const parts = [
+ basePart('pill', 'The robot', 'readout', { pressed: false, min: { w: 1, h: 1 }, styles: { tints: ['surface'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true }, bindings: { keys: [], gamepad: [] } }),
+ basePart('more', 'More', 'button', { pressed: true, min: { w: 1, h: 1 }, styles: { tints: ['surface'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false }, bindings: { keys: [], gamepad: [] } }),
+ basePart('estop', 'E-stop', 'button', {
+ required: true, pressed: true, min: { w: 2, h: 2, px: 56 }, never_covers: ['more', 'pill'],
+ styles: { tints: ['danger'], shapes: ['round'], opacity: { min: 0.7, max: 1 }, label: true },
+ bindings: { keys: ['Space'], gamepad: ['start'] },
+ }),
+ ];
+ if (hasStick) parts.push(basePart('stick', 'Stick', 'stick', {
+ pressed: true, min: { w: 2, h: 2 }, never_covers: ['estop', 'pill'],
+ styles: { tints: ['surface', 'accent'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false },
+ bindings: { keys: STICK_KEYS, gamepad: ['left-stick', 'dpad'] },
+ }));
+ if (slider) parts.push(basePart('slider', slider.name || 'Slider', 'slider', {
+ pressed: true, min: { w: 2, h: 1 }, never_covers: ['estop'],
+ styles: { tints: ['surface', 'accent'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true },
+ bindings: { keys: [], gamepad: [] },
+ }));
+ if (canDrive) parts.push(basePart('drive', 'Drive', 'button', { pressed: true, min: { w: 2, h: 1 }, styles: { tints: ['accent'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true }, bindings: { keys: [], gamepad: [] } }));
+ return {
+ id: 'bot.overlay', version: 1, name: 'Robot over a stream',
+ description: 'The controls over a robot’s stream: the stick, the slider, the e-stop, more and the robot’s pill.',
+ devices: DEVICES, grid: GRID, theme_tokens: THEME_TOKENS, parts,
+ };
+}
+
+/** The catalog's part, or a stand-in when a stored layout names one this robot no longer has. */
+function partOf(catalog, id) {
+ return (catalog.parts || []).find((p) => p.id === id) || null;
+}
+
+function entryOf(layout, id) {
+ return ((layout && layout.parts) || []).find((p) => p.id === id) || null;
+}
+
+function styleOf(part, entry) {
+ const st = (entry && entry.style) || {};
+ const tints = (part.styles && part.styles.tints) || ['surface'];
+ const shapes = (part.styles && part.styles.shapes) || ['square'];
+ return {
+ tint: tints.includes(st.tint) ? st.tint : tints[0],
+ shape: shapes.includes(st.shape) ? st.shape : shapes[0],
+ opacity: typeof st.opacity === 'number' ? st.opacity : 1,
+ label: typeof st.label === 'boolean' ? st.label : (part.styles && part.styles.label) !== false,
+ };
+}
+
+/** One part's entry in the layout shown now: the person's, else the default one. */
+function resolvedEntry(catalog, layout, device, id) {
+ const part = partOf(catalog, id);
+ if (!part) return null;
+ return entryOf(layout, id) || entryOf(defaultLayout(catalog, device), id) || null;
+}
+
+/**
+ * The default layout: the camera, then the stick and Stop, then the rest in the catalog's order. Phone rows are
+ * full width; a computer gives the camera eight of its twelve columns and keeps the stick and Stop beside it.
+ */
+function defaultLayout(catalog, device = 'computer') {
+ const d = DEVICES.includes(device) ? device : 'computer';
+ const cols = GRID.columns[d];
+ const order = [...(catalog.parts || [])].sort((a, b) => {
+ const rank = (p) => (p.id === 'camera' ? -3 : p.id === 'stick' ? -2 : p.id === 'stop' ? -1 : p.id === 'pad' ? 0 : 1);
+ return rank(a) - rank(b);
+ });
+ let x = 0; let y = 0; let rowH = 0;
+ const parts = order.map((p) => {
+ const camera = p.id === 'camera';
+ const side = p.id === 'stick' || p.id === 'stop';
+ let w = camera ? (d === 'computer' ? 8 : cols) : side ? 2 : Math.min(cols, Math.max(p.min.w, d === 'phone' ? cols : 4));
+ if (p.kind === 'readout' && d !== 'phone') w = Math.min(cols, Math.max(2, p.min.w));
+ w = Math.min(cols, w);
+ let h = camera ? 3 : p.kind === 'readout' ? 1 : p.id === 'stop' || p.id === 'stick' ? 2 : Math.max(p.min.h, 2);
+ if (p.kind === 'slider') h = Math.max(1, p.min.h);
+ if (p.id === 'latency') h = 2;
+ if (x + w > cols) { x = 0; y += rowH; rowH = 0; }
+ const place = { x, y, w, h };
+ x += w;
+ rowH = Math.max(rowH, h);
+ const st = styleOf(p, null);
+ return { id: p.id, visible: true, place, style: st, bindings: { keys: [], gamepad: [] } };
+ });
+ return { surface: catalog.id, surface_version: catalog.version, device: d, name: 'Default', author: { name: 'OpenVibe' }, sharing: 'private', parts };
+}
+
+/** The overlay's default layout: the pill and stick left, more and e-stop right, the slider at the bottom. */
+function overlayDefaultLayout(catalog, device = 'computer') {
+ const dockedOf = (id, dock, order, w = 1, h = 1) => {
+ const p = partOf(catalog, id);
+ if (!p) return null;
+ return { id, visible: true, place: { dock, order, w: Math.max(p.min.w, w), h: Math.max(p.min.h, h) }, style: styleOf(p, null), bindings: { keys: [], gamepad: [] } };
+ };
+ const parts = [
+ dockedOf('pill', 'left', 0, 2, 1),
+ dockedOf('stick', 'left', 1, 2, 2),
+ dockedOf('slider', 'bottom', 0, 2, 1),
+ dockedOf('more', 'right', 0, 1, 1),
+ dockedOf('estop', 'right', 2, 2, 2),
+ dockedOf('drive', 'right', 0, 2, 1),
+ ].filter(Boolean);
+ return { surface: catalog.id, surface_version: catalog.version, device, name: 'Default', author: { name: 'OpenVibe' }, sharing: 'private', parts };
+}
+
+const escAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
+
+/**
+ * The attributes that put one part's entry on the element: its grid area (or dock edge), its style variables
+ * and whether this person hid it. `roleHidden` marks a part the page hides for a role reason (the e-stop before
+ * a watcher takes the controls): the editor must not unhide it.
+ */
+function attrsFor(catalog, layout, device, id, { apply = true } = {}) {
+ if (!apply) return '';
+ const part = partOf(catalog, id);
+ const e = resolvedEntry(catalog, layout, device, id);
+ if (!part || !e) return '';
+ const st = styleOf(part, e);
+ const bits = [
+ `data-ov-tint="${escAttr(st.tint)}"`, `data-ov-shape="${escAttr(st.shape)}"`, `data-ov-label="${st.label ? 'true' : 'false'}"`,
+ ];
+ const css = [`--ov-part-opacity:${st.opacity}`];
+ if (e.place && e.place.dock) {
+ bits.push(`data-ov-dock="${escAttr(e.place.dock)}"`, `data-ov-order="${Number(e.place.order) || 0}"`);
+ bits.push(`data-ov-size-w="${Number(e.place.w) || part.min.w}"`, `data-ov-size-h="${Number(e.place.h) || part.min.h}"`);
+ css.push(`--ov-part-w:${Number(e.place.w) || part.min.w}`, `--ov-part-h:${Number(e.place.h) || part.min.h}`);
+ } else if (e.place) {
+ bits.push(`data-ov-grid="${e.place.x},${e.place.y},${e.place.w},${e.place.h}"`);
+ css.push(`grid-column:${e.place.x + 1} / span ${e.place.w}`, `grid-row:${e.place.y + 1} / span ${e.place.h}`);
+ }
+ if (e.visible === false) bits.push('hidden');
+ bits.push(`style="${css.join(';')}"`);
+ return bits.join(' ');
+}
+
+/** The `.widgets` element's own attributes: the surface, its device class and its grid. */
+function surfaceAttrs(catalog, layout, device, apply = true) {
+ const cols = GRID.columns[device] || 12;
+ const cell = GRID.cell_px[device] || 64;
+ if (!apply) return `data-ov-surface="${escAttr(catalog.id)}" data-ov-device="${escAttr(device)}" data-ov-cols="${cols}" data-ov-cell="${cell}" style="--ov-cols:${cols};--ov-cell:${cell}px"`;
+ return `data-ov-arranged="true" data-ov-surface="${escAttr(catalog.id)}" data-ov-device="${escAttr(device)}" data-ov-cols="${cols}" data-ov-cell="${cell}" style="--ov-cols:${cols};--ov-cell:${cell}px;grid-template-columns:repeat(${cols}, minmax(0, 1fr));grid-auto-rows:minmax(${cell}px, auto)"`;
+}
+
+/** The overlay surface's attributes (no grid template: its parts dock to the edges). */
+function overlaySurfaceAttrs(catalog, layout, device, apply = true) {
+ const cols = GRID.columns[device] || 4;
+ const cell = GRID.cell_px[device] || 64;
+ if (!apply) return `data-ov-surface="${escAttr(catalog.id)}" data-ov-device="${escAttr(device)}" data-ov-cols="${cols}" data-ov-cell="${cell}" style="--ov-cols:${cols};--ov-cell:${cell}px"`;
+ return `data-ov-arranged="true" data-ov-custom="true" data-ov-surface="${escAttr(catalog.id)}" data-ov-device="${escAttr(device)}" data-ov-cols="${cols}" data-ov-cell="${cell}" style="--ov-cols:${cols};--ov-cell:${cell}px"`;
+}
+
+module.exports = { deviceClass, partsOf, controlsCatalog, overlayCatalog, defaultLayout, overlayDefaultLayout, attrsFor, surfaceAttrs, overlaySurfaceAttrs, styleOf, partOf, entryOf, THEME_TOKENS, GRID, DEVICES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+// The Arrange editor on the real panel, in headless Chrome (make your own controls, stage 2): the panel is drawn
+// with its parts, Arrange loads openvibe-shared/ov-arrange.js only when pressed, Stop moves on the grid, Save posts
+// the layout to the panel's own route, and Ask OpenVibe previews a suggestion before Keep. No robot and no
+// Network: the page is rendered by renderPanel and the two arrange endpoints answer with a stub. Skipped without
+// Chrome.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const http = require('node:http');
+const h = require('openvibe-shared/browser-harness');
+const render = require('../server/web/render');
+const surface = require('../server/web/surface');
+const profile = require('../server/profiles/looi.json');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('arrange-browser: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+const sharedRoot = path.dirname(require.resolve('openvibe-shared/package.json'));
+const catalog = surface.controlsCatalog(profile);
+const defaults = surface.defaultLayout(catalog, 'phone');
+const allowed = Object.keys(profile.commands);
+const robot = { id: 'r1', name: 'Looi', profile_id: profile.id, access_policy: 'private', estop: { latched: false } };
+const pageHtml = render.renderPanel({
+ robot, profile, role: 'owner', allowed_commands: allowed,
+ arrange: {
+ catalog, parts: surface.partsOf(profile), layout: defaults, device: 'phone', signedIn: true, layoutSource: 'default',
+ src: '/shared/ov-arrange.js', save: '/panel/r1/arrange', suggest: '/panel/r1/arrange/suggest', share: '/panel/r1/arrange/share',
+ },
+});
+const calls = { save: null, suggest: null };
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (req.method === 'POST' && (u.pathname === '/panel/r1/arrange' || u.pathname === '/panel/r1/arrange/suggest')) {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ const got = JSON.parse(body || '{}');
+ res.writeHead(200, { 'content-type': 'application/json' });
+ if (u.pathname.endsWith('/suggest')) {
+ calls.suggest = got;
+ const layout = JSON.parse(JSON.stringify(defaults));
+ const stop = layout.parts.find((p) => p.id === 'stop');
+ stop.place = { x: 0, y: 0, w: 2, h: 2 };
+ const stick = layout.parts.find((p) => p.id === 'stick');
+ stick.place = { x: 2, y: 0, w: 2, h: 2 };
+ return res.end(JSON.stringify({ ok: true, layout, summary: 'Stop is first, beside the stick.', reasons: [] }));
+ }
+ calls.save = got;
+ return res.end('{"ok":true}');
+ });
+ return;
+ }
+ if (u.pathname === '/panel/r1') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(pageHtml); }
+ if (u.pathname.startsWith('/panel/')) {
+ const file = path.join(__dirname, '..', 'public', u.pathname.slice('/panel/'.length));
+ if (fs.existsSync(file)) { res.writeHead(200, { 'content-type': file.endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(file)); }
+ }
+ if (u.pathname.startsWith('/shared/')) {
+ const file = path.join(sharedRoot, u.pathname.slice('/shared/'.length));
+ if (fs.existsSync(file) && fs.statSync(file).isFile()) { res.writeHead(200, { 'content-type': file.endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(file)); }
+ }
+ res.writeHead(404); res.end('no');
+});
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-arrange-bot-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ try {
+ const p = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800 });
+ await p.goto(base + '/panel/r1', { maxMs: 8000 });
+ const until = async (expr) => { const end = Date.now() + 8000; while (Date.now() < end) { const v = await p.evaluate(expr); if (v) return v; await wait(50); } return false; };
+ const evalv = (expr) => p.evaluate(expr);
+ assert.ok(await until(`!!document.querySelector('[data-arrange]')`), 'the Arrange button is there');
+ assert.ok(await evalv(`!window.OVArrange`), 'the editor is not loaded before Arrange is pressed');
+ await evalv(`document.querySelector('[data-arrange]').click()`);
+ assert.ok(await until(`!!window.OVArrange && !!window.ovArrange`), 'Arrange loads the editor and opens it');
+ if (!await until(`!!document.querySelector('[data-ov-custom]')`)) console.log('DEBUG', await evalv(`JSON.stringify({custom: !!document.querySelector('[data-ov-custom]'), surfaces: [...document.querySelectorAll('[data-ov-surface]')].map(e=>e.className+':'+e.getAttribute('data-ov-custom')), bar: !!document.querySelector('.ov-arrange-bar'), status: document.querySelector('.ov-arrange-status') && document.querySelector('.ov-arrange-status').textContent, err: window.__err})`));
+ assert.ok(await until(`!!document.querySelector('[data-ov-custom]')`), 'the editor applies the layout to the real surface');
+ // The stick and Stop are in their own cells; the old pad wrapper flattens.
+ assert.ok(await evalv(`getComputedStyle(document.querySelector('.widget-drive')).display === 'contents'`), 'the drive section flattens so its parts are grid cells');
+ const before = await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stop').place.x`);
+ for (let i = 0; i < 2; i++) await evalv(`(() => { const el = document.querySelector('[data-ov-part="stick"]'); el.focus(); el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); })()`);
+ await evalv(`(() => { const el = document.querySelector('[data-ov-part="stop"]'); el.focus(); el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); })()`);
+ assert.equal(await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stop').place.x`), before - 1, 'Stop moved one cell left');
+ await evalv(`document.querySelector('[data-save]').click()`);
+ assert.ok(calls.save && calls.save.layout, 'Save posted the layout to the panel route');
+ // Ask OpenVibe: previewed, then kept.
+ await evalv(`(() => { const f = document.querySelector('form[data-ask]'); f.querySelector('input').value = 'put Stop first'; f.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); })()`);
+ assert.ok(await until(`!!document.querySelector('.ov-arrange-preview')`), 'the suggestion is previewed');
+ await evalv(`document.querySelector('.ov-arrange-preview [data-keep]').click()`);
+ assert.equal(await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stop').place.x`), 0, 'Keep applied the suggestion');
+ assert.ok(calls.suggest && calls.suggest.words === 'put Stop first', 'the words reached the panel route');
+ // The Controls v2 store becomes the person's first layout once, then is gone.
+ const ctx2 = await browser.newContext();
+ const p2 = await h.openPage(browser, ctx2, { width: 390, height: 800 });
+ const p2until = async (expr) => { const end = Date.now() + 8000; while (Date.now() < end) { const v = await p2.evaluate(expr); if (v) return v; await wait(50); } return false; };
+ await p2.goto(base + '/panel/r1', { maxMs: 8000 });
+ await p2.evaluate(`localStorage.setItem('ov-bot-pad-prefs', JSON.stringify({ hand: 'left', size: 'l', readouts: 'off' }))`);
+ await p2.goto(base + '/panel/r1', { maxMs: 8000 });
+ assert.ok(await p2until(`localStorage.getItem('ov-bot-pad-prefs') === null`), 'the old store is retired after one migration');
+ const migrated = JSON.parse(await p2.evaluate(`document.getElementById('ov-arrange-data').textContent`));
+ assert.deepEqual(migrated.layout.parts.find((p) => p.id === 'stop').place, { x: 0, y: 0, w: 2, h: 2 }, 'Stop takes the left hand');
+ assert.deepEqual(migrated.layout.parts.find((p) => p.id === 'stick').place, { x: 0, y: 2, w: 3, h: 3 }, 'the pad takes its larger size, moved clear of Stop on a phone');
+ assert.equal(migrated.layout.parts.find((p) => p.id === 'battery').visible, false, 'the readouts are folded');
+ assert.ok(await p2until(`!!document.querySelector('.widgets[data-ov-arranged]')`), 'the migrated layout is drawn on the parts');
+ assert.equal(JSON.parse(await p2.evaluate(`document.getElementById('ov-arrange-data').textContent`)).layoutSource, 'default', 'the page was drawn from the default before the migration');
+ await p2.close();
+
+ console.log('arrange-browser: Arrange on the panel, grid move, Save to the panel route, AI preview and Keep, and the pad store migrated once');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+// The panel's Network client for a person's layout (make your own controls, stage 2): the read is the person's
+// own token, a 404 or an unreachable Network leaves the default, and save/suggest/share speak the person routes.
+// No network: a fake fetch records the request.
+const assert = require('node:assert/strict');
+const layouts = require('../server/web/layouts');
+
+const config = { network: { url: 'https://network.example' } };
+const layout = { surface: 'bot.controls', surface_version: 1, device: 'phone', name: 'Mine', author: { name: 'Ana' }, sharing: 'private', parts: [] };
+const catalog = { id: 'bot.controls', version: 1, name: 'Robot controls', devices: ['phone'], grid: { columns: { phone: 4 }, cell_px: { phone: 64 } }, theme_tokens: ['surface'], parts: [] };
+const seen = [];
+const answer = (status, body) => async (url, init) => {
+ seen.push({ url, method: init.method, auth: init.headers.Authorization, body: init.body ? JSON.parse(init.body) : null });
+ return { status, ok: status >= 200 && status < 300, json: async () => body };
+};
+
+(async () => {
+ assert.equal(await layouts.activeLayout({ config, token: null, surface: 'bot.controls', device: 'phone', fetchImpl: async () => { throw new Error('must not call'); } }), null, 'signed out never calls Network');
+ assert.equal(await layouts.activeLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', fetchImpl: answer(404, { error: 'none' }) }), null, 'no layout is the default');
+ const got = await layouts.activeLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', fetchImpl: answer(200, { layout }) });
+ assert.deepEqual(got, layout);
+ assert.match(seen.at(-1).url, /\/api\/v1\/me\/layouts\/bot\.controls\?device=phone$/);
+ assert.equal(seen.at(-1).auth, 'Bearer t', 'the person’s own token');
+
+ await layouts.saveLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', layout, catalog, fetchImpl: answer(200, { ...layout, id: 'lay_x' }) });
+ assert.equal(seen.at(-1).method, 'PUT');
+ assert.deepEqual(seen.at(-1).body, { catalog, layout });
+
+ const sug = await layouts.suggestLayout({ config, token: 't', surface: 'bot.overlay', device: 'phone', words: 'put Stop left', layout, catalog, fetchImpl: answer(200, { ok: true, layout, summary: 'Stop is left.', reasons: [] }) });
+ assert.equal(sug.ok, true);
+ assert.match(seen.at(-1).url, /\/api\/v1\/me\/layouts\/bot\.overlay\/suggest\?device=phone$/);
+ assert.equal(seen.at(-1).body.words, 'put Stop left');
+
+ await assert.rejects(() => layouts.saveLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', layout, catalog, fetchImpl: answer(422, { detail: 'Stop has to stay visible.' }) }), /Stop has to stay visible\./);
+
+ assert.deepEqual(await layouts.resetLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', fetchImpl: answer(204, null) }), { ok: true });
+ assert.match(seen.at(-1).url, /\/api\/v1\/me\/layouts\/bot\.controls\/reset\?device=phone$/);
+ assert.deepEqual(await layouts.resetLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', fetchImpl: answer(404, { code: 'layout.none' }) }), { ok: true }, 'nothing to reset is the default already');
+
+ const shared = await layouts.shareLayout({ config, token: 't', surface: 'bot.controls', device: 'phone', layout, catalog, fetchImpl: (url, init) => {
+ if (init.method === 'PUT') return answer(200, { ...layout, id: 'lay_abc' })(url, init);
+ return answer(200, { url: '/api/v1/layouts/shared/tok_1' })(url, init);
+ } });
+ assert.match(seen.at(-1).url, /\/api\/v1\/me\/layouts\/lay_abc\/share$/);
+ assert.equal(shared.url, 'https://network.example/api/v1/layouts/shared/tok_1');
+ console.log('arrange-layouts: read, 404, save, suggest and a refusal');
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+// The panel pages carry the Arrange surface (make your own controls, stage 2): the person's layout is drawn on the
+// real parts server-side, the editor itself is not loaded until Arrange is pressed, the old pad menu is gone, and
+// the overlay marks its own parts. Nothing here changes a robot command.
+const assert = require('node:assert/strict');
+const render = require('../server/web/render');
+const surface = require('../server/web/surface');
+const profile = require('../server/profiles/looi.json');
+const { validate } = require('openvibe-sdk/ui-layout');
+
+const robot = { id: 'r1', name: 'Looi', profile_id: profile.id, access_policy: 'private', estop: { latched: false } };
+const allowed = Object.keys(profile.commands);
+const catalog = surface.controlsCatalog(profile);
+const defaults = surface.defaultLayout(catalog, 'phone');
+
+function panel(extra = {}) {
+ return render.renderPanel({
+ robot, profile, role: 'owner', allowed_commands: allowed,
+ arrange: {
+ catalog, parts: surface.partsOf(profile), layout: defaults, device: 'phone', signedIn: true,
+ layoutSource: 'default', src: '/shared/ov-arrange.js?v=abc', save: '/panel/r1/arrange',
+ suggest: '/panel/r1/arrange/suggest', share: '/panel/r1/arrange/share', ...extra,
+ },
+ });
+}
+
+const html = panel();
+assert.ok(html.includes('data-arrange'), 'the Arrange button is in the panel head');
+assert.ok(/data-arrange-src="\/shared\/ov-arrange\.js\?v=abc"/.test(html), 'it names the shared editor to load when pressed');
+assert.ok(!/<script[^>]+ov-arrange\.js/.test(html), 'the editor is not loaded until Arrange is pressed');
+assert.ok(html.includes('id="ov-arrange-data"'), 'the catalog and layout reach the client as data');
+assert.ok(!html.includes('pad-prefs'), 'the old sliders-icon arrangement menu is gone');
+assert.ok(html.includes('data-ov-part="stop"') && html.includes('data-ov-part="stick"') && html.includes('data-ov-part="head"'), 'the real parts are marked');
+assert.ok(html.includes('data-ov-surface="bot.controls"'), 'the panel names its surface');
+assert.ok(!html.includes('data-ov-arranged="true"'), 'without a stored layout the page keeps its own look (the editor applies the grid when opened)');
+
+const stored = surface.defaultLayout(catalog, 'phone');
+stored.parts.find((p) => p.id === 'battery').visible = false;
+const withLayout = panel({ layout: stored, layoutSource: 'account' });
+assert.ok(/data-ov-arranged="true"/.test(withLayout), 'a stored layout draws the arranged grid on the server');
+assert.ok(/grid-column:1 \/ span 2/.test(withLayout), 'the stick is drawn in its cells');
+assert.ok(/data-ov-part="battery"[^>]*hidden/.test(withLayout) || /hidden[^>]*data-ov-part="battery"/.test(withLayout), 'a hidden readout is hidden before first paint');
+
+const overlayCatalog = surface.overlayCatalog(profile, { slider: { name: 'head' }, hasStick: true, canDrive: false });
+const overlay = render.renderPanel({
+ robot, profile, role: 'watcher', allowed_commands: [], mode: 'embed', view: 'overlay', signedIn: false,
+ arrange: {
+ catalog: overlayCatalog, layout: surface.overlayDefaultLayout(overlayCatalog, 'phone'), device: 'phone',
+ signedIn: false, layoutSource: 'default', save: '/panel/r1/arrange', suggest: '/panel/r1/arrange/suggest', share: '/panel/r1/arrange/share',
+ },
+});
+for (const id of ['pill', 'stick', 'slider', 'estop', 'more']) assert.ok(overlay.includes(`data-ov-part="${id}"`), `the overlay marks ${id}`);
+assert.ok(overlay.includes('data-ov-role-hidden="true"'), 'the watcher’s e-stop stays role-hidden until Drive');
+assert.ok(overlay.includes('id="ov-arrange-data"'), 'the overlay carries its catalog and layout too');
+assert.ok(overlayCatalog.parts.find((p) => p.id === 'estop').required, 'the overlay catalog makes the e-stop required');
+
+// The default layouts are what the SDK would accept, the one check every surface shares.
+assert.deepEqual(validate(catalog, defaults), { ok: true, reasons: [] });
+assert.deepEqual(validate(overlayCatalog, surface.overlayDefaultLayout(overlayCatalog, 'phone')), { ok: true, reasons: [] });
+console.log('arrange-panel: parts, server-side layout, lazy editor, no old menu, stored layout and overlay');
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+// The panel's arrangeable surfaces (make your own controls, stage 2): bot.controls is built from the robot's own
+// widgets, Stop and the overlay's e-stop are required, the default layouts pass openvibe-sdk/ui-layout, and the
+// attributes put a person's layout on the real parts.
+const assert = require('node:assert/strict');
+const { validate } = require('openvibe-sdk/ui-layout');
+const surface = require('../server/web/surface');
+const looi = require('../server/profiles/looi.json');
+const rover = require('../server/profiles/sim.rover.json');
+
+const ids = (catalog) => catalog.parts.map((p) => p.id);
+const cat = surface.controlsCatalog(looi);
+assert.deepEqual(ids(cat), ['stick', 'stop', 'head', 'battery', 'latency'], 'the LOOI panel: the stick, Stop, the head slider and the readouts');
+assert.equal(cat.parts.find((p) => p.id === 'stop').required, true, 'Stop is required');
+assert.equal(cat.parts.find((p) => p.id === 'stick').styles.label, false, 'the stick has no label');
+for (const device of ['phone', 'tablet', 'computer']) {
+ const layout = surface.defaultLayout(cat, device);
+ assert.deepEqual(validate(cat, layout), { ok: true, reasons: [] }, `the ${device} default layout is valid`);
+}
+const roverCat = surface.controlsCatalog(rover);
+assert.ok(ids(roverCat).includes('camera') && ids(roverCat).includes('stick') && ids(roverCat).includes('lights'), 'the rover: the camera, the drive stick, Stop and its controls');
+assert.ok(ids(roverCat).includes('telemetry.2'), 'a second widget of one type gets its own part');
+assert.deepEqual(validate(roverCat, surface.defaultLayout(roverCat, 'phone')), { ok: true, reasons: [] });
+
+const overlay = surface.overlayCatalog(looi, { slider: { name: 'head' }, hasStick: true, canDrive: true });
+assert.deepEqual(ids(overlay), ['pill', 'more', 'estop', 'stick', 'slider', 'drive']);
+assert.equal(overlay.parts.find((p) => p.id === 'estop').required, true, 'the overlay e-stop is required');
+assert.deepEqual(validate(overlay, surface.overlayDefaultLayout(overlay, 'phone')), { ok: true, reasons: [] }, 'the overlay default is valid');
+
+// A hidden readout and a moved Stop reach the element's attributes; a default (not the person's own) leaves the
+// page's own look alone and only names the surface.
+const layout = surface.defaultLayout(cat, 'phone');
+layout.parts.find((p) => p.id === 'battery').visible = false;
+assert.match(surface.attrsFor(cat, layout, 'phone', 'battery'), /hidden/, 'a hidden part carries hidden');
+const stop = surface.attrsFor(cat, layout, 'phone', 'stop');
+assert.match(stop, /grid-column:3 \/ span 2/, 'Stop is beside the stick');
+assert.equal((stop.match(/style=/g) || []).length, 1, 'one style attribute');
+assert.match(stop, /--ov-part-opacity:1/, 'its opacity comes from the layout');
+assert.equal(surface.attrsFor(cat, layout, 'phone', 'stop', { apply: false }), '', 'the default is the page, not the layout');
+assert.match(surface.surfaceAttrs(cat, layout, 'phone'), /data-ov-arranged="true"/);
+assert.match(surface.surfaceAttrs(cat, layout, 'phone', false), /data-ov-surface="bot.controls"/);
+assert.doesNotMatch(surface.surfaceAttrs(cat, layout, 'phone', false), /data-ov-arranged/);
+
+assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' } }), 'phone');
+assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)' } }), 'tablet');
+assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)' } }), 'computer');
+console.log('surface: bot.controls and bot.overlay catalogs, required safety parts, valid defaults and part attributes');