openvibers/openvibe.design Commits

Components: the floating panel, with a live drag/tuck/size demo (shared 4.208.0) (#272)

OP OpenVibers committed 2 days ago · bbe92b049c · 8 files, +158 −10

* Components: the floating panel, with a live drag/tuck/size demo and the PiP rule

* Pin openvibe-shared v4.208.0 for OVFloat

* Merge remote-tracking branch 'origin/main' into ov-float-design

# Conflicts:
#	package-lock.json
#	package.json

* Changes test: an older release names its plates on its own page

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex a123ae7..2959b7d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -19,7 +19,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.208.0",
"pg": "^8.23.1",
"subset-font": "^2.9.0",
"wawoff2": "^2.0.1"
@@@ -1476,9 +1476,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.206.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
-- "integrity": "sha512-mvkdGKmdYXDDV380k0DvdSVmBkg5tjYrw1La9BwNwp87NnR3mNlUCDDgAZTbyP1KyVGuK/zLodiM7bNV61zXfg==",
++ "version": "4.208.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.208.0",
++ "integrity": "sha512-WSn8VAjkW6AcwHAnZWb88pi+DTnztDjRgucNFAvxIY1HzA5sFlFJAeW992HJQtBo3iKhK3AtGu556jjVd5kqkQ==",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex cbe79c4..f0fefc5 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.208.0",
"pg": "^8.23.1",
"subset-font": "^2.9.0",
"wawoff2": "^2.0.1"
ddiff --git a/public/css/component-registry.css b/public/css/component-registry.css
iindex 61b7447..79e51b3 100644
---- a/public/css/component-registry.css
++++ b/public/css/component-registry.css
@@@ -91,3 +91,25 @@
.crl-ex-t { margin: 12px 0 6px; font-weight: 700; }
.crl-card .cr-card-link span { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.crl-build { margin: 4px 0 22px; }
++
++/* The floating panel's live demo (/components/float, 4.208.0): a player-shaped frame the box docks in, and the same
++ box over the page when it is dragged out. The engine injects its behaviour CSS; this is the look of the demo only. */
++.crf-stage { position: relative; height: clamp(280px, 48vh, 440px); margin: 4px 0 0; border: 1px solid var(--ovk-line); border-radius: 18px; overflow: hidden;
++ background: radial-gradient(ellipse at 30% 20%, #1d2c44, #080c14 70%); }
++.crf-stage::before { content: "PLAYER"; position: absolute; left: 14px; top: 12px; color: rgba(255, 255, 255, .35); font: 700 .68rem/1 system-ui, sans-serif; letter-spacing: .14em; }
++.crf-box { display: flex; flex-direction: column; justify-content: flex-end; gap: 1px; padding: 10px 12px; overflow: hidden; cursor: grab;
++ color: #fff; border: 1px solid rgba(255, 255, 255, .18); border-radius: 14px;
++ background: linear-gradient(180deg, rgba(8, 12, 20, .12), rgba(8, 12, 20, .82)), #24405f;
++ box-shadow: 0 10px 28px -8px rgba(0, 0, 0, .7); }
++.crf-box:focus-visible { outline: 2px solid var(--ovk-accent, var(--accent)); outline-offset: 2px; }
++.crf-box b { font-size: .82rem; line-height: 1.2; }
++.crf-box small { color: rgba(255, 255, 255, .66); font-size: .68rem; }
++.crf-tag { align-self: flex-start; margin-bottom: 3px; padding: 2px 6px; border-radius: 999px; background: #e5484d; color: #fff; font: 800 .58rem/1.6 system-ui, sans-serif; letter-spacing: .06em; }
++.crf-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; }
++.crf-state { margin-left: auto; color: var(--ovk-muted); font-size: .82rem; font-variant-numeric: tabular-nums; }
++.crf-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; margin: 14px 0 0; }
++.crf-options div { padding: 10px 12px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); }
++.crf-options dt { margin-bottom: 4px; font-weight: 700; }
++.crf-options dd { margin: 0; color: var(--ovk-muted); font-size: .86rem; line-height: 1.45; }
++.crf kbd { padding: 1px 5px; border: 1px solid var(--ovk-line); border-radius: 5px; background: var(--ovk-panel-2); font-size: .78rem; }
++@media (max-width: 640px) { .crf-stage { height: 300px; } .crf-state { margin-left: 0; } }
ddiff --git a/public/js/ov-float-demo.js b/public/js/ov-float-demo.js
nnew file mode 100644
iindex 0000000..ad4a5b9
---- /dev/null
++++ b/public/js/ov-float-demo.js
@@@ -0,0 +1,32 @@
++/* ov-float-demo.js — the live demo on /components/float: the shared engine moves the box, the buttons say what it can do. */
++(function () {
++ 'use strict';
++ const frame = document.getElementById('crf-frame');
++ const box = document.getElementById('crf-box');
++ const out = document.getElementById('crf-state');
++ if (!frame || !box || !window.OVFloat) return;
++
++ const KEY = 'design:float-demo';
++ const DEFAULTS = { x: 0.62, y: 0.2, w: 0.3, mode: 'dock' };
++ const state = (g) => `${g.mode === 'float' ? 'floating' : 'docked'}${g.tuck ? ` · tucked ${g.tuck}` : ''} · ${Math.round(g.w * 100)}%`;
++ const f = window.OVFloat.attach(box, {
++ frame, key: KEY, defaults: DEFAULTS, aspect: 16 / 9, minPx: 150, store: null,
++ onChange(g) { if (out) out.textContent = state(g); },
++ });
++ if (out) out.textContent = state(f.geom());
++
++ const acts = {
++ tuck: () => f.tuck(),
++ untuck: () => f.untuck(),
++ mode: () => f.toggleMode(),
++ size: () => f.toggleSize(),
++ reset: () => { try { localStorage.removeItem(KEY); } catch { /* */ } f.reset(); },
++ };
++ for (const button of frame.ownerDocument.querySelectorAll('[data-crf]')) {
++ button.addEventListener('click', () => {
++ const act = acts[button.dataset.crf];
++ if (act) act();
++ if (out) out.textContent = state(f.geom());
++ });
++ }
++})();
ddiff --git a/server/components/import.js b/server/components/import.js
iindex 66b733c..b74bdb3 100644
---- a/server/components/import.js
++++ b/server/components/import.js
@@@ -392,10 +392,56 @@ function buildHomeEdit() {
return { name: part.id, source: `${HOME_MODULE}#home`, definition, markup };
}
--/** Every entry: the /kit builders, then the two Home parts that are not kit builders. */
++/** The floating panel: OVFloat, the move engine behind a PiP or a panel (openvibe-shared/ov-float.js, 4.208.0). */
++function buildFloat() {
++ const part = {
++ id: 'float', fn: 'attach', name: 'Floating panel', group: 'layout',
++ line: 'A box that can be dragged anywhere: docked in its frame or floating over the page, tucked into a side, sized and kept where you left it.',
++ code: `OVFloat.attach(el, { frame, key, defaults, aspect, minPx, onChange })`,
++ };
++ const definition = {
++ schema: 'design.component@1',
++ name: part.id,
++ title: part.name,
++ summary: text(part.line, MAX.summary),
++ anatomy: [
++ { part: 'box', role: 'The thing that moves: any element, with its own look and children.' },
++ { part: 'frame', role: 'The element it docks in and measures its fractions against; without one it only floats over the page.' },
++ { part: 'tab', role: 'What stays on screen when the box is tucked into a side; a press brings it back.' },
++ { part: 'grip', role: 'The corner that sizes it, and two fingers that pinch it; a double tap toggles small and large.' },
++ ],
++ props: {
++ frame: 'element?', key: 'string?', defaults: 'object?', aspect: 'number?', minPx: 'number?', onChange: 'function?',
++ handle: 'element?', tab: 'boolean?', resize: 'boolean?', store: 'object?', legacyKeys: 'list?', viewport: 'string?',
++ },
++ states: ['docked', 'floating', 'lifted', 'dragging', 'tucked-left', 'tucked-right', 'resizing', 'active'],
++ tokens: ['--ov-float-tab', '--ov-ease-spring', '--ov-dur-3', 'color.accent.strong'],
++ behaviors: { keyboardAware: true, outsideTapCloses: false, scrollLock: false, reducedMotion: 'No spring, lift or scrub; it is simply where it goes.' },
++ accessibility: {
++ name: 'A floating panel; its tab says Bring it back',
++ target: 'At least 44 × 44 px for the tab and the corner grip',
++ focus: 'With focus: arrows move, + and − size, T tucks, F docks or floats, Home puts it back where it started.',
++ },
++ platforms: {
++ web: { module: 'openvibe-shared/ov-float', markup: part.code, demo: '/components/float' },
++ android: {
++ toolkit: 'Jetpack Compose', material3: 'Surface',
++ specification: 'A draggable Surface in a Box: a remembered fraction of its frame, a spring landing, edge snapping, a tucked tab, pinch and corner size, and the place kept per key.',
++ },
++ ios: {
++ toolkit: 'SwiftUI',
++ specification: 'A draggable view in a GeometryReader with a spring, edge snapping, a tucked tab, pinch and corner size, and a fraction place kept per key.',
++ },
++ },
++ examples: [{ title: 'Default', props: { key: 'design:float-demo', defaults: { x: 0.62, y: 0.2, w: 0.3 }, aspect: 16 / 9, minPx: 150 } }],
++ };
++ return { name: part.id, source: 'openvibe-shared/ov-float', definition, markup: `<div class="ov-float" data-ov-float-demo>Drag me anywhere</div>` };
++}
++
++/** Every entry: the /kit builders, the two Home parts that are not kit builders, and the float engine. */
function buildEntries() {
const entries = PARTS.map(buildPart);
-- entries.push(buildHomeGrid(), buildHomeEdit());
++ entries.push(buildHomeGrid(), buildHomeEdit(), buildFloat());
for (const entry of entries) {
const check = contracts.validate('design.component', entry.definition);
if (!check.valid) {
ddiff --git a/server/views/components-list.js b/server/views/components-list.js
iindex 55329fa..c46c1c2 100644
---- a/server/views/components-list.js
++++ b/server/views/components-list.js
@@@ -10,6 +10,8 @@
*/
const kit = require('openvibe-shared/kit');
const { layout, esc } = require('../render/layout');
++const { RULES } = require('../rules/register');
++const FLOAT_RULE = RULES.find((r) => r.id === 'pip-goes-anywhere');
/** What each component is for. A name not listed here lands in Other, so a new kit part is never dropped. */
const GROUPS = [
@@@ -18,7 +20,7 @@ const GROUPS = [
['layout', 'Page and layout', 'The frame of a page: heads, sections, sidebars, rails and tabs.', ['page', 'page-head', 'head', 'section', 'fold', 'sidebar', 'rail', 'tabs', 'steps']],
['content', 'Lists and content', 'Rows, cards and what shows while there is nothing yet.', ['list', 'row', 'card', 'empty', 'snippet', 'skeleton']],
['choosing', 'Choosing and settings', 'Switches, ranges, selects, segmented controls, chips and saving.', ['switch-row', 'range-row', 'select-row', 'seg', 'segmented', 'chips', 'find', 'save-bar', 'drop']],
-- ['surfaces', 'Surfaces', 'What opens over the page.', ['sheet', 'dock']],
++ ['surfaces', 'Surfaces', 'What opens over the page.', ['sheet', 'dock', 'float']],
['data', 'Numbers and data', 'Stats, tables, charts and receipts.', ['stats', 'num', 'table', 'chart', 'rank', 'meter', 'receipt']],
['tools', 'Tools', 'The parts of an OpenVibe tool page.', ['tool', 'tool-head', 'tool-input', 'answer', 'family', 'tries']],
['home', 'Home', 'The menu\'s Home screen.', ['home-grid', 'home-edit']],
@@@ -85,6 +87,36 @@ function examples(entry) {
return `<section class="crl-sec" aria-labelledby="crl-ex-h"><h2 id="crl-ex-h">Markup</h2>${withMarkup.map((x, i) => `${x.title ? `<p class="crl-ex-t">${esc(x.title)}</p>` : ''}${kit.snippet({ code: String(x.markup), id: `crl-ex-${i}` })}`).join('')}</section>`;
}
++/** The one component whose page needs a live demo: the move engine is behaviour, not markup. */
++function floatDemo() {
++ const href = '/rules#pip-goes-anywhere';
++ const rule = FLOAT_RULE ? `Rule ${FLOAT_RULE.n}: ${esc(FLOAT_RULE.rule)}` : 'A picture-in-picture goes anywhere, tucks aside and waits with the mark';
++ return `<section class="crl-sec crf" id="crf" aria-labelledby="crf-h">
++<h2 id="crf-h">Live demo</h2>
++<p>Drag the box by its body; let go over the frame and it docks, anywhere else it floats. Throw it past a side to tuck it, drag its corner to size it, or use the keys with it focused: arrows move, <kbd>+</kbd> and <kbd>−</kbd> size, <kbd>T</kbd> tucks, <kbd>F</kbd> docks or floats, <kbd>Home</kbd> puts it back.</p>
++<div class="crf-stage" id="crf-frame" aria-label="A player frame; the box docks in it or floats over the page">
++ <div class="crf-box" id="crf-box" tabindex="0" aria-label="A floating box: drag it anywhere">
++ <span class="crf-tag">LIVE</span><b>Picture-in-picture</b><small>Drag me anywhere</small>
++ </div>
++</div>
++<div class="crf-bar" role="group" aria-label="The floating panel's actions">
++ <button type="button" class="ovk-btn ovk-btn--quiet" data-crf="tuck">Tuck</button>
++ <button type="button" class="ovk-btn ovk-btn--quiet" data-crf="untuck">Bring it back</button>
++ <button type="button" class="ovk-btn ovk-btn--quiet" data-crf="mode">Dock / float</button>
++ <button type="button" class="ovk-btn ovk-btn--quiet" data-crf="size">Small / large</button>
++ <button type="button" class="ovk-btn ovk-btn--quiet" data-crf="reset">Home</button>
++ <output id="crf-state" class="crf-state" aria-live="polite">docked</output>
++</div>
++<dl class="crf-options">
++ <div><dt><code>frame</code></dt><dd>The element it docks in and measures its fractions against. Without one it only floats over the page.</dd></div>
++ <div><dt><code>key</code> and <code>defaults</code></dt><dd>The place kept in this browser, and the streamer's starting x, y and width as fractions of the frame.</dd></div>
++ <div><dt><code>aspect</code> and <code>minPx</code></dt><dd>The source's own shape, and the smallest 16:9 width on a phone-sized frame (150 px for the PiP).</dd></div>
++ <div><dt><code>onChange</code></dt><dd>Every saved place, with the geometry and the instance. <code>OVFloat.store</code> is the account plug; <code>OVFloat.layout()</code> and <code>ov:float</code> say where everything is.</dd></div>
++</dl>
++<p class="ds-fine"><a href="${href}">${rule}</a>. The same engine moves Live's picture-in-picture and a panel window; on a site with <code>GET/PATCH /api/me/layout</code> the place also follows the person to another device.</p>
++</section>`;
++}
++
/** /components/<name>: one entry, laid out like the designed interface pages. */
function componentEntryPage(entry) {
const c = entry.definition || {};
@@@ -95,6 +127,7 @@ function componentEntryPage(entry) {
const body = `<div class="ds-wrap tk cr crl">
<header class="ds-page-h"><p class="ds-kicker"><a href="/components${group ? `#crl-${group[0]}` : ''}">${esc(group ? group[1] : 'Component registry')}</a></p><h1>${esc(c.title || entry.name)}</h1><p class="ds-lede">${esc(c.summary || '')}</p>
<p class="crl-where">${web.module ? `<code>${esc(web.module)}</code>` : ''}<a href="/api/v1/components/${encodeURIComponent(entry.name)}">JSON</a></p></header>
++${entry.name === 'float' ? floatDemo() : ''}
${web.markup ? `<section class="crl-sec crl-build" aria-labelledby="crl-build-h"><h2 id="crl-build-h">Build it</h2>${kit.snippet({ code: String(web.markup), id: 'crl-build' })}</section>` : ''}
<div class="cr-layout">
<section class="crl-sec" aria-labelledby="crl-props-h"><h2 id="crl-props-h">Props</h2>${props.length ? `<dl class="cr-props">${props.map(([k, t]) => `<div><dt><code>${esc(k)}</code></dt><dd>${esc(t)}</dd></div>`).join('')}</dl>` : '<p class="crl-none">No props.</p>'}
@@@ -116,6 +149,8 @@ ${examples(entry)}
path: `/components/${entry.name}`,
active: '/components',
styles: ['css/component-registry.css'],
++ shared: entry.name === 'float' ? ['ov-float.js'] : [],
++ scripts: entry.name === 'float' ? ['js/ov-float-demo.js'] : [],
body,
});
}
ddiff --git a/test/changes.test.js b/test/changes.test.js
iindex b1d12f7..fbe9c9b 100644
---- a/test/changes.test.js
++++ b/test/changes.test.js
@@@ -86,7 +86,10 @@ const { PUBLIC_DIR } = require('../server/assets');
assert.ok(paths.length >= 40, `${paths.length} plates named`);
for (const p of paths) assert.ok(fs.existsSync(path.join(PUBLIC_DIR, p)), p);
for (const v of ['4.195.0', '4.196.0', '4.197.0', '4.198.0', '4.199.0', '4.200.0']) {
-- assert.ok(paths.some((p) => p.startsWith(`/changes/${v}/`)), `${v} has plates`);
++ // /changes keeps the newest releases as cards; an older one still names its plates on its own page.
++ const one = await t.get(`/changes/${v}`);
++ assert.strictEqual(one.status, 200);
++ assert.match(one.text, new RegExp(`/changes/${v.replace(/\./g, '\\.')}/[a-z0-9-]+-(390|1280)-(dark|light)\\.webp`), `${v} has plates`);
assert.ok(fs.existsSync(path.join(PUBLIC_DIR, 'changes', v)), `${v} folder`);
}
const { screenshots } = changes;
ddiff --git a/test/component-registry.test.js b/test/component-registry.test.js
iindex ee5421e..63bb7cb 100644
---- a/test/component-registry.test.js
++++ b/test/component-registry.test.js
@@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/boot');
const rows = await t.ctx.registry.list();
const names = new Set(rows.map((row) => row.name));
assert.ok(rows.length >= 50, `${rows.length} entries imported`);
-- for (const name of ['button', 'card', 'page-head', 'head', 'tabs', 'sheet', 'seg', 'segmented', 'empty', 'switch-row', 'home-grid', 'home-edit']) {
++ for (const name of ['button', 'card', 'page-head', 'head', 'tabs', 'sheet', 'seg', 'segmented', 'empty', 'switch-row', 'home-grid', 'home-edit', 'float']) {
assert.ok(names.has(name), `${name} is in the registry`);
}
for (const row of rows) {
@@@ -73,6 +73,8 @@ const { boot, check, done } = require('./helpers/boot');
assert.match(page.text, /component-registry\.css/);
assert.match(page.text, /id="crl-layout"/, 'the layout group');
assert.match(page.text, /id="crl-home"/, 'the Home group');
++ assert.match(page.text, /id="crl-surfaces"/, 'the surfaces group');
++ assert.match(page.text, /href="\/components\/float"><b>Floating panel<\/b>/, 'the move engine is in the registry');
assert.match(page.text, /href="\/components\/page-head"><b>Page head<\/b>/);
assert.match(page.text, /href="\/components\/home-grid"><b>Home grid<\/b>/);
assert.match(page.text, /class="crl-jump"/, 'a chip row jumps between groups');
@@@ -86,6 +88,14 @@ const { boot, check, done } = require('./helpers/boot');
// The earlier kit-document and designed interface pages keep their own routes.
assert.match((await t.get('/components?c=card')).text, /One design system/);
assert.match((await t.get('/components/sheet')).text, /data-ov-component="sheet"/);
++
++ // The one behaviour page carries a live demo and links the rule it comes from.
++ const float = await t.get('/components/float');
++ assert.strictEqual(float.status, 200);
++ assert.match(float.text, /id="crf-box"/, 'a box to drag');
++ assert.match(float.text, /id="crf-frame"/, 'a frame to dock in');
++ assert.match(float.text, /js\/ov-float-demo\.js/, 'the demo script');
++ assert.match(float.text, /href="\/rules#pip-goes-anywhere"/, 'the rule is linked');
});
await check('an import adds new entries and leaves an edited entry alone unless forced', async () => {