openvibers/openvibe.shared ov-arrange.js

ov-arrange.js

main 60.2 KB · 845 lines · javascript

1/**
2 * openvibe-shared/ov-arrange.js — the Arrange editor (make your own controls, stage 2, 2026-11-11).
3 *
4 * A product publishes a ui.surface@1 catalog and renders one element per part with data-ov-part="<id>".
5 * A person's ui.layout@1 is applied to those real parts: a place on the surface's grid, or a dock edge for
6 * an overlay; visible; a style (a theme token, a shape, an opacity and the label); and bindings. This file
7 * is loaded only when someone presses Arrange (a product loads it then, by the shared serve URL, and this
8 * file loads its own sibling ov-arrange-core.js — openvibe-sdk/ui-layout for the browser — by content hash).
9 *
10 * OVArrange.open({
11 * surface, catalog, layout, device: 'phone'|'tablet'|'computer',
12 * validate(catalog, layout) -> { ok, reasons[] }, // optional: defaults to the sdk core
13 * defaults(catalog) -> layout, // optional: defaults to the sdk core
14 * onSave(layout) -> Promise<{ ok?, reasons? }>, // PUT the person's layout (Network)
15 * onShare(layout) -> Promise<{ url?, reasons? }>, // save, then a share link
16 * onReset() -> Promise<{ ok?, reasons? }>, // clear the stored layout (Network's reset)
17 * onAsk(words, request) -> Promise<ui.layout-suggestion@1>,
18 * onClose(), confirm() -> boolean, core
19 * }) -> { close, apply, layout, destroy }
20 *
21 * Edit mode: drag a part to move it, drag its corner to resize, arrow keys to move and Shift with the
22 * arrows to resize (for a docked part: the arrows choose the edge, up/down its order). A part's sheet
23 * (long press on a phone, the Style button, or Enter on a part) has visible, size, style and bindings.
24 * Every move and resize is announced, and a refused move says why in plain words (Stop has to stay
25 * visible; it is too small). Undo, Reset to default, Save and Share are in the bar, with Ask OpenVibe:
26 * a suggestion is previewed on the real parts and kept only with Keep. Motion Off changes nothing;
27 * Calm plays the short transition. Plain kit markup (ovk-*): the look is the page's.
28 */
29(function (root) {
30 'use strict';
31 if (!root || root.OVArrange || typeof document === 'undefined') return;
32
33 const VERSION = '1.0.0';
34 const MOTION = () => {
35 const m = document.documentElement.getAttribute('data-ov-motion');
36 if (m === 'off') return 'off';
37 return m === 'reduced' || (!m && root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches) ? 'calm' : 'full';
38 };
39
40 const CSS = `
41.ov-arrange-surface { position: relative; display: grid; grid-template-columns: repeat(var(--ov-cols, 12), minmax(0, 1fr)); grid-auto-rows: minmax(var(--ov-cell, 64px), auto); gap: var(--ov-gap, .75rem); }
42.ov-arrange-surface > [data-ov-part] { min-width: 0; min-height: 0; opacity: var(--ov-part-opacity, 1); }
43.ov-arrange-surface > [data-ov-part][data-ov-label="false"] .ov-arrange-label { display: none; }
44.ov-arrange-editing [data-ov-part] { cursor: grab; outline: 1px dashed color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); outline-offset: 2px; touch-action: none; transition: box-shadow var(--ov-arrange-dur, .2s) ease, outline-color var(--ov-arrange-dur, .2s) ease; }
45.ov-arrange-editing [data-ov-part]:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 3px; }
46.ov-arrange-editing [data-ov-part][data-ov-selected] { outline: 2px solid var(--accent, #3b82f6); outline-offset: 3px; }
47.ov-arrange-editing [data-ov-part] :is(button, input, select, textarea, a, [role="button"]) { pointer-events: none; }
48.ov-arrange-editing [data-ov-part][data-ov-dragging] { cursor: grabbing; opacity: .8; }
49.ov-arrange-editing [data-ov-part][data-ov-refused] { outline-color: var(--danger, #e5484d); }
50.ov-arrange-sel { position: fixed; z-index: 2147482990; pointer-events: none; border: 2px solid var(--accent, #3b82f6); border-radius: 10px; box-shadow: 0 0 0 9999px color-mix(in srgb, #000 14%, transparent); }
51.ov-arrange-handle, .ov-arrange-style { position: fixed; z-index: 2147482991; pointer-events: auto; width: 34px; height: 34px; min-width: 34px; min-height: 34px; padding: 0; display: grid; place-items: center; border-radius: 9px; border: 1px solid var(--line, rgba(255,255,255,.2)); background: var(--raised, #1b2230); color: var(--text, #eef); cursor: nwse-resize; }
52.ov-arrange-handle { touch-action: none; }
53.ov-arrange-style { cursor: pointer; font-size: 15px; }
54.ov-arrange-handle::before { content: ""; width: 12px; height: 12px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: translate(-2px, -2px); }
55.ov-arrange-bar { position: fixed; z-index: 2147483000; left: 50%; bottom: max(12px, env(safe-area-inset-bottom)); transform: translateX(-50%); width: min(56rem, calc(100vw - 24px)); display: grid; gap: 8px; padding: 10px 12px; border: 1px solid var(--line, rgba(255,255,255,.16)); border-radius: 16px; background: var(--surface, #151b26); color: var(--text, #eef); box-shadow: 0 18px 50px -16px rgba(0,0,0,.7); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
56.ov-arrange-bar form { display: flex; gap: 8px; min-width: 0; }
57.ov-arrange-bar input { flex: 1; min-width: 0; min-height: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--line, rgba(255,255,255,.18)); background: var(--bg, #0d1117); color: var(--text, #eef); font: inherit; }
58.ov-arrange-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
59.ov-arrange-actions .ovk-btn, .ov-arrange-bar form .ovk-btn { min-height: 38px; }
60.ov-arrange-status { margin: 0; min-height: 1.2em; color: var(--muted, #9aa6b6); font-size: 12.5px; }
61.ov-arrange-status[data-tone="bad"] { color: var(--danger, #e5484d); }
62.ov-arrange-status[data-tone="ok"] { color: var(--ok, #3fb950); }
63.ov-arrange-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 10px; background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
64.ov-arrange-preview p { margin: 0; flex: 1 1 14rem; }
65.ov-arrange-sheet { position: fixed; z-index: 2147483001; inset: auto 0 0 0; max-height: min(82vh, 42rem); overflow: auto; padding: 14px; background: var(--surface, #151b26); color: var(--text, #eef); border-top: 1px solid var(--line, rgba(255,255,255,.18)); border-radius: 16px 16px 0 0; box-shadow: 0 -20px 50px -20px rgba(0,0,0,.7); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: grid; gap: 12px; }
66@media (min-width: 44rem) { .ov-arrange-sheet { left: 50%; right: auto; bottom: 50%; transform: translate(-50%, 50%); width: min(30rem, calc(100vw - 32px)); border-radius: 16px; border: 1px solid var(--line, rgba(255,255,255,.18)); } }
67.ov-arrange-sheet h2 { margin: 0; font-size: 15px; }
68.ov-arrange-sheet .ov-arrange-row { display: grid; gap: 5px; }
69.ov-arrange-sheet .ov-arrange-row > span { font-size: 12px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--muted, #9aa6b6); }
70.ov-arrange-sheet .ov-arrange-opts { display: flex; flex-wrap: wrap; gap: 6px; }
71.ov-arrange-sheet .ov-arrange-opts label { display: inline-flex; align-items: center; gap: 5px; min-height: 36px; padding: 0 10px; border: 1px solid var(--line, rgba(255,255,255,.18)); border-radius: 9px; cursor: pointer; }
72.ov-arrange-sheet .ov-arrange-opts input { margin: 0; }
73.ov-arrange-sheet .ov-arrange-close { justify-self: end; }
74.ov-arrange-sheet input[type="text"] { min-height: 38px; padding: 0 10px; border-radius: 9px; border: 1px solid var(--line, rgba(255,255,255,.18)); background: var(--bg, #0d1117); color: var(--text, #eef); font: inherit; }
75.ov-arrange-share { display: grid; gap: 8px; padding: 10px; border-radius: 10px; background: var(--bg, #0d1117); }
76.ov-arrange-share input { width: 100%; min-height: 38px; padding: 0 10px; border-radius: 9px; border: 1px solid var(--line, rgba(255,255,255,.18)); background: var(--surface, #151b26); color: var(--text, #eef); font: inherit; }
77html[data-ov-motion="off"] .ov-arrange-surface [data-ov-part], html[data-ov-motion="off"] .ov-arrange-sel, html[data-ov-motion="off"] .ov-arrange-bar { transition: none !important; }
78html[data-ov-motion="reduced"] { --ov-arrange-dur: .12s; }
79`;
80
81 const clone = (v) => (v == null ? v : JSON.parse(JSON.stringify(v)));
82 const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
83 const esc = (s) => String(s == null ? '' : s);
84 const partName = (catalog, id) => {
85 const p = (catalog.parts || []).find((x) => x.id === id);
86 return p ? p.name : id;
87 };
88 const catalogPart = (catalog, id) => (catalog.parts || []).find((x) => x.id === id) || null;
89 const entryOf = (layout, id) => ((layout && layout.parts) || []).find((p) => p.id === id) || null;
90 const docked = (place) => !!(place && place.dock);
91
92 function styleOnce(doc) {
93 if (doc.getElementById('ov-arrange-styles')) return;
94 const st = doc.createElement('style');
95 st.id = 'ov-arrange-styles';
96 st.textContent = CSS;
97 (doc.head || doc.documentElement).appendChild(st);
98 }
99
100 // ── The SDK's rules, as a browser sibling (scripts/build-arrange-core.js) ─────────────────────────
101 const ARRANGE_CORE_REV = { 'ov-arrange-core.js': 'cbf71f9cebb2' };
102 let coreLoading = null;
103 function loadCore(opts) {
104 if (opts && opts.core) return Promise.resolve(opts.core);
105 if (root.OVArrangeCore) return Promise.resolve(root.OVArrangeCore);
106 if (coreLoading) return coreLoading;
107 if (!root.document || !document.currentScript) return Promise.resolve(null);
108 const src = document.currentScript.src || '';
109 if (!src) return Promise.resolve(null);
110 const url = src.replace(/[^/]*$/, `ov-arrange-core.js${ARRANGE_CORE_REV['ov-arrange-core.js'] ? `?v=${ARRANGE_CORE_REV['ov-arrange-core.js']}` : ''}`);
111 coreLoading = new Promise((resolve) => {
112 const sc = document.createElement('script');
113 sc.src = url; sc.async = true;
114 sc.onload = () => resolve(root.OVArrangeCore || null);
115 sc.onerror = () => resolve(null);
116 document.head.appendChild(sc);
117 });
118 return coreLoading;
119 }
120
121 // The rules when the SDK core is not there (a product without the sibling): the same checks the contract
122 // lists, in the same plain words. The SDK remains the one authority when it is loaded.
123 function fallbackValidate(catalog, layout) {
124 const reasons = [];
125 const parts = catalog && catalog.parts || [];
126 const byId = new Map(parts.map((p) => [p.id, p]));
127 if (!isObj(layout)) return { ok: false, reasons: ['That is not a layout.'] };
128 if (layout.surface !== catalog.id) reasons.push(`This layout is for ${esc(layout.surface)}, not ${esc(catalog.id)}.`);
129 if (layout.surface_version !== catalog.version) reasons.push('This layout was made for another version of these controls.');
130 if (!(catalog.devices || []).includes(layout.device)) reasons.push(`These controls do not work on a ${esc(layout.device)}.`);
131 if (!Array.isArray(layout.parts) || !layout.parts.length) reasons.push('A layout needs at least one part.');
132 const seen = new Set();
133 const entries = Array.isArray(layout.parts) ? layout.parts : [];
134 for (const e of entries) {
135 if (!isObj(e) || typeof e.id !== 'string') { reasons.push('A layout has a part without an id.'); continue; }
136 if (seen.has(e.id)) reasons.push(`${partName(catalog, e.id)} is in the layout twice.`);
137 seen.add(e.id);
138 if (!byId.has(e.id)) { reasons.push(`These controls have no part called ${esc(e.id)}.`); continue; }
139 // No links or code anywhere in the data.
140 for (const [k, v] of Object.entries(e)) {
141 if (typeof v === 'string' && /(https?:\/\/|\/\/|javascript:|data:|<[a-z])/i.test(v)) reasons.push('A layout only carries data: no links or code.');
142 if (k === 'style' && isObj(v)) for (const sv of Object.values(v)) if (typeof sv === 'string' && /(https?:\/\/|\/\/|javascript:|data:|<[a-z])/i.test(sv)) reasons.push('A layout only carries data: no links or code.');
143 }
144 }
145 for (const p of parts) {
146 const e = entries.find((x) => isObj(x) && x.id === p.id);
147 if (p.required && !e) { reasons.push(`${p.name} has to be there.`); continue; }
148 if (!e) continue;
149 if (p.required && e.visible === false) { reasons.push(`${p.name} has to stay visible.`); continue; }
150 if (!isObj(e.place)) { reasons.push(`${p.name} has no place.`); continue; }
151 if (!docked(e.place)) {
152 const { x, y, w, h } = e.place;
153 const cols = (catalog.grid && catalog.grid.columns && catalog.grid.columns[layout.device]) || 12;
154 if (![x, y, w, h].every((n) => Number.isInteger(n))) { reasons.push(`${p.name} needs a place on the grid.`); continue; }
155 if (x < 0 || y < 0 || w < 1 || h < 1 || x + w > cols) reasons.push(`${p.name} does not fit on this grid.`);
156 if (w < p.min.w || h < p.min.h) reasons.push(`${p.name} is too small: it needs ${p.min.w} by ${p.min.h} cells.`);
157 const cell = (catalog.grid && catalog.grid.cell_px && catalog.grid.cell_px[layout.device]) || 44;
158 if (p.min.px && (w * cell < p.min.px || h * cell < p.min.px)) reasons.push(`${p.name} has to be at least ${p.min.px} px.`);
159 if (p.pressed && (w * cell < 44 || h * cell < 44)) reasons.push(`${p.name} is pressed, so it has to be at least 44 px.`);
160 } else {
161 if (!['left', 'right', 'top', 'bottom'].includes(e.place.dock)) reasons.push(`${p.name} needs an edge to dock to.`);
162 if (!Number.isInteger(e.place.order) || e.place.order < 0) reasons.push(`${p.name} needs an order on its edge.`);
163 if (!Number.isInteger(e.place.w) || !Number.isInteger(e.place.h)) reasons.push(`${p.name} needs a size, in cells.`);
164 else {
165 if (e.place.w < p.min.w || e.place.h < p.min.h) reasons.push(`${p.name} is too small: it needs ${p.min.w} by ${p.min.h} cells.`);
166 const cell = (catalog.grid && catalog.grid.cell_px && catalog.grid.cell_px[layout.device]) || 44;
167 if (p.min.px && (e.place.w * cell < p.min.px || e.place.h * cell < p.min.px)) reasons.push(`${p.name} has to be at least ${p.min.px} px.`);
168 if (p.pressed && (e.place.w * cell < 44 || e.place.h * cell < 44)) reasons.push(`${p.name} is pressed, so it has to be at least 44 px.`);
169 }
170 }
171 const st = e.style || {};
172 if (!(catalog.theme_tokens || []).includes(st.tint)) reasons.push(`${p.name} uses a colour this theme does not have.`);
173 if (!((p.styles && p.styles.tints) || []).includes(st.tint)) reasons.push(`${partName(catalog, p.id)} cannot use that colour.`);
174 if (!((p.styles && p.styles.shapes) || []).includes(st.shape)) reasons.push(`${p.name} cannot be that shape.`);
175 if (typeof st.opacity === 'number' && (st.opacity < 0.4 || st.opacity > 1)) reasons.push(`${p.name} has to stay at least 40% visible.`);
176 const b = e.bindings || {};
177 for (const k of (b.keys || [])) if (!((p.bindings && p.bindings.keys) || []).includes(k)) reasons.push(`${p.name} cannot use the key ${esc(k)}.`);
178 for (const g of (b.gamepad || [])) if (!((p.bindings && p.bindings.gamepad) || []).includes(g)) reasons.push(`${p.name} cannot use the gamepad control ${esc(g)}.`);
179 }
180 // Nothing covers a required part, and never_covers.
181 const placed = entries.filter((e) => isObj(e.place) && !docked(e.place));
182 const dockedEntries = entries.filter((e) => isObj(e.place) && docked(e.place));
183 for (const a of dockedEntries) {
184 if (!catalogPart(catalog, a.id)) continue;
185 for (const b of dockedEntries) {
186 if (a === b || !catalogPart(catalog, b.id)) continue;
187 if (a.place.dock === b.place.dock && a.place.order === b.place.order) {
188 const ap = catalogPart(catalog, a.id), bp = catalogPart(catalog, b.id);
189 if (bp.required) reasons.push(`${partName(catalog, a.id)} covers ${bp.name}.`);
190 if (((ap.never_covers) || []).includes(b.id)) reasons.push(`${partName(catalog, a.id)} must never cover ${bp.name}.`);
191 }
192 }
193 }
194 for (const a of placed) {
195 if (!catalogPart(catalog, a.id)) continue;
196 for (const b of placed) {
197 if (a === b || !catalogPart(catalog, b.id)) continue;
198 const covers = a.place.x < b.place.x + b.place.w && a.place.x + a.place.w > b.place.x && a.place.y < b.place.y + b.place.h && a.place.y + a.place.h > b.place.y;
199 if (!covers) continue;
200 const bp = catalogPart(catalog, b.id);
201 if (bp.required) reasons.push(`${partName(catalog, a.id)} covers ${bp.name}.`);
202 if (((catalogPart(catalog, a.id).never_covers) || []).includes(b.id)) reasons.push(`${partName(catalog, a.id)} must never cover ${bp.name}.`);
203 }
204 }
205 return { ok: reasons.length === 0, reasons: [...new Set(reasons)] };
206 }
207
208 // ── Defaults: one visible part after another on the grid; docked parts by their catalog order ─────
209 function spanFor(p, device) {
210 const cols = (p && p._cols) || 4;
211 if (device === 'computer') return { w: Math.min(4, cols), h: 2 };
212 if (device === 'tablet') return { w: Math.min(4, cols), h: 2 };
213 return { w: cols, h: 2 };
214 }
215 function fallbackDefaults(catalog, device) {
216 const cols = (catalog.grid && catalog.grid.columns && catalog.grid.columns[device]) || 12;
217 const overlay = (catalog.id || '').split('.').pop() === 'overlay';
218 const parts = (catalog.parts || []).map((p) => {
219 const st = { tint: (p.styles && p.styles.tints && p.styles.tints[0]) || (catalog.theme_tokens || ['surface'])[0], shape: (p.styles && p.styles.shapes && p.styles.shapes[0]) || 'square', opacity: 1, label: true };
220 const b = { keys: (p.bindings && p.bindings.keys || []).slice(0, 0), gamepad: [] };
221 if (overlay) {
222 const dock = p.id === 'pill' || p.id === 'stick' ? 'left' : p.id === 'slider' ? 'bottom' : 'right';
223 return { id: p.id, visible: true, place: { dock, order: 0, w: Math.max(p.min.w, p.id === 'stick' ? 2 : 1), h: Math.max(p.min.h, p.id === 'stick' ? 2 : 1) }, style: st, bindings: b };
224 }
225 return { id: p.id, visible: true, place: { x: 0, y: 0, w: Math.min(spanFor({ ...p, _cols: cols }, device).w, cols), h: Math.max(p.min.h, 2) }, style: st, bindings: b };
226 });
227 if (!overlay) {
228 // Flow them: full-width rows on a phone, two or three columns on larger surfaces.
229 let y = 0;
230 for (const p of parts) {
231 const c = catalogPart(catalog, p.id);
232 const w = Math.min(cols, Math.max(c.min.w, device === 'phone' ? cols : Math.min(cols, device === 'tablet' ? 4 : 4)));
233 const h = Math.max(c.min.h, c.kind === 'video' ? 3 : c.kind === 'readout' ? 1 : 2);
234 if (p.place.x + w > cols) { p.place.x = 0; y += 1; }
235 p.place = { x: p.place.x, y, w, h };
236 p.place.x += w;
237 if (p.place.x >= cols) { p.place.x = 0; y += h; }
238 }
239 }
240 return { surface: catalog.id, surface_version: catalog.version, device, name: 'My layout', author: { name: 'Me' }, sharing: 'private', parts };
241 }
242
243 function fitPlace(place, catalog, device, part) {
244 if (!place) return null;
245 const cols = (catalog.grid && catalog.grid.columns && catalog.grid.columns[device]) || 12;
246 if (docked(place)) return { dock: place.dock, order: Math.max(0, Math.min(63, Number(place.order) || 0)), w: Math.max(1, Math.min(24, Math.round(place.w) || part.min.w)), h: Math.max(1, Math.min(24, Math.round(place.h) || part.min.h)) };
247 const w = Math.max(1, Math.min(24, Math.round(place.w) || part.min.w));
248 const h = Math.max(1, Math.min(24, Math.round(place.h) || part.min.h));
249 return { x: Math.max(0, Math.min(Math.max(0, cols - w), Math.round(place.x) || 0)), y: Math.max(0, Math.min(100, Math.round(place.y) || 0)), w, h };
250 }
251
252 // ── The editor ──────────────────────────────────────────────────────────────────────────────────
253 function open(opts) {
254 const surface = opts && opts.surface;
255 if (!surface || !isObj(opts.catalog)) throw new Error('OVArrange.open: a surface element and its ui.surface@1 catalog are needed');
256 const catalog = opts.catalog;
257 const doc = surface.ownerDocument || document;
258 const device = ['phone', 'tablet', 'computer'].includes(opts.device) ? opts.device : 'computer';
259 styleOnce(doc);
260 const cols = (catalog.grid && catalog.grid.columns && catalog.grid.columns[device]) || 12;
261 const cell = (catalog.grid && catalog.grid.cell_px && catalog.grid.cell_px[device]) || 64;
262 const confirmFn = typeof opts.confirm === 'function' ? opts.confirm : (msg) => !root.confirm || root.confirm(msg);
263 const announce = (text) => { if (live) live.textContent = text; if (opts.announce) try { opts.announce(text); } catch (_) { /* */ } };
264
265 let core = opts.core || root.OVArrangeCore || null;
266 loadCore(opts).then((c) => { if (c) core = c; }).catch(() => { /* the fallback rules stay */ });
267 const validate = (lay) => {
268 const fn = (opts.validate || (core && core.validate) || fallbackValidate);
269 try {
270 const v = fn(catalog, lay, device);
271 return v && typeof v.ok === 'boolean' ? { ok: v.ok, reasons: Array.isArray(v.reasons) ? v.reasons : [] } : { ok: false, reasons: ['That layout could not be checked.'] };
272 } catch (e) { return { ok: false, reasons: [String(e && e.message || e)] }; }
273 };
274 const defaults = () => {
275 const fn = opts.defaults || (core && core.applyDefaults);
276 try { const d = fn ? fn(catalog, device) : null; if (isObj(d)) return d; } catch (_) { /* fall through */ }
277 return fallbackDefaults(catalog, device);
278 };
279 const normal = (lay) => {
280 const d = defaults();
281 const out = clone(d);
282 const base = isObj(lay) ? lay : opts.layout;
283 out.surface = catalog.id; out.surface_version = catalog.version; out.device = device;
284 if (isObj(base)) {
285 out.name = base.name || out.name; out.sharing = base.sharing || out.sharing; out.author = base.author || out.author;
286 out.parts = (out.parts || []).map((p) => {
287 const got = entryOf(base, p.id);
288 return got ? { ...p, ...clone(got) } : p;
289 });
290 }
291 out.parts = (out.parts || []).map((p) => {
292 const cp = catalogPart(catalog, p.id) || { min: { w: 1, h: 1 }, styles: {} };
293 return { ...p, visible: cp.required ? true : p.visible !== false, place: fitPlace(p.place, catalog, device, cp) || { x: 0, y: 0, w: cp.min.w, h: cp.min.h }, style: { tint: (p.style && p.style.tint) || (cp.styles.tints || catalog.theme_tokens || ['surface'])[0], shape: (p.style && p.style.shape) || (cp.styles.shapes || ['square'])[0], opacity: p.style && typeof p.style.opacity === 'number' ? p.style.opacity : 1, label: p.style && typeof p.style.label === 'boolean' ? p.style.label : (cp.styles && cp.styles.label) !== false }, bindings: { keys: (p.bindings && p.bindings.keys) || [], gamepad: (p.bindings && p.bindings.gamepad) || [] } };
294 });
295 return out;
296 };
297
298 const initial = normal(opts.layout);
299 let working = clone(initial);
300 let undoStack = [];
301 let preview = null; // { layout, summary, reasons, words }
302 let selected = null;
303 let dirty = false;
304 let saving = false;
305 let closed = false;
306 let drag = null;
307
308 const host = doc.createElement('div');
309 host.className = 'ov-arrange-bar';
310 host.setAttribute('role', 'toolbar');
311 host.setAttribute('aria-label', 'Arrange the controls');
312 host.innerHTML = `<form data-ask><label class="sr-only" for="ov-arrange-words">Ask OpenVibe</label><input id="ov-arrange-words" type="text" maxlength="500" autocomplete="off" placeholder="Describe how you want it…" aria-label="Ask OpenVibe"><button type="submit" class="ovk-btn ovk-btn--primary">Ask OpenVibe</button></form>
313<div class="ov-arrange-actions"><button type="button" class="ovk-btn ovk-btn--quiet" data-undo>Undo</button><button type="button" class="ovk-btn ovk-btn--quiet" data-reset>Reset to default</button><button type="button" class="ovk-btn ovk-btn--quiet" data-share>Share</button><button type="button" class="ovk-btn ovk-btn--primary" data-save>Save</button><button type="button" class="ovk-btn ovk-btn--ghost" data-done>Done</button></div>
314<p class="ov-arrange-status" data-status role="status" aria-live="polite"></p>`;
315 doc.body.appendChild(host);
316 const statusEl = host.querySelector('[data-status]');
317 const setStatus = (text, tone) => { statusEl.textContent = text || ''; if (tone) statusEl.dataset.tone = tone; else delete statusEl.dataset.tone; if (text) announce(text); };
318 const live = doc.createElement('div');
319 live.setAttribute('aria-live', 'polite'); live.setAttribute('aria-atomic', 'true');
320 live.className = 'sr-only';
321 doc.body.appendChild(live);
322
323 const sel = doc.createElement('div'); sel.className = 'ov-arrange-sel'; sel.hidden = true; doc.body.appendChild(sel);
324 const handle = doc.createElement('button'); handle.type = 'button'; handle.className = 'ov-arrange-handle'; handle.setAttribute('aria-label', 'Resize'); handle.title = 'Resize'; handle.hidden = true; doc.body.appendChild(handle);
325 const styleBtn = doc.createElement('button'); styleBtn.type = 'button'; styleBtn.className = 'ov-arrange-style'; styleBtn.textContent = 'Aa'; styleBtn.setAttribute('aria-label', 'Style this part'); styleBtn.title = 'Style this part'; styleBtn.hidden = true; doc.body.appendChild(styleBtn);
326
327 const partEls = () => [...surface.querySelectorAll('[data-ov-part]')].filter((el) => catalogPart(catalog, el.dataset.ovPart));
328 const originals = new Map();
329 const innerTab = [];
330 for (const el of partEls()) originals.set(el, { style: el.getAttribute('style'), hidden: el.hidden, tabindex: el.getAttribute('tabindex'), role: el.getAttribute('role'), label: el.getAttribute('aria-label'), cls: el.className });
331 const surfaceStyle = surface.getAttribute('style');
332 const surfaceClass = surface.className;
333 const surfaceCustom = surface.getAttribute('data-ov-custom');
334 const elOf = (id) => partEls().find((el) => el.dataset.ovPart === id) || null;
335 const entry = (id) => entryOf(working, id);
336
337 function snapshot() { return clone(working); }
338 function pushUndo() { undoStack.push(snapshot()); if (undoStack.length > 40) undoStack.shift(); }
339 function markDirty() { dirty = true; paintActions(); }
340
341 function flatten(el) {
342 for (let a = el.parentElement; a && a !== surface; a = a.parentElement) a.setAttribute('data-ov-flatten', 'true');
343 }
344
345 function paintPlace(el, e) {
346 const p = e.place || {};
347 el.style.gridColumn = ''; el.style.gridRow = ''; el.style.order = '';
348 delete el.dataset.ovDock; delete el.dataset.ovOrder;
349 if (docked(p)) {
350 el.dataset.ovDock = p.dock;
351 el.dataset.ovOrder = String(p.order || 0);
352 el.style.order = String(p.order || 0);
353 } else {
354 el.style.gridColumn = `${p.x + 1} / span ${p.w}`;
355 el.style.gridRow = `${p.y + 1} / span ${p.h}`;
356 }
357 if (el.dataset.ovRoleHidden !== 'true') el.hidden = e.visible === false;
358 el.style.setProperty('--ov-part-opacity', String(e.style && typeof e.style.opacity === 'number' ? e.style.opacity : 1));
359 el.dataset.ovTint = (e.style && e.style.tint) || '';
360 el.dataset.ovShape = (e.style && e.style.shape) || '';
361 el.dataset.ovLabel = e.style && e.style.label === false ? 'false' : 'true';
362 el.dataset.ovKeys = ((e.bindings && e.bindings.keys) || []).join(' ');
363 el.dataset.ovGamepad = ((e.bindings && e.bindings.gamepad) || []).join(' ');
364 el.classList.toggle('ov-arrange-part', true);
365 }
366
367 function apply(lay, { focus = true } = {}) {
368 surface.classList.add('ov-arrange-surface');
369 surface.setAttribute('data-ov-custom', 'true');
370 surface.style.setProperty('--ov-cols', String(cols));
371 surface.style.setProperty('--ov-cell', `${cell}px`);
372 surface.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
373 surface.style.gridAutoRows = `minmax(${cell}px, auto)`;
374 const dockMode = (catalog.id || '').split('.').pop() === 'overlay';
375 if (dockMode) { surface.style.display = surface.style.display || 'block'; surface.style.position = 'relative'; }
376 for (const el of partEls()) {
377 flatten(el);
378 for (const inner of el.querySelectorAll('button, input, select, textarea, a[href], [tabindex]')) {
379 if (!innerTab.some((x) => x.el === inner)) innerTab.push({ el: inner, tabindex: inner.getAttribute('tabindex') });
380 if (inner !== el) inner.setAttribute('tabindex', '-1');
381 }
382 }
383 for (const p of (catalog.parts || [])) {
384 const el = elOf(p.id), e = entryOf(lay, p.id);
385 if (!el || !e) continue;
386 paintPlace(el, e);
387 if (focus && !dockMode) {
388 el.tabIndex = 0;
389 el.setAttribute('role', 'group');
390 el.setAttribute('aria-label', `${p.name}${p.required ? ', required' : ''}. Arrow keys move it, Shift with the arrows resizes it, Enter opens its style.`);
391 }
392 }
393 positionSelection();
394 }
395
396 function selectedEl() { return selected ? elOf(selected) : null; }
397 function positionSelection() {
398 const el = selectedEl();
399 if (!el || el.hidden || closed) { sel.hidden = true; handle.hidden = true; styleBtn.hidden = true; return; }
400 const r = el.getBoundingClientRect();
401 sel.hidden = false; sel.style.left = `${r.left - 3}px`; sel.style.top = `${r.top - 3}px`; sel.style.width = `${r.width + 6}px`; sel.style.height = `${r.height + 6}px`;
402 handle.hidden = false; handle.style.left = `${r.right - 20}px`; handle.style.top = `${r.bottom - 20}px`;
403 styleBtn.hidden = false; styleBtn.style.left = `${r.left + 6}px`; styleBtn.style.top = `${r.top + 6}px`;
404 }
405 function select(id) {
406 if (selected && elOf(selected)) elOf(selected).removeAttribute('data-ov-selected');
407 selected = id;
408 if (id && elOf(id)) elOf(id).setAttribute('data-ov-selected', 'true');
409 positionSelection();
410 }
411
412 function labelOf(id) { return partName(catalog, id); }
413 function where(d, place) {
414 if (docked(place)) return `on the ${place.dock} edge, number ${Number(place.order) + 1}`;
415 return `column ${place.x + 1}, row ${place.y + 1}, ${place.w} by ${place.h} cells`;
416 }
417
418 function paintActions() {
419 const u = host.querySelector('[data-undo]'); if (u) u.disabled = !undoStack.length;
420 const s = host.querySelector('[data-save]'); if (s) s.disabled = saving || !dirty;
421 const sh = host.querySelector('[data-share]'); if (sh) sh.disabled = saving;
422 }
423
424 function refuse(reasons) {
425 setStatus((reasons && reasons[0]) ? `Can't do that: ${reasons[0]}` : "Can't do that.", 'bad');
426 const el = selectedEl(); if (el) el.setAttribute('data-ov-refused', 'true');
427 }
428
429 function commit(candidate, { announceText, push = true } = {}) {
430 const v = validate(candidate);
431 if (!v.ok) { refuse(v.reasons); return false; }
432 if (push) pushUndo();
433 working = candidate;
434 const el = selectedEl(); if (el) el.removeAttribute('data-ov-refused');
435 apply(working);
436 markDirty();
437 if (announceText) { const id = selected; setStatus(announceText, 'ok'); if (id) announce(`${labelOf(id)} is now ${where(device, entry(id).place)}.`); }
438 return true;
439 }
440
441 function moveBy(id, dx, dy) {
442 const e = clone(entry(id)); if (!e) return;
443 const p = catalogPart(catalog, id) || { min: { w: 1, h: 1 } };
444 if (docked(e.place)) {
445 if (dx) e.place.dock = dx < 0 ? 'left' : 'right';
446 else if (dy) e.place.dock = dy < 0 ? 'top' : 'bottom';
447 } else {
448 e.place.x = Math.max(0, Math.min(cols - e.place.w, e.place.x + dx));
449 e.place.y = Math.max(0, e.place.y + dy);
450 if (e.place.x === entry(id).place.x && e.place.y === entry(id).place.y) return;
451 }
452 const cand = snapshot();
453 const at = cand.parts.findIndex((x) => x.id === id);
454 cand.parts[at] = e;
455 commit(cand, { announceText: `${labelOf(id)} moved to ${where(device, e.place)}.` });
456 }
457
458 function resizeBy(id, dw, dh) {
459 const e = clone(entry(id)); if (!e) return;
460 const p = catalogPart(catalog, id) || { min: { w: 1, h: 1 } };
461 if (docked(e.place)) {
462 e.place.w = Math.max(1, Math.min(24, e.place.w + dw));
463 e.place.h = Math.max(1, Math.min(24, e.place.h + dh));
464 } else {
465 e.place.w = Math.max(1, Math.min(cols - e.place.x, e.place.w + dw));
466 e.place.h = Math.max(1, e.place.h + dh);
467 }
468 if (e.place.w === entry(id).place.w && e.place.h === entry(id).place.h) return;
469 const cand = snapshot();
470 const at = cand.parts.findIndex((x) => x.id === id);
471 cand.parts[at] = e;
472 commit(cand, { announceText: `${labelOf(id)} is now ${where(device, e.place)}.` });
473 }
474
475 // Pointer drag: move, then the corner handle for resize. A refused cell keeps the last one that passed.
476 function cellAt(clientX, clientY) {
477 const r = surface.getBoundingClientRect();
478 const col = Math.max(0, Math.min(cols - 1, Math.floor((clientX - r.left) / (r.width / cols))));
479 const rows = (getComputedStyle(surface).gridTemplateRows || '').split(/\s+/).map(parseFloat).filter((n) => n > 0);
480 let y = 0, top = r.top;
481 for (let i = 0; i < rows.length; i++) { if (clientY < top + rows[i]) { y = i; break; } top += rows[i]; y = i + 1; }
482 return { x: col, y };
483 }
484 function startDrag(e, id, mode) {
485 if (e.button != null && e.button !== 0) return;
486 const el = elOf(id); if (!el) return;
487 if (preview) { setStatus('Keep or discard the suggestion first.'); return; }
488 e.preventDefault();
489 select(id);
490 const p = clone(entry(id).place);
491 drag = { id, mode, startX: e.clientX, startY: e.clientY, place: p, before: snapshot(), pushed: false, moved: false };
492 el.setAttribute('data-ov-dragging', 'true');
493 try { (mode === 'resize' ? handle : el).setPointerCapture(e.pointerId); } catch (_) { /* */ }
494 onPointerMove = (ev) => {
495 if (!drag) return;
496 if (Math.abs(ev.clientX - drag.startX) + Math.abs(ev.clientY - drag.startY) < 3 && !drag.moved) return;
497 drag.moved = true;
498 const cand = snapshot();
499 const at = cand.parts.findIndex((x) => x.id === drag.id);
500 if (docked(drag.place)) {
501 if (drag.mode === 'resize') {
502 const colW = surface.getBoundingClientRect().width / cols;
503 const w = Math.max(1, Math.round((ev.clientX - drag.startX) / colW) + drag.place.w);
504 const h = Math.max(1, Math.round((ev.clientY - drag.startY) / cell) + drag.place.h);
505 if (w === cand.parts[at].place.w && h === cand.parts[at].place.h) return;
506 cand.parts[at].place.w = w; cand.parts[at].place.h = h;
507 } else {
508 const r = surface.getBoundingClientRect();
509 const dx = (ev.clientX - r.left) / Math.max(1, r.width);
510 const dy = (ev.clientY - r.top) / Math.max(1, r.height);
511 const dock = Math.abs(dx - 0.5) > Math.abs(dy - 0.5) ? (dx < 0.5 ? 'left' : 'right') : (dy < 0.5 ? 'top' : 'bottom');
512 const along = dock === 'left' || dock === 'right' ? dy : dx;
513 const order = Math.max(0, Math.min(63, Math.floor(along * 8)));
514 if (dock === cand.parts[at].place.dock && order === cand.parts[at].place.order) return;
515 cand.parts[at].place.dock = dock; cand.parts[at].place.order = order;
516 }
517 const v = validate(cand);
518 if (!v.ok) { refuse(v.reasons); return; }
519 if (!drag.pushed) { undoStack.push(drag.before); drag.pushed = true; }
520 working = cand; apply(working); markDirty();
521 return;
522 }
523 const c = cellAt(ev.clientX, ev.clientY);
524 if (drag.mode === 'resize') {
525 const w = Math.max(1, c.x - drag.place.x + 1);
526 const h = Math.max(1, c.y - drag.place.y + 1);
527 if (w === cand.parts[at].place.w && h === cand.parts[at].place.h) return;
528 cand.parts[at].place.w = w; cand.parts[at].place.h = h;
529 } else {
530 const colW = surface.getBoundingClientRect().width / cols;
531 const dx = Math.round((ev.clientX - drag.startX) / colW);
532 const x = Math.max(0, Math.min(cols - drag.place.w, drag.place.x + dx));
533 const y = Math.max(0, drag.place.y + (c.y - cellAt(drag.startX, drag.startY).y));
534 if (x === cand.parts[at].place.x && y === cand.parts[at].place.y) return;
535 cand.parts[at].place.x = x; cand.parts[at].place.y = y;
536 }
537 const v = validate(cand);
538 if (!v.ok) { refuse(v.reasons); return; }
539 if (!drag.pushed) { undoStack.push(drag.before); drag.pushed = true; }
540 working = cand; apply(working); markDirty();
541 };
542 onPointerUp = () => {
543 if (!drag) return;
544 const done = drag; drag = null;
545 const el2 = elOf(done.id);
546 if (el2) { el2.removeAttribute('data-ov-dragging'); el2.removeAttribute('data-ov-refused'); }
547 if (done.moved) {
548 const place = entry(done.id).place;
549 setStatus(`${labelOf(done.id)} is now ${where(device, place)}.`, 'ok');
550 announce(`${labelOf(done.id)} is now ${where(device, place)}.`);
551 }
552 positionSelection();
553 };
554 onPointerCancel = onPointerUp;
555 }
556 let onPointerMove = null, onPointerUp = null, onPointerCancel = null;
557 const pointerMove = (e) => { if (onPointerMove) onPointerMove(e); };
558 const pointerUp = (e) => { if (onPointerUp) { const f = onPointerUp; onPointerMove = null; onPointerUp = null; onPointerCancel = null; f(e); } };
559 doc.addEventListener('pointermove', pointerMove, { passive: true });
560 doc.addEventListener('pointerup', pointerUp);
561 doc.addEventListener('pointercancel', pointerUp);
562
563 // Long press (a phone) opens the sheet; a short press selects and focuses.
564 let pressTimer = null;
565 function onPartDown(e) {
566 const el = e.target.closest && e.target.closest('[data-ov-part]');
567 if (!el || !surface.contains(el)) return;
568 if (e.target.closest('.ov-arrange-handle, .ov-arrange-style')) return;
569 const id = el.dataset.ovPart;
570 select(id);
571 clearTimeout(pressTimer);
572 pressTimer = setTimeout(() => {
573 if (drag && drag.moved) return;
574 if (drag) {
575 // A long press is the part's sheet, not a drag: let go of the gesture before opening it.
576 const held = elOf(drag.id); drag = null;
577 onPointerMove = null; onPointerUp = null; onPointerCancel = null;
578 if (held) held.removeAttribute('data-ov-dragging');
579 }
580 openSheet(id);
581 }, 520);
582 startDrag(e, id, 'move');
583 if (e.pointerType === 'touch') try { el.setPointerCapture(e.pointerId); } catch (_) { /* */ }
584 }
585 surface.addEventListener('pointerdown', onPartDown);
586 const cancelPress = () => clearTimeout(pressTimer);
587 surface.addEventListener('pointerup', cancelPress);
588 surface.addEventListener('pointercancel', cancelPress);
589
590 // Keyboard: arrows move, Shift+arrows resize, Enter opens the sheet.
591 function onKey(e) {
592 const el = e.target.closest && e.target.closest('[data-ov-part]');
593 if (!el) return;
594 const id = el.dataset.ovPart;
595 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openSheet(id); return; }
596 if (!/^Arrow(Up|Down|Left|Right)$/.test(e.key)) return;
597 e.preventDefault();
598 const dx = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0;
599 const dy = e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0;
600 if (e.shiftKey) resizeBy(id, dx, dy); else moveBy(id, dx, dy);
601 }
602 surface.addEventListener('keydown', onKey);
603
604 handle.addEventListener('pointerdown', (e) => { if (selected) startDrag(e, selected, 'resize'); });
605 styleBtn.addEventListener('click', () => { if (selected) openSheet(selected); });
606
607 // ── The part's sheet ─────────────────────────────────────────────────────────────────────
608 let sheet = null;
609 function closeSheet() { if (sheet) { sheet.remove(); sheet = null; select(selected); } }
610 function openSheet(id) {
611 closeSheet();
612 const p = catalogPart(catalog, id), e = entry(id);
613 if (!p || !e) return;
614 select(id);
615 sheet = doc.createElement('section');
616 sheet.className = 'ov-arrange-sheet';
617 sheet.setAttribute('role', 'dialog');
618 sheet.setAttribute('aria-modal', 'false');
619 sheet.setAttribute('aria-label', `${p.name}`);
620 const tints = (p.styles && p.styles.tints) || catalog.theme_tokens || ['surface'];
621 const shapes = (p.styles && p.styles.shapes) || ['square'];
622 const wp = clone(e.place);
623 sheet.innerHTML = `<button type="button" class="ovk-btn ovk-btn--quiet ov-arrange-close" data-close>Close</button>
624<h2>${esc(p.name)}</h2>
625${p.required ? '<p>The Stop has to stay visible and at its size.</p>' : ''}
626<label class="ov-arrange-row"><input type="checkbox" data-visible${e.visible === false ? '' : ' checked'}${p.required ? ' disabled' : ''}> <span>Visible</span></label>
627<div class="ov-arrange-row"><span>Size</span><div class="ov-arrange-opts">
628${(p.sizes || []).filter((s) => s.w >= p.min.w && s.h >= p.min.h).map((s) => `<label><input type="radio" name="ov-arrange-size" value="${s.w}x${s.h}"${docked(e.place) ? ' disabled' : (e.place.w === s.w && e.place.h === s.h ? ' checked' : '')}>${s.w} × ${s.h}</label>`).join('')}
629<label>Width <input type="number" min="${p.min.w}" max="24" value="${e.place.w}" data-w style="width:4.5rem"></label>
630<label>Height <input type="number" min="${p.min.h}" max="24" value="${e.place.h}" data-h style="width:4.5rem"></label>
631</div></div>
632<div class="ov-arrange-row"><span>Colour</span><div class="ov-arrange-opts">${(catalog.theme_tokens || tints).filter((t) => tints.includes(t)).map((t) => `<label><input type="radio" name="ov-arrange-tint" value="${esc(t)}"${e.style.tint === t ? ' checked' : ''}>${esc(t)}</label>`).join('')}</div></div>
633<div class="ov-arrange-row"><span>Shape</span><div class="ov-arrange-opts">${shapes.map((s) => `<label><input type="radio" name="ov-arrange-shape" value="${esc(s)}"${e.style.shape === s ? ' checked' : ''}>${esc(s)}</label>`).join('')}</div></div>
634<div class="ov-arrange-row"><span>Opacity</span><input type="range" min="0.4" max="1" step="0.05" value="${e.style.opacity}" data-opacity></div>
635<label class="ov-arrange-row"><input type="checkbox" data-label${e.style.label === false ? '' : ' checked'}> <span>Show its label</span></label>
636<div class="ov-arrange-row"><span>Keys</span><input type="text" data-keys value="${esc(((e.bindings && e.bindings.keys) || []).join(' '))}" placeholder="${esc(((p.bindings && p.bindings.keys) || []).join(' '))}" aria-label="Keys"></div>
637<div class="ov-arrange-row"><span>Gamepad</span><input type="text" data-gamepad value="${esc(((e.bindings && e.bindings.gamepad) || []).join(' '))}" placeholder="${esc(((p.bindings && p.bindings.gamepad) || []).join(' '))}" aria-label="Gamepad controls"></div>
638<p class="ov-arrange-status" data-sheet-status role="status" aria-live="polite"></p>`;
639 doc.body.appendChild(sheet);
640 const sheetStatus = sheet.querySelector('[data-sheet-status]');
641 const setSheetStatus = (t, tone) => { sheetStatus.textContent = t; sheetStatus.dataset.tone = tone || ''; announce(t); };
642 function change(fn) {
643 const cand = snapshot();
644 const at = cand.parts.findIndex((x) => x.id === id);
645 fn(cand.parts[at], p);
646 const v = validate(cand);
647 if (!v.ok) { setSheetStatus(`Can't do that: ${v.reasons[0]}`, 'bad'); return false; }
648 pushUndo(); working = cand; apply(working); markDirty(); setSheetStatus('', ''); return true;
649 }
650 sheet.querySelector('[data-close]').addEventListener('click', closeSheet);
651 sheet.querySelector('[data-visible]').addEventListener('change', (ev) => {
652 const on = ev.target.checked;
653 const ok = change((x) => { x.visible = on; });
654 if (!ok) ev.target.checked = !on;
655 else announce(`${p.name} is ${on ? 'shown' : 'hidden'}.`);
656 });
657 sheet.querySelectorAll('input[name="ov-arrange-size"]').forEach((r) => r.addEventListener('change', () => {
658 const [w, h] = r.value.split('x').map(Number);
659 if (change((x) => { x.place.w = w; x.place.h = h; })) announce(`${p.name} is now ${w} by ${h} cells.`);
660 }));
661 const wIn = sheet.querySelector('[data-w]'), hIn = sheet.querySelector('[data-h]');
662 for (const [inp, key] of [[wIn, 'w'], [hIn, 'h']]) inp.addEventListener('change', () => {
663 const n = Number(inp.value);
664 if (!Number.isInteger(n) || n < 1) { setSheetStatus(`Can't do that: ${p.name} needs at least ${key === 'w' ? p.min.w : p.min.h} cells.`, 'bad'); return; }
665 change((x) => { x.place[key] = n; });
666 });
667 sheet.querySelectorAll('input[name="ov-arrange-tint"]').forEach((r) => r.addEventListener('change', () => { if (change((x) => { x.style.tint = r.value; })) announce(`${p.name} is ${r.value}.`); }));
668 sheet.querySelectorAll('input[name="ov-arrange-shape"]').forEach((r) => r.addEventListener('change', () => { if (change((x) => { x.style.shape = r.value; })) announce(`${p.name} is ${r.value}.`); }));
669 const op = sheet.querySelector('[data-opacity]');
670 op.addEventListener('change', () => { if (change((x) => { x.style.opacity = Number(op.value); })) announce(`${p.name} is ${Math.round(op.value * 100)}% visible.`); });
671 sheet.querySelector('[data-label]').addEventListener('change', (ev) => { const on = ev.target.checked; if (change((x) => { x.style.label = on; })) announce(`${p.name}'s label is ${on ? 'shown' : 'hidden'}.`); });
672 const keys = sheet.querySelector('[data-keys]'), pad = sheet.querySelector('[data-gamepad]');
673 const parse = (v) => String(v || '').trim().split(/\s+/).filter(Boolean);
674 keys.addEventListener('change', () => { const v = parse(keys.value); if (change((x) => { x.bindings.keys = v; })) announce(`${p.name}'s keys: ${v.join(' ') || 'none'}.`); });
675 pad.addEventListener('change', () => { const v = parse(pad.value); if (change((x) => { x.bindings.gamepad = v; })) announce(`${p.name}'s gamepad: ${v.join(' ') || 'none'}.`); });
676 const first = sheet.querySelector('[data-close]'); if (first) first.focus();
677 }
678 function onDocKey(e) {
679 if (e.key === 'Escape') { if (sheet) closeSheet(); else if (preview) discardPreview(); }
680 }
681 doc.addEventListener('keydown', onDocKey);
682
683 // ── Ask OpenVibe, previewed ───────────────────────────────────────────────────────────────
684 const ask = host.querySelector('form[data-ask]');
685 const words = host.querySelector('#ov-arrange-words');
686 let previewBar = null;
687 function showPreview(sug, wordText) {
688 preview = { layout: normal(sug.layout), summary: sug.summary || '', reasons: sug.reasons || [], words: wordText, model: sug.receipt && sug.receipt.model ? String(sug.receipt.model) : '' };
689 apply(preview.layout);
690 if (previewBar) previewBar.remove();
691 previewBar = doc.createElement('div');
692 previewBar.className = 'ov-arrange-preview';
693 previewBar.setAttribute('role', 'group');
694 previewBar.setAttribute('aria-label', 'OpenVibe\'s suggestion');
695 previewBar.innerHTML = `<p><b>OpenVibe suggests:</b> ${esc(preview.summary)}${preview.model ? ` <span class="ov-arrange-by">(${esc(preview.model)})</span>` : ''}</p><button type="button" class="ovk-btn ovk-btn--primary" data-keep>Keep</button><button type="button" class="ovk-btn ovk-btn--quiet" data-discard>Discard</button>`;
696 host.querySelector('.ov-arrange-actions').before(previewBar);
697 previewBar.querySelector('[data-keep]').addEventListener('click', keepPreview);
698 previewBar.querySelector('[data-discard]').addEventListener('click', discardPreview);
699 setStatus(preview.summary || 'Previewing OpenVibe\'s suggestion.', 'ok');
700 announce(`OpenVibe suggests: ${preview.summary}. Keep or discard.`);
701 }
702 function keepPreview() {
703 if (!preview) return;
704 pushUndo(); working = preview.layout; preview = null;
705 if (previewBar) { previewBar.remove(); previewBar = null; }
706 apply(working); markDirty(); setStatus('Kept. Save it to keep it for next time.', 'ok');
707 announce('Kept the suggestion. Save it to keep it.');
708 }
709 function discardPreview() {
710 if (!preview) return;
711 preview = null;
712 if (previewBar) { previewBar.remove(); previewBar = null; }
713 apply(working); setStatus('Discarded the suggestion.');
714 announce('Discarded the suggestion.');
715 }
716 ask.addEventListener('submit', async (e) => {
717 e.preventDefault();
718 const text = words.value.trim();
719 if (!text) return;
720 if (typeof opts.onAsk !== 'function') { setStatus('Ask OpenVibe needs a signed-in person.', 'bad'); return; }
721 if (preview) discardPreview();
722 setStatus('Asking OpenVibe…');
723 ask.querySelector('button').disabled = true;
724 try {
725 const request = { catalog, layout: clone(working), words: text, device };
726 const sug = await opts.onAsk(text, request);
727 if (!sug) { setStatus("OpenVibe couldn't answer. Try again.", 'bad'); return; }
728 if (sug.ok === false || !isObj(sug.layout)) {
729 const why = (sug.reasons && sug.reasons[0]) || sug.summary || "OpenVibe couldn't do that.";
730 setStatus(`Can't do that: ${why}`, 'bad'); return;
731 }
732 const v = validate(sug.layout);
733 if (!v.ok) { setStatus(`Can't do that: ${v.reasons[0]}`, 'bad'); return; }
734 showPreview(sug, text);
735 } catch (err) {
736 setStatus(`OpenVibe could not answer: ${String(err && err.message || err)}`, 'bad');
737 } finally { ask.querySelector('button').disabled = false; }
738 });
739
740 // ── Save, Share, Reset, Undo, Done ───────────────────────────────────────────────────────
741 host.querySelector('[data-undo]').addEventListener('click', () => {
742 if (!undoStack.length) return;
743 working = undoStack.pop(); preview = null;
744 if (previewBar) { previewBar.remove(); previewBar = null; }
745 apply(working); markDirty(); setStatus('Undone.'); announce('Undone.');
746 });
747 host.querySelector('[data-reset]').addEventListener('click', async () => {
748 preview = null;
749 if (previewBar) { previewBar.remove(); previewBar = null; }
750 pushUndo(); working = defaults(); apply(working); markDirty();
751 if (typeof opts.onReset !== 'function') { setStatus('Reset to the default. Save it to keep it.', 'ok'); announce('Reset to the default.'); return; }
752 setStatus('Resetting to the default…');
753 try {
754 const r = await opts.onReset({ surface: catalog.id, device });
755 if (r && r.ok === false) { setStatus(`Couldn't reset: ${(r.reasons && r.reasons[0]) || 'try again.'}`, 'bad'); return; }
756 dirty = false; paintActions();
757 setStatus('Reset to the default.', 'ok'); announce('Reset to the default. It comes back next time.');
758 } catch (err) { setStatus(`Couldn't reset: ${String(err && err.message || err)}`, 'bad'); }
759 });
760 host.querySelector('[data-save]').addEventListener('click', async () => {
761 if (saving) return;
762 const v = validate(working);
763 if (!v.ok) { refuse(v.reasons); return; }
764 if (typeof opts.onSave !== 'function') { setStatus('Sign in to save your layout.', 'bad'); return; }
765 saving = true; paintActions(); setStatus('Saving…');
766 try {
767 const r = await opts.onSave(clone(working), { surface: catalog.id, device });
768 if (r && r.ok === false) { setStatus(`Couldn't save: ${(r.reasons && r.reasons[0]) || r.message || 'try again.'}`, 'bad'); return; }
769 dirty = false; setStatus('Saved. It follows you to your other devices.', 'ok');
770 announce('Saved.');
771 } catch (err) { setStatus(`Couldn't save: ${String(err && err.message || err)}`, 'bad'); }
772 finally { saving = false; paintActions(); }
773 });
774 host.querySelector('[data-share]').addEventListener('click', async () => {
775 if (typeof opts.onShare !== 'function') { setStatus('Sign in to share a layout.', 'bad'); return; }
776 const v = validate(working);
777 if (!v.ok) { refuse(v.reasons); return; }
778 setStatus('Making a share link…');
779 try {
780 const r = await opts.onShare(clone(working), { surface: catalog.id, device });
781 if (!r || !r.url) { setStatus(`Couldn't share: ${(r && r.reasons && r.reasons[0]) || 'try again.'}`, 'bad'); return; }
782 shareBox(r.url);
783 setStatus('Shared. Anyone with the link can use this arrangement.', 'ok');
784 } catch (err) { setStatus(`Couldn't share: ${String(err && err.message || err)}`, 'bad'); }
785 });
786 function shareBox(url) {
787 const box = doc.createElement('div');
788 box.className = 'ov-arrange-share';
789 box.innerHTML = `<label>Share this arrangement<input type="text" readonly value="${esc(url)}" aria-label="Share link"></label><button type="button" class="ovk-btn ovk-btn--quiet" data-copy>Copy link</button>`;
790 host.querySelector('.ov-arrange-status').before(box);
791 const input = box.querySelector('input');
792 box.querySelector('[data-copy]').addEventListener('click', async () => {
793 input.select();
794 try { await navigator.clipboard.writeText(url); setStatus('Link copied.', 'ok'); }
795 catch (_) { setStatus('Select the link and copy it.', 'ok'); }
796 });
797 }
798 function close({ force = false } = {}) {
799 if (closed) return;
800 if (dirty && !force && !confirmFn('Leave without saving your arrangement?')) return;
801 closed = true;
802 if (previewBar) previewBar.remove();
803 if (sheet) sheet.remove();
804 host.remove(); live.remove(); sel.remove(); handle.remove(); styleBtn.remove();
805 doc.removeEventListener('pointermove', pointerMove);
806 doc.removeEventListener('pointerup', pointerUp);
807 doc.removeEventListener('pointercancel', pointerUp);
808 doc.removeEventListener('keydown', onDocKey);
809 root.removeEventListener('resize', positionSelection);
810 doc.removeEventListener('scroll', positionSelection, true);
811 surface.removeEventListener('pointerdown', onPartDown);
812 surface.removeEventListener('keydown', onKey);
813 if (surfaceStyle == null) surface.removeAttribute('style'); else surface.setAttribute('style', surfaceStyle);
814 surface.className = surfaceClass;
815 if (surfaceCustom == null) surface.removeAttribute('data-ov-custom'); else surface.setAttribute('data-ov-custom', surfaceCustom);
816 for (const [el, o] of originals) {
817 for (const a of ['data-ov-selected', 'data-ov-dragging', 'data-ov-refused', 'data-ov-flatten', 'data-ov-tint', 'data-ov-shape', 'data-ov-label', 'data-ov-keys', 'data-ov-gamepad', 'data-ov-dock', 'data-ov-order']) el.removeAttribute(a);
818 if (o.style == null) el.removeAttribute('style'); else el.setAttribute('style', o.style);
819 el.hidden = o.hidden;
820 if (o.tabindex == null) el.removeAttribute('tabindex'); else el.setAttribute('tabindex', o.tabindex);
821 if (o.role == null) el.removeAttribute('role'); else el.setAttribute('role', o.role);
822 if (o.label == null) el.removeAttribute('aria-label'); else el.setAttribute('aria-label', o.label);
823 el.className = o.cls;
824 }
825 for (const x of innerTab) { if (x.tabindex == null) x.el.removeAttribute('tabindex'); else x.el.setAttribute('tabindex', x.tabindex); }
826 for (const a of surface.querySelectorAll('[data-ov-flatten]')) a.removeAttribute('data-ov-flatten');
827 if (typeof opts.onApply === 'function') try { opts.onApply(clone(working)); } catch (_) { /* */ }
828 if (typeof opts.onClose === 'function') try { opts.onClose(clone(working), { dirty }); } catch (_) { /* */ }
829 }
830 host.querySelector('[data-done]').addEventListener('click', () => close());
831
832 surface.classList.add('ov-arrange-editing');
833 apply(working);
834 paintActions();
835 positionSelection();
836 root.addEventListener('resize', positionSelection, { passive: true });
837 doc.addEventListener('scroll', positionSelection, { passive: true, capture: true });
838 setStatus('Arrange: drag a part, or use the arrow keys. Shift with the arrows resizes.');
839 return { close, apply: (lay) => { working = normal(lay); apply(working); }, layout: () => clone(working), destroy: () => close({ force: true }), get core() { return core; } };
840 }
841
842 root.OVArrange = { open, validate: fallbackValidate, defaults: fallbackDefaults, _fallback: { validate: fallbackValidate, defaults: fallbackDefaults }, VERSION };
843 loadCore().then((c) => { if (c) { fallbackValidate.sdk = c; root.OVArrange.core = c; } }).catch(() => { /* the fallback rules stay */ });
844})(typeof window !== 'undefined' ? window : globalThis);
845