openvibers/openvibe.design Commits
Components: the floating panel, with a live drag/tuck/size demo (shared 4.208.0) (#272)
Changes
@@@ -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",@@@ -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"@@@ -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; } }nnew file mode 100644@@@ -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());++ });++ }++})();@@@ -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) {@@@ -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,});}@@@ -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;@@@ -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 () => {
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"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-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-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",
"pg": "^8.23.1",
"subset-font": "^2.9.0",
"subset-font": "^2.9.0",
"wawoff2": "^2.0.1"
"wawoff2": "^2.0.1"
@@ -1476,9 +1476,9 @@
@@ -1476,9 +1476,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"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-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-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",
"pg": "^8.23.1",
"subset-font": "^2.9.0",
"subset-font": "^2.9.0",
"wawoff2": "^2.0.1"
"wawoff2": "^2.0.1"
@@ -91,3 +91,25 @@
@@ -91,3 +91,25 @@
.crl-ex-t { margin: 12px 0 6px; font-weight: 700; }
.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-card .cr-card-link span { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.crl-build { margin: 4px 0 22px; }
.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; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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());
+ });
+ }
+})();
@@ -392,10 +392,56 @@ function buildHomeEdit() {
@@ -392,10 +392,56 @@ function buildHomeEdit() {
return { name: part.id, source: `${HOME_MODULE}#home`, definition, markup };
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() {
function buildEntries() {
const entries = PARTS.map(buildPart);
const entries = PARTS.map(buildPart);
- entries.push(buildHomeGrid(), buildHomeEdit());
+ entries.push(buildHomeGrid(), buildHomeEdit(), buildFloat());
for (const entry of entries) {
for (const entry of entries) {
const check = contracts.validate('design.component', entry.definition);
const check = contracts.validate('design.component', entry.definition);
if (!check.valid) {
if (!check.valid) {
@@ -10,6 +10,8 @@
@@ -10,6 +10,8 @@
*/
*/
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const { layout, esc } = require('../render/layout');
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. */
/** What each component is for. A name not listed here lands in Other, so a new kit part is never dropped. */
const GROUPS = [
const GROUPS = [
@@ -18,7 +20,7 @@ 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']],
['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']],
['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']],
['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']],
['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']],
['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']],
['home', 'Home', 'The menu\'s Home screen.', ['home-grid', 'home-edit']],
@@ -85,6 +87,36 @@ function examples(entry) {
@@ -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>`;
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. */
/** /components/<name>: one entry, laid out like the designed interface pages. */
function componentEntryPage(entry) {
function componentEntryPage(entry) {
const c = entry.definition || {};
const c = entry.definition || {};
@@ -95,6 +127,7 @@ function componentEntryPage(entry) {
@@ -95,6 +127,7 @@ function componentEntryPage(entry) {
const body = `<div class="ds-wrap tk cr crl">
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>
<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>
<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>` : ''}
${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">
<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>'}
<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)}
@@ -116,6 +149,8 @@ ${examples(entry)}
path: `/components/${entry.name}`,
path: `/components/${entry.name}`,
active: '/components',
active: '/components',
styles: ['css/component-registry.css'],
styles: ['css/component-registry.css'],
+ shared: entry.name === 'float' ? ['ov-float.js'] : [],
+ scripts: entry.name === 'float' ? ['js/ov-float-demo.js'] : [],
body,
body,
});
});
}
}
@@ -86,7 +86,10 @@ const { PUBLIC_DIR } = require('../server/assets');
@@ -86,7 +86,10 @@ const { PUBLIC_DIR } = require('../server/assets');
assert.ok(paths.length >= 40, `${paths.length} plates named`);
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 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']) {
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`);
assert.ok(fs.existsSync(path.join(PUBLIC_DIR, 'changes', v)), `${v} folder`);
}
}
const { screenshots } = changes;
const { screenshots } = changes;
@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/boot');
const rows = await t.ctx.registry.list();
const rows = await t.ctx.registry.list();
const names = new Set(rows.map((row) => row.name));
const names = new Set(rows.map((row) => row.name));
assert.ok(rows.length >= 50, `${rows.length} entries imported`);
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`);
assert.ok(names.has(name), `${name} is in the registry`);
}
}
for (const row of rows) {
for (const row of rows) {
@@ -73,6 +73,8 @@ const { boot, check, done } = require('./helpers/boot');
@@ -73,6 +73,8 @@ const { boot, check, done } = require('./helpers/boot');
assert.match(page.text, /component-registry\.css/);
assert.match(page.text, /component-registry\.css/);
assert.match(page.text, /id="crl-layout"/, 'the layout group');
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-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\/page-head"><b>Page head<\/b>/);
assert.match(page.text, /href="\/components\/home-grid"><b>Home grid<\/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');
assert.match(page.text, /class="crl-jump"/, 'a chip row jumps between groups');
@@ -86,6 +88,14 @@ const { boot, check, done } = require('./helpers/boot');
@@ -86,6 +88,14 @@ const { boot, check, done } = require('./helpers/boot');
// The earlier kit-document and designed interface pages keep their own routes.
// 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?c=card')).text, /One design system/);
assert.match((await t.get('/components/sheet')).text, /data-ov-component="sheet"/);
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 () => {
await check('an import adds new entries and leaves an edited entry alone unless forced', async () => {