openvibers/openvibe.design Commits
Show progress and uploader in Design (#207)
Changes
nnew file mode 100644@@@ -0,0 +1,11 @@++.uk-wrap { max-width: 1050px; margin: 0 auto; padding: 20px 20px 70px; }++.uk-wrap header { max-width: 760px; margin: 14px 0 30px; }++.uk-wrap h1 { font-size: clamp(2rem, 5vw, 3rem); line-height: 1.1; }++.uk-wrap p { line-height: 1.6; }++.uk-kicker { color: var(--ov-accent, #9b7bff); font-weight: 800; text-transform: uppercase; letter-spacing: .12em; }++.uk-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr)); gap: 15px; }++.uk-card { min-width: 0; margin: 0 0 16px; padding: 20px; border: 1px solid var(--ov-line, #596179); border-radius: 16px; background: var(--ov-surface, rgba(100,110,145,.08)); }++.uk-card h2 { margin: 0 0 6px; font-size: 1.2rem; }++.uk-card > p { margin: 0 0 16px; }++.uk-card code { overflow-wrap: anywhere; }++.uk-note { margin-top: 12px !important; opacity: .7; font-size: .9rem; }nnew file mode 100644@@@ -0,0 +1,16 @@++(function () {++ 'use strict';++ if (!window.OVUpload) return;++ document.querySelectorAll('[data-demo-state]').forEach(function (el) {++ var state = el.dataset.demoState;++ var meter = OVUpload.progress(el, { onRetry: function () { meter.update({ state: 'sending', done: 12 * 1024 * 1024, total: 32 * 1024 * 1024 }); } });++ meter.update({ state: state, done: state === 'done' || state === 'processing' ? 32 * 1024 * 1024 : state === 'waiting' ? 0 : 12 * 1024 * 1024,++ total: 32 * 1024 * 1024, message: state === 'failed' ? 'The connection timed out.' : state === 'processing' ? 'Making thumbnails' : '' });++ if (state === 'sending') {++ var bytes = 12 * 1024 * 1024;++ setInterval(function () { bytes = bytes >= 30 * 1024 * 1024 ? 12 * 1024 * 1024 : bytes + 1024 * 1024; meter.update({ state: 'sending', done: bytes }); }, 1100);++ }++ });++ var demo = document.querySelector('[data-demo-picker]');++ if (demo) OVUpload.picker(demo, { label: 'Choose a file', multiple: true });++})();@@@ -38,6 +38,8 @@ function publicPages(themes = []) {{ path: '/tokens', changefreq: 'weekly', priority: 0.8, title: 'Your theme, as code', text: 'Any OpenVibe theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark together.' },{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },{ path: '/loading', changefreq: 'monthly', priority: 0.6, title: 'Loading: nothing spins', text: 'How OpenVibe waits: placeholders shaped like the content (kit.skeleton), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules.' },++ { path: '/progress', changefreq: 'monthly', priority: 0.6, title: 'Upload progress', text: 'The shared ov-progress component: transfer, processing, pause, retry and completion, with tokens and code.' },++ { path: '/uploader', changefreq: 'monthly', priority: 0.6, title: 'Uploader', text: 'The shared file picker and upload queue for OpenVibe sites, with preview, Remove and code.' },{ path: '/forms', changefreq: 'monthly', priority: 0.6, title: 'Forms: OpenVibe’s own controls', text: 'OpenVibe’s select and form controls on every site: a menu on a computer, a sheet on a phone, choices that say whether they are ready, switches, fields and choice cards (openvibe-shared ov-forms).' },{ path: '/native', changefreq: 'monthly', priority: 0.6, title: 'Apps for Android and iOS', text: 'How an OpenVibe page becomes a real Android or iOS screen: one set of tokens, components, patterns and icons, three size classes, what each platform does its own way, and the road from the web to apps.' },{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },@@@ -104,6 +106,8 @@ function createDiscoveryRoutes(ctx) {{ title: 'Open font library', url: abs('/fonts'), note: 'Open licence typefaces with live previews, every style, licence, source, CSS and download. Search and filters at /fonts; JSON at GET /api/v1/fonts and /api/v1/fonts/<family>; MCP tools list and get fonts.' },{ title: 'A free Font Awesome alternative', url: abs('/icons/font-awesome-alternative'), note: 'one script keeps Font Awesome markup, classes, animations and data-fa-transform, and shows the OpenVibe glyphs', html: true },{ title: 'Loading', url: abs('/loading'), note: 'how OpenVibe waits: placeholders shaped like the content (kit.skeleton: card, row, tile, chip, line, chat, stat), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules' },++ { title: 'Progress', url: abs('/progress'), note: 'ov-progress in all six states, with time remaining, reduced motion, tokens and code' },++ { title: 'Uploader', url: abs('/uploader'), note: 'the shared drop card, file preview, queue and total progress, with code' },{ title: 'Forms', url: abs('/forms'), note: 'OpenVibe’s own select (a menu on a computer, a sheet on a phone; options with state, badge and a setup link), switches, fields and choice cards: ov-forms.js and ov-forms.css' },{ title: 'The OpenVibe mark', url: abs('/brand'), note: 'the logo, its states and motion, colours, the brand face, SVG files and code (openvibe-shared/mark, ov-mark.js)' },{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },@@@ -90,6 +90,8 @@ function createPageRoutes(ctx) {r.get('/motion', (req, res) => page(res, 200, motionView.motionPage({ base: layoutBase() }), req.viewer));r.get('/native', (req, res) => page(res, 200, require('../views/native').nativePage(), req.viewer));r.get('/loading', (req, res) => page(res, 200, require('../views/loading').loadingPage({ base: layoutBase() }), req.viewer));++ r.get('/progress', (req, res) => page(res, 200, require('../views/upload-kit').progressPage(), req.viewer));++ r.get('/uploader', (req, res) => page(res, 200, require('../views/upload-kit').uploaderPage(), req.viewer));// ── OpenVibe's own form controls (openvibe-shared ov-forms, 2026-11-03) ──r.get('/forms', (req, res) => page(res, 200, require('../views/forms').formsPage({ base: layoutBase() }), req.viewer));r.get('/patterns', (req, res) => page(res, 200, patternsView.index(), req.viewer));@@@ -31,6 +31,8 @@ const NAV = [{ label: 'The mark', href: '/brand', icon: 'fa-star', more: true },{ label: 'Motion', href: '/motion', icon: 'fa-wand-magic-sparkles', more: true },{ label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },++ { label: 'Progress', href: '/progress', icon: 'fa-bars-progress', more: true },++ { label: 'Uploader', href: '/uploader', icon: 'fa-upload', more: true },{ label: 'Forms', href: '/forms', icon: 'fa-list-check', more: true },{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },{ label: 'Fonts', href: '/fonts', icon: 'fa-font', more: true },nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++const kit = require('openvibe-shared/kit');++const { layout } = require('../render/layout');++++const styles = ['ov-upload.css'];++const shared = ['ov-upload.js'];++const scripts = ['js/upload-kit.js'];++const states = [++ ['waiting', 'Waiting', 'A file is ready to send.'],++ ['sending', 'Sending', 'Bytes, speed and time left update while the file travels.'],++ ['paused', 'Paused', 'The connection dropped; the upload resumes when it returns.'],++ ['processing', 'Processing', 'The server is making the file playable.'],++ ['done', 'Done', 'The check draws when the upload finishes.'],++ ['failed', 'Failed', 'A plain reason and Retry appear together.'],++];++++function progressPage() {++ const body = `<article class="uk-wrap"><header><p class="uk-kicker">Components</p><h1>Progress that tells you what is happening</h1>++<p>One <code>ov-progress</code> follows a file from selection through transfer and processing. It shows bytes, speed and an estimated time, then a drawn check when the file is ready.</p>++<p><a href="/uploader">See the uploader →</a></p></header>++<div class="uk-grid">${states.map(([id, name, explanation]) => `<section class="uk-card"><h2>${name}</h2><p>${explanation}</p><ov-progress data-demo-state="${id}"></ov-progress></section>`).join('')}</div>++<section class="uk-card"><h2>Use it</h2>${kit.snippet({ code: `<link rel="stylesheet" href="/shared/ov-upload.css">\n<script src="/shared/ov-upload.js" defer></script>\n<ov-progress id="transfer"></ov-progress>\n<script>\n const p = OVUpload.progress(document.querySelector('#transfer'), { onRetry: retry });\n p.update({ state: 'sending', done: 8388608, total: 33554432 });\n p.update({ state: 'processing', message: 'Making thumbnails' });\n p.update({ state: 'done' });\n</script>`, id: 'uk-progress-code', label: 'Copy progress code' })}++<p>States: waiting, sending, paused, processing, done and failed. The server can name each processing step through <code>message</code>.</p>++<p>Tokens: <code>--ov-accent</code> for focus and Retry, <code>--ov-line</code> for the track and borders, <code>--ov-surface</code> for the card. The gradient is OpenVibe blue, purple and rose. Reduced motion stops the sheen, pulse and check animation.</p></section></article>`;++ return layout({ title: 'Progress component', description: 'Every state and token of OpenVibe upload progress.', path: '/progress', active: '/progress', body,++ sharedStyles: styles, shared, scripts, styles: ['css/upload-kit.css'] });++}++++function uploaderPage() {++ const body = `<article class="uk-wrap"><header><p class="uk-kicker">Components</p><h1>Choose a file and keep going</h1>++<p>The picker opens the system chooser on a phone and accepts a drop on a computer. A chosen file gets its preview, name, size and Remove control. Sites pass the transfer function that reaches Media.</p>++<p><a href="/progress">See progress states →</a></p></header>++<section class="uk-card"><h2>Try the picker</h2><div data-demo-picker></div><p class="uk-note">This example stays on your device; it does not upload the file.</p></section>++<section class="uk-card"><h2>Use it</h2>${kit.snippet({ code: `<link rel="stylesheet" href="/shared/ov-upload.css">\n<script src="/shared/ov-upload.js" defer></script>\n<div id="files"></div>\n<script>\n OVUpload.queue(document.querySelector('#files'), {\n label: 'Choose files', multiple: true, concurrency: 3,\n upload: (file, { onProgress, signal }) => media.upload(file, { onProgress, signal })\n });\n</script>`, id: 'uk-picker-code', label: 'Copy uploader code' })}++<p>Use <code>OVUpload.picker</code> for a single file. Use <code>OVUpload.queue</code> for multiple files and a total progress bar. The transport owns retry and resume; Media's multipart API supports both.</p>++<p>Tokens: <code>--ov-accent</code> for focus, <code>--ov-line</code> for the drop card, <code>--ov-surface</code> for its background. The native input is labelled and visually hidden.</p></section></article>`;++ return layout({ title: 'Uploader component', description: 'The shared OpenVibe file picker and queue.', path: '/uploader', active: '/uploader', body,++ sharedStyles: styles, shared, scripts, styles: ['css/upload-kit.css'] });++}++++module.exports = { progressPage, uploaderPage };nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++const assert = require('node:assert/strict');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ await check('Progress shows every state and its shared assets', async () => {++ const page = await t.get('/progress');++ assert.equal(page.status, 200);++ for (const state of ['waiting', 'sending', 'paused', 'processing', 'done', 'failed']) assert.ok(page.text.includes(`data-demo-state="${state}"`));++ assert.ok(page.text.includes('/shared/ov-upload.js') && page.text.includes('/shared/ov-upload.css'));++ assert.ok(page.text.includes('prefers-reduced-motion') || page.text.includes('Reduced motion'));++ });++ await check('Uploader has the demo, queue code and discoverable links', async () => {++ const page = await t.get('/uploader');++ assert.equal(page.status, 200);++ assert.ok(page.text.includes('data-demo-picker') && page.text.includes('OVUpload.queue'));++ assert.ok((await t.get('/sitemap.xml')).text.includes('/progress'));++ assert.ok((await t.get('/llms.txt')).text.includes('/uploader'));++ });++ } finally { await done(t); }++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+.uk-wrap { max-width: 1050px; margin: 0 auto; padding: 20px 20px 70px; }
+.uk-wrap header { max-width: 760px; margin: 14px 0 30px; }
+.uk-wrap h1 { font-size: clamp(2rem, 5vw, 3rem); line-height: 1.1; }
+.uk-wrap p { line-height: 1.6; }
+.uk-kicker { color: var(--ov-accent, #9b7bff); font-weight: 800; text-transform: uppercase; letter-spacing: .12em; }
+.uk-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr)); gap: 15px; }
+.uk-card { min-width: 0; margin: 0 0 16px; padding: 20px; border: 1px solid var(--ov-line, #596179); border-radius: 16px; background: var(--ov-surface, rgba(100,110,145,.08)); }
+.uk-card h2 { margin: 0 0 6px; font-size: 1.2rem; }
+.uk-card > p { margin: 0 0 16px; }
+.uk-card code { overflow-wrap: anywhere; }
+.uk-note { margin-top: 12px !important; opacity: .7; font-size: .9rem; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+(function () {
+ 'use strict';
+ if (!window.OVUpload) return;
+ document.querySelectorAll('[data-demo-state]').forEach(function (el) {
+ var state = el.dataset.demoState;
+ var meter = OVUpload.progress(el, { onRetry: function () { meter.update({ state: 'sending', done: 12 * 1024 * 1024, total: 32 * 1024 * 1024 }); } });
+ meter.update({ state: state, done: state === 'done' || state === 'processing' ? 32 * 1024 * 1024 : state === 'waiting' ? 0 : 12 * 1024 * 1024,
+ total: 32 * 1024 * 1024, message: state === 'failed' ? 'The connection timed out.' : state === 'processing' ? 'Making thumbnails' : '' });
+ if (state === 'sending') {
+ var bytes = 12 * 1024 * 1024;
+ setInterval(function () { bytes = bytes >= 30 * 1024 * 1024 ? 12 * 1024 * 1024 : bytes + 1024 * 1024; meter.update({ state: 'sending', done: bytes }); }, 1100);
+ }
+ });
+ var demo = document.querySelector('[data-demo-picker]');
+ if (demo) OVUpload.picker(demo, { label: 'Choose a file', multiple: true });
+})();
@@ -38,6 +38,8 @@ function publicPages(themes = []) {
@@ -38,6 +38,8 @@ function publicPages(themes = []) {
{ path: '/tokens', changefreq: 'weekly', priority: 0.8, title: 'Your theme, as code', text: 'Any OpenVibe theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark together.' },
{ path: '/tokens', changefreq: 'weekly', priority: 0.8, title: 'Your theme, as code', text: 'Any OpenVibe theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark together.' },
{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
{ path: '/loading', changefreq: 'monthly', priority: 0.6, title: 'Loading: nothing spins', text: 'How OpenVibe waits: placeholders shaped like the content (kit.skeleton), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules.' },
{ path: '/loading', changefreq: 'monthly', priority: 0.6, title: 'Loading: nothing spins', text: 'How OpenVibe waits: placeholders shaped like the content (kit.skeleton), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules.' },
+ { path: '/progress', changefreq: 'monthly', priority: 0.6, title: 'Upload progress', text: 'The shared ov-progress component: transfer, processing, pause, retry and completion, with tokens and code.' },
+ { path: '/uploader', changefreq: 'monthly', priority: 0.6, title: 'Uploader', text: 'The shared file picker and upload queue for OpenVibe sites, with preview, Remove and code.' },
{ path: '/forms', changefreq: 'monthly', priority: 0.6, title: 'Forms: OpenVibe’s own controls', text: 'OpenVibe’s select and form controls on every site: a menu on a computer, a sheet on a phone, choices that say whether they are ready, switches, fields and choice cards (openvibe-shared ov-forms).' },
{ path: '/forms', changefreq: 'monthly', priority: 0.6, title: 'Forms: OpenVibe’s own controls', text: 'OpenVibe’s select and form controls on every site: a menu on a computer, a sheet on a phone, choices that say whether they are ready, switches, fields and choice cards (openvibe-shared ov-forms).' },
{ path: '/native', changefreq: 'monthly', priority: 0.6, title: 'Apps for Android and iOS', text: 'How an OpenVibe page becomes a real Android or iOS screen: one set of tokens, components, patterns and icons, three size classes, what each platform does its own way, and the road from the web to apps.' },
{ path: '/native', changefreq: 'monthly', priority: 0.6, title: 'Apps for Android and iOS', text: 'How an OpenVibe page becomes a real Android or iOS screen: one set of tokens, components, patterns and icons, three size classes, what each platform does its own way, and the road from the web to apps.' },
{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },
{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },
@@ -104,6 +106,8 @@ function createDiscoveryRoutes(ctx) {
@@ -104,6 +106,8 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Open font library', url: abs('/fonts'), note: 'Open licence typefaces with live previews, every style, licence, source, CSS and download. Search and filters at /fonts; JSON at GET /api/v1/fonts and /api/v1/fonts/<family>; MCP tools list and get fonts.' },
{ title: 'Open font library', url: abs('/fonts'), note: 'Open licence typefaces with live previews, every style, licence, source, CSS and download. Search and filters at /fonts; JSON at GET /api/v1/fonts and /api/v1/fonts/<family>; MCP tools list and get fonts.' },
{ title: 'A free Font Awesome alternative', url: abs('/icons/font-awesome-alternative'), note: 'one script keeps Font Awesome markup, classes, animations and data-fa-transform, and shows the OpenVibe glyphs', html: true },
{ title: 'A free Font Awesome alternative', url: abs('/icons/font-awesome-alternative'), note: 'one script keeps Font Awesome markup, classes, animations and data-fa-transform, and shows the OpenVibe glyphs', html: true },
{ title: 'Loading', url: abs('/loading'), note: 'how OpenVibe waits: placeholders shaped like the content (kit.skeleton: card, row, tile, chip, line, chat, stat), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules' },
{ title: 'Loading', url: abs('/loading'), note: 'how OpenVibe waits: placeholders shaped like the content (kit.skeleton: card, row, tile, chip, line, chat, stat), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules' },
+ { title: 'Progress', url: abs('/progress'), note: 'ov-progress in all six states, with time remaining, reduced motion, tokens and code' },
+ { title: 'Uploader', url: abs('/uploader'), note: 'the shared drop card, file preview, queue and total progress, with code' },
{ title: 'Forms', url: abs('/forms'), note: 'OpenVibe’s own select (a menu on a computer, a sheet on a phone; options with state, badge and a setup link), switches, fields and choice cards: ov-forms.js and ov-forms.css' },
{ title: 'Forms', url: abs('/forms'), note: 'OpenVibe’s own select (a menu on a computer, a sheet on a phone; options with state, badge and a setup link), switches, fields and choice cards: ov-forms.js and ov-forms.css' },
{ title: 'The OpenVibe mark', url: abs('/brand'), note: 'the logo, its states and motion, colours, the brand face, SVG files and code (openvibe-shared/mark, ov-mark.js)' },
{ title: 'The OpenVibe mark', url: abs('/brand'), note: 'the logo, its states and motion, colours, the brand face, SVG files and code (openvibe-shared/mark, ov-mark.js)' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
@@ -90,6 +90,8 @@ function createPageRoutes(ctx) {
@@ -90,6 +90,8 @@ function createPageRoutes(ctx) {
r.get('/motion', (req, res) => page(res, 200, motionView.motionPage({ base: layoutBase() }), req.viewer));
r.get('/motion', (req, res) => page(res, 200, motionView.motionPage({ base: layoutBase() }), req.viewer));
r.get('/native', (req, res) => page(res, 200, require('../views/native').nativePage(), req.viewer));
r.get('/native', (req, res) => page(res, 200, require('../views/native').nativePage(), req.viewer));
r.get('/loading', (req, res) => page(res, 200, require('../views/loading').loadingPage({ base: layoutBase() }), req.viewer));
r.get('/loading', (req, res) => page(res, 200, require('../views/loading').loadingPage({ base: layoutBase() }), req.viewer));
+ r.get('/progress', (req, res) => page(res, 200, require('../views/upload-kit').progressPage(), req.viewer));
+ r.get('/uploader', (req, res) => page(res, 200, require('../views/upload-kit').uploaderPage(), req.viewer));
// ── OpenVibe's own form controls (openvibe-shared ov-forms, 2026-11-03) ──
// ── OpenVibe's own form controls (openvibe-shared ov-forms, 2026-11-03) ──
r.get('/forms', (req, res) => page(res, 200, require('../views/forms').formsPage({ base: layoutBase() }), req.viewer));
r.get('/forms', (req, res) => page(res, 200, require('../views/forms').formsPage({ base: layoutBase() }), req.viewer));
r.get('/patterns', (req, res) => page(res, 200, patternsView.index(), req.viewer));
r.get('/patterns', (req, res) => page(res, 200, patternsView.index(), req.viewer));
@@ -31,6 +31,8 @@ const NAV = [
@@ -31,6 +31,8 @@ const NAV = [
{ label: 'The mark', href: '/brand', icon: 'fa-star', more: true },
{ label: 'The mark', href: '/brand', icon: 'fa-star', more: true },
{ label: 'Motion', href: '/motion', icon: 'fa-wand-magic-sparkles', more: true },
{ label: 'Motion', href: '/motion', icon: 'fa-wand-magic-sparkles', more: true },
{ label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },
{ label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },
+ { label: 'Progress', href: '/progress', icon: 'fa-bars-progress', more: true },
+ { label: 'Uploader', href: '/uploader', icon: 'fa-upload', more: true },
{ label: 'Forms', href: '/forms', icon: 'fa-list-check', more: true },
{ label: 'Forms', href: '/forms', icon: 'fa-list-check', more: true },
{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
{ label: 'Fonts', href: '/fonts', icon: 'fa-font', more: true },
{ label: 'Fonts', href: '/fonts', icon: 'fa-font', more: true },
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+const kit = require('openvibe-shared/kit');
+const { layout } = require('../render/layout');
+
+const styles = ['ov-upload.css'];
+const shared = ['ov-upload.js'];
+const scripts = ['js/upload-kit.js'];
+const states = [
+ ['waiting', 'Waiting', 'A file is ready to send.'],
+ ['sending', 'Sending', 'Bytes, speed and time left update while the file travels.'],
+ ['paused', 'Paused', 'The connection dropped; the upload resumes when it returns.'],
+ ['processing', 'Processing', 'The server is making the file playable.'],
+ ['done', 'Done', 'The check draws when the upload finishes.'],
+ ['failed', 'Failed', 'A plain reason and Retry appear together.'],
+];
+
+function progressPage() {
+ const body = `<article class="uk-wrap"><header><p class="uk-kicker">Components</p><h1>Progress that tells you what is happening</h1>
+<p>One <code>ov-progress</code> follows a file from selection through transfer and processing. It shows bytes, speed and an estimated time, then a drawn check when the file is ready.</p>
+<p><a href="/uploader">See the uploader →</a></p></header>
+<div class="uk-grid">${states.map(([id, name, explanation]) => `<section class="uk-card"><h2>${name}</h2><p>${explanation}</p><ov-progress data-demo-state="${id}"></ov-progress></section>`).join('')}</div>
+<section class="uk-card"><h2>Use it</h2>${kit.snippet({ code: `<link rel="stylesheet" href="/shared/ov-upload.css">\n<script src="/shared/ov-upload.js" defer></script>\n<ov-progress id="transfer"></ov-progress>\n<script>\n const p = OVUpload.progress(document.querySelector('#transfer'), { onRetry: retry });\n p.update({ state: 'sending', done: 8388608, total: 33554432 });\n p.update({ state: 'processing', message: 'Making thumbnails' });\n p.update({ state: 'done' });\n</script>`, id: 'uk-progress-code', label: 'Copy progress code' })}
+<p>States: waiting, sending, paused, processing, done and failed. The server can name each processing step through <code>message</code>.</p>
+<p>Tokens: <code>--ov-accent</code> for focus and Retry, <code>--ov-line</code> for the track and borders, <code>--ov-surface</code> for the card. The gradient is OpenVibe blue, purple and rose. Reduced motion stops the sheen, pulse and check animation.</p></section></article>`;
+ return layout({ title: 'Progress component', description: 'Every state and token of OpenVibe upload progress.', path: '/progress', active: '/progress', body,
+ sharedStyles: styles, shared, scripts, styles: ['css/upload-kit.css'] });
+}
+
+function uploaderPage() {
+ const body = `<article class="uk-wrap"><header><p class="uk-kicker">Components</p><h1>Choose a file and keep going</h1>
+<p>The picker opens the system chooser on a phone and accepts a drop on a computer. A chosen file gets its preview, name, size and Remove control. Sites pass the transfer function that reaches Media.</p>
+<p><a href="/progress">See progress states →</a></p></header>
+<section class="uk-card"><h2>Try the picker</h2><div data-demo-picker></div><p class="uk-note">This example stays on your device; it does not upload the file.</p></section>
+<section class="uk-card"><h2>Use it</h2>${kit.snippet({ code: `<link rel="stylesheet" href="/shared/ov-upload.css">\n<script src="/shared/ov-upload.js" defer></script>\n<div id="files"></div>\n<script>\n OVUpload.queue(document.querySelector('#files'), {\n label: 'Choose files', multiple: true, concurrency: 3,\n upload: (file, { onProgress, signal }) => media.upload(file, { onProgress, signal })\n });\n</script>`, id: 'uk-picker-code', label: 'Copy uploader code' })}
+<p>Use <code>OVUpload.picker</code> for a single file. Use <code>OVUpload.queue</code> for multiple files and a total progress bar. The transport owns retry and resume; Media's multipart API supports both.</p>
+<p>Tokens: <code>--ov-accent</code> for focus, <code>--ov-line</code> for the drop card, <code>--ov-surface</code> for its background. The native input is labelled and visually hidden.</p></section></article>`;
+ return layout({ title: 'Uploader component', description: 'The shared OpenVibe file picker and queue.', path: '/uploader', active: '/uploader', body,
+ sharedStyles: styles, shared, scripts, styles: ['css/upload-kit.css'] });
+}
+
+module.exports = { progressPage, uploaderPage };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('Progress shows every state and its shared assets', async () => {
+ const page = await t.get('/progress');
+ assert.equal(page.status, 200);
+ for (const state of ['waiting', 'sending', 'paused', 'processing', 'done', 'failed']) assert.ok(page.text.includes(`data-demo-state="${state}"`));
+ assert.ok(page.text.includes('/shared/ov-upload.js') && page.text.includes('/shared/ov-upload.css'));
+ assert.ok(page.text.includes('prefers-reduced-motion') || page.text.includes('Reduced motion'));
+ });
+ await check('Uploader has the demo, queue code and discoverable links', async () => {
+ const page = await t.get('/uploader');
+ assert.equal(page.status, 200);
+ assert.ok(page.text.includes('data-demo-picker') && page.text.includes('OVUpload.queue'));
+ assert.ok((await t.get('/sitemap.xml')).text.includes('/progress'));
+ assert.ok((await t.get('/llms.txt')).text.includes('/uploader'));
+ });
+ } finally { await done(t); }
+})();