openvibers/openvibe.design Commits
/forms: OpenVibe's own form controls, live, with the code (Shared v4.109.1) (#151)
Changes
@@@ -229,6 +229,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by<!-- versions:start -->- openvibe-contracts: v0.275.0- openvibe-sdk: v0.82.0--- openvibe-shared: v4.108.0++- openvibe-shared: v4.109.1- openvibe-publishing: v1.5.0<!-- versions:end -->@@@ -10,12 +10,12 @@"domain": "openvibe.design","contracts": "openvibe-contracts v0.275.0","sdk": "openvibe-sdk v0.82.0",-- "shared": "openvibe-shared v4.108.0",++ "shared": "openvibe-shared v4.109.1","packages": ["openvibe-contracts v0.275.0","openvibe-publishing v1.5.0","openvibe-sdk v0.82.0",-- "openvibe-shared v4.108.0"++ "openvibe-shared v4.109.1"],"summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore. The Arena queues one prompt for three models through OpenVibe.AI (client credentials, ai.run.create), stores sanitized HTML with reported model/provider/cost/time/tokens, renders it only in sandboxed CSP-capped iframes, draws blind pairs, records one vote per person per round, and keeps Elo ratings per category plus median cost and time. The prompts, outputs and votes are an open JSONL dataset; rounds and votes follow ADR-033 export and deletion.","works": [@@@ -18,7 +18,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.275.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.108.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.1","pg": "^8.23.1","subset-font": "^2.9.0"},@@@ -1384,9 +1384,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.108.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.108.0",-- "integrity": "sha512-NPzwjxpzGIpFpIm4uw3+VwHfsxbJ2+cD9yuhRzSwolSwqv2kCmwaVtHJ0DA9gKO3K8PCBfIP3UpKzpuSbYEJ+g==",++ "version": "4.109.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.1",++ "integrity": "sha512-6zetGo5TixgKoEqa2yhK2huRWWOcBq/Eujz/HK+Aw+QQ90T07wHmeGOUznz60PNHQhghtJOEVyn3FdwKPQyuFg==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -24,7 +24,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.275.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.108.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.1","pg": "^8.23.1","subset-font": "^2.9.0"},nnew file mode 100644@@@ -0,0 +1,30 @@++/* /forms: OpenVibe's own form controls (openvibe-shared ov-forms). Theme variables from the Frame; the controls are ov-forms.css's. */++.fm { --fm-face: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; max-width: 1120px; margin: 0 auto; padding: clamp(18px, 4vw, 44px) clamp(16px, 3vw, 32px) 96px; color: var(--text-primary); }++.fm-hero { max-width: 800px; padding-bottom: clamp(18px, 3vw, 28px); }++.fm-eye { margin: 0 0 10px; color: var(--accent); font-size: 12px; font-weight: 750; letter-spacing: .12em; text-transform: uppercase; }++.fm-h1 { margin: 0; font-family: var(--fm-face); font-weight: 900; font-size: clamp(32px, 5.2vw, 56px); line-height: 1.05; letter-spacing: -.02em; text-wrap: balance; }++.fm-lede { margin: 14px 0 0; max-width: 64ch; color: var(--text-secondary); font-size: clamp(15px, 1.6vw, 17px); line-height: 1.6; }++.fm-jump { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 18px; font-size: 14px; }++.fm-jump a { color: var(--text-secondary); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); }++.fm-jump a:hover { color: var(--text-primary); }++.fm-sec { margin: 0; padding: clamp(22px, 3.5vw, 38px) 0; border-top: 1px solid var(--border); scroll-margin-top: 72px; }++.fm-sec-head { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 8px 32px; align-items: baseline; margin-bottom: 18px; }++.fm-sec-head h2 { margin: 0; font-family: var(--fm-face); font-weight: 900; font-size: clamp(22px, 3vw, 30px); line-height: 1.15; text-wrap: balance; }++.fm-sec-head p { margin: 0; max-width: 64ch; color: var(--text-secondary); line-height: 1.55; }++.fm code { font-size: .92em; }++.fm-demo { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap: 16px; align-items: start; }++.fm-live { display: grid; gap: 14px; padding: 20px; border-radius: 16px; border: 1px solid var(--border); background: var(--bg-secondary); min-width: 0; }++.fm-stack { gap: 16px; }++.fm-out { margin: 0; color: var(--text-secondary); font-size: 13px; }++.fm-code { display: grid; gap: 12px; min-width: 0; }++.fm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 16px; padding: 20px; border-radius: 16px; border: 1px solid var(--border); background: var(--bg-secondary); }++.fm-row { display: flex; flex-wrap: wrap; gap: 14px 28px; padding: 20px; border-radius: 16px; border: 1px solid var(--border); background: var(--bg-secondary); }++.fm-check { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; font-size: 15px; }++.fm-range input { width: 160px; }++.fm-table-wrap { overflow-x: auto; border-radius: 14px; border: 1px solid var(--border); }++.fm-table { width: 100%; border-collapse: collapse; font-size: 14px; }++.fm-table th, .fm-table td { padding: 11px 14px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border); }++.fm-table th { color: var(--text-secondary); font-size: 12px; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; background: var(--bg-secondary); }++.fm-table tr:last-child td { border-bottom: 0; }++.fm-table td:first-child { white-space: nowrap; }++@media (max-width: 760px) { .fm-sec-head, .fm-demo { grid-template-columns: 1fr; } }nnew file mode 100644@@@ -0,0 +1,11 @@++// /forms: say what the first demo's select holds, so a visitor sees the hidden select follow OpenVibe's list.++(function () {++ 'use strict';++ document.querySelectorAll('[data-fm-out]').forEach(function (out) {++ var select = document.getElementById(out.getAttribute('data-fm-out'));++ if (!select) return;++ var say = function () { out.textContent = select.value || '(none)'; };++ select.addEventListener('change', say);++ say();++ });++})();@@@ -36,6 +36,7 @@ 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: '/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: '/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: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },@@@ -90,6 +91,7 @@ function createDiscoveryRoutes(ctx) {{ title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *', 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: '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' },{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },@@@ -89,6 +89,8 @@ function createPageRoutes(ctx) {// ── How OpenVibe moves: the mark's motion and the shared effects kit (plan L45) ──r.get('/motion', (req, res) => page(res, 200, motionView.motionPage({ base: layoutBase() }), req.viewer));r.get('/loading', (req, res) => page(res, 200, require('../views/loading').loadingPage({ base: layoutBase() }), 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));r.get('/patterns/mark/studies', (req, res) => res.sendFile(path.join(__dirname, '../../public/mark-studies.html')));r.get('/patterns/:slug', (req, res) => {@@@ -31,6 +31,7 @@ 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: 'Forms', href: '/forms', icon: 'fa-list-check', more: true },{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },{ label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++/**++ * /forms: OpenVibe's own form controls (openvibe-shared 4.109, ov-forms.js and ov-forms.css; the owner, 11-03: "a custom++ * system for those select inputs … implemented in openvibe.design and shared across the network and all websites++ * rather than using default stuff … mobile, desktop, etc. device specific support that looks amazing"). Every part is++ * live on the page — the select (a menu here, a sheet on a phone), several choices, search, the switch, choice cards,++ * fields — with the markup to copy and the attributes it reads. Server-rendered; the page has plain controls without++ * JavaScript, and the navbar brings ov-forms.js like on every OpenVibe site.++ *++ * formsPage({ base })++ */++const kit = require('openvibe-shared/kit');++const { layout, esc } = require('../render/layout');++++const AGENT_SELECT = `<select id="fm-agent" name="agent" data-ov-title="Fallback agent">++ <option value="">None</option>++ <option value="codex" data-state="ok" data-badge="20% used"++ data-desc="ChatGPT plan · signed in on ada-laptop" selected>Codex</option>++ <option value="opencode" data-state="warn" data-badge="88% used"++ data-desc="5-hour window nearly full">OpenCode</option>++ <option value="minimax" disabled data-state="off" data-desc="Not signed in on this machine"++ data-href="#fm-attrs" data-href-label="Sign in">MiniMax Code</option>++</select>`;++++const MODELS = [++ ['OpenAI', ['gpt-5.5', 'gpt-5.5-mini', 'gpt-5.4-codex']],++ ['Anthropic', ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5']],++ ['DeepSeek', ['deepseek-v4', 'deepseek-v4-flash']],++ ['Open models', ['kimi-k3', 'glm-5', 'qwen-4-coder', 'minimax-m3']],++];++++const ATTRS = [++ ['data-desc', 'option', 'A second line under the label.'],++ ['data-badge', 'option', 'A short tag on the right: “Signed in”, “72% used”.'],++ ['data-state', 'option', 'ok · warn · bad · off · new: a dot before the label, and the badge’s tone.'],++ ['data-icon', 'option', 'One or two characters before the label: an emoji, a letter.'],++ ['data-href · data-href-label', 'option', 'A link on the row (“Set up”), kept on a disabled choice: it says how to make it ready.'],++ ['data-ov-search', 'select', 'on or off: a search box (on by itself from 9 choices).'],++ ['data-ov-title', 'select', 'The phone sheet’s title; otherwise the select’s own label.'],++ ['data-ov-select="bare"', 'select', 'No box of its own: for a select inside a chip or a toolbar.'],++ ['multiple', 'select', 'Boxes on the rows; the list stays open; the button says “a, c” or “3 chosen”.'],++ ['data-ov-native', 'any', 'Keeps the browser’s own control, on it and everything inside it.'],++];++++function formsPage({ base = 'https://openvibe.design' } = {}) {++ const fieldSnippet = `<div class="ov-field">++ <label for="key">API key</label>++ <input id="key" name="key" autocomplete="off">++ <p class="ov-field-hint">Sealed, never shown again.</p>++</div>`;++ const choiceSnippet = `<div class="ov-choices">++ <label class="ov-choice">++ <input type="radio" name="plan" value="go">++ <span class="ov-choice-body">++ <b class="ov-choice-title">OpenCode Go</b>++ <span class="ov-choice-desc">A month of open models</span>++ </span>++ </label>++ …++</div>`;++ const useSnippet = `<!-- On an OpenVibe site: nothing. The navbar loads both files for a page with a form control. -->++<!-- Anywhere else (MIT): -->++<link rel="stylesheet" href="${base}/shared/ov-forms.css">++<script src="${base}/shared/ov-forms.js" defer></script>`;++ const body = `<article class="fm">++<header class="fm-hero">++ <p class="fm-eye">Forms</p>++ <h1 class="fm-h1">Controls that look like OpenVibe, on every site</h1>++ <p class="fm-lede">Every select is OpenVibe’s own: a menu on a computer, a sheet from the bottom on a phone. A choice can say whether it is ready, how much is used, and how to set it up. The form underneath is the browser’s, so everything that worked still works, and a page without JavaScript keeps its plain controls.</p>++ <nav class="fm-jump" aria-label="On this page"><a href="#fm-select">The select</a><a href="#fm-more">Several, search, groups</a><a href="#fm-controls">Switches and boxes</a><a href="#fm-fields">Fields and choices</a><a href="#fm-attrs">What it reads</a><a href="#fm-use">Use it</a></nav>++</header>++++<section class="fm-sec" id="fm-select" aria-labelledby="fm-select-h">++ <div class="fm-sec-head"><h2 id="fm-select-h">The select</h2><p>Open it. On a phone it rises from the bottom with rows a finger tall; on a computer it opens under the button, or above it when there is more room. The keyboard works the whole way: arrows, Home and End, typing to jump, Enter, Escape.</p></div>++ <div class="fm-demo">++ <div class="fm-live">++ <div class="ov-field"><label for="fm-agent">Fallback agent</label>${AGENT_SELECT}<p class="ov-field-hint">Used when Claude Code nears its weekly limit.</p></div>++ <p class="fm-out" aria-live="polite">Chosen: <b data-fm-out="fm-agent">codex</b></p>++ </div>++ <div class="fm-code">${kit.snippet({ code: AGENT_SELECT, id: 'fm-agent-code', label: 'Copy' })}</div>++ </div>++</section>++++<section class="fm-sec" id="fm-more" aria-labelledby="fm-more-h">++ <div class="fm-sec-head"><h2 id="fm-more-h">Several, search, groups</h2><p>A long list gets a search box. Groups become headings. A multiple select keeps its list open and counts what is chosen.</p></div>++ <div class="fm-grid">++ <div class="ov-field"><label for="fm-model">Model</label><select id="fm-model" name="model">${MODELS.map(([g, list]) => `<optgroup label="${esc(g)}">${list.map((m) => `<option${m === 'gpt-5.5' ? ' selected' : ''}>${esc(m)}</option>`).join('')}</optgroup>`).join('')}</select></div>++ <div class="ov-field"><label for="fm-sites">Show on</label><select id="fm-sites" name="sites" multiple data-placeholder="No site">${['Live', 'Chat', 'Codes', 'Games', 'Space', 'Blog'].map((s, i) => `<option${i < 2 ? ' selected' : ''}>${s}</option>`).join('')}</select></div>++ <div class="ov-field"><label for="fm-lang">Language</label><select id="fm-lang" name="lang">${[['🇬🇧', 'English'], ['🇪🇸', 'Español'], ['🇯🇵', '日本語'], ['🇧🇷', 'Português'], ['🇩🇪', 'Deutsch']].map(([f, l], i) => `<option data-icon="${f}"${i === 0 ? ' selected' : ''}>${l}</option>`).join('')}</select></div>++ </div>++</section>++++<section class="fm-sec" id="fm-controls" aria-labelledby="fm-controls-h">++ <div class="fm-sec-head"><h2 id="fm-controls-h">Switches and boxes</h2><p>A checkbox with <code>role="switch"</code> is a switch. Checkboxes, radios and sliders take the site’s accent. Nothing here changes a size, so a site’s own layout never moves.</p></div>++ <div class="fm-row">++ <label class="fm-check"><input type="checkbox" role="switch" checked> Switch back when the week frees</label>++ <label class="fm-check"><input type="checkbox" checked> Tell me when it switches</label>++ <label class="fm-check"><input type="radio" name="fm-r" checked> Ask first</label>++ <label class="fm-check"><input type="radio" name="fm-r"> Full access</label>++ <label class="fm-check fm-range">Volume <input type="range" min="0" max="100" value="64"></label>++ <label class="fm-check">A file <input type="file"></label>++ </div>++</section>++++<section class="fm-sec" id="fm-fields" aria-labelledby="fm-fields-h">++ <div class="fm-sec-head"><h2 id="fm-fields-h">Fields and choices</h2><p>Opt in with <code>.ov-field</code> for the whole field: a label, a control 44 px tall (16 px text on a phone, so it never zooms), a hint, an error. <code>.ov-choice</code> draws a choice as a card.</p></div>++ <div class="fm-demo">++ <div class="fm-live fm-stack">++ <div class="ov-field"><label for="fm-key">API key</label><input id="fm-key" autocomplete="off" placeholder="sk-…"><p class="ov-field-hint">Sealed, never shown again.</p></div>++ <div class="ov-field"><label for="fm-note">Note</label><textarea id="fm-note" placeholder="Anything it should know"></textarea></div>++ <div class="ov-choices">++ <label class="ov-choice"><input type="radio" name="fm-plan" value="go" checked><span class="ov-choice-body"><b class="ov-choice-title">OpenCode Go</b><span class="ov-choice-desc">A month of open models, one price.</span></span><span class="ov-sel-badge is-ok">Ready</span></label>++ <label class="ov-choice"><input type="radio" name="fm-plan" value="keys"><span class="ov-choice-body"><b class="ov-choice-title">Your own keys</b><span class="ov-choice-desc">Pay each provider by use.</span></span></label>++ </div>++ </div>++ <div class="fm-code">${kit.snippet({ code: fieldSnippet, id: 'fm-field-code', label: 'Copy' })}${kit.snippet({ code: choiceSnippet, id: 'fm-choice-code', label: 'Copy' })}</div>++ </div>++</section>++++<section class="fm-sec" id="fm-attrs" aria-labelledby="fm-attrs-h">++ <div class="fm-sec-head"><h2 id="fm-attrs-h">What it reads</h2><p>Plain HTML attributes: a server that knows what a person has set up writes them, and every site draws them the same way.</p></div>++ <div class="fm-table-wrap"><table class="fm-table"><thead><tr><th scope="col">Attribute</th><th scope="col">On</th><th scope="col">What it does</th></tr></thead><tbody>${ATTRS.map(([a, on, what]) => `<tr><td><code>${esc(a)}</code></td><td>${esc(on)}</td><td>${esc(what)}</td></tr>`).join('')}</tbody></table></div>++</section>++++<section class="fm-sec" id="fm-use" aria-labelledby="fm-use-h">++ <div class="fm-sec-head"><h2 id="fm-use-h">Use it</h2><p>On an OpenVibe site the navbar brings both files once the page settles. Anywhere else they are free (MIT). <code><html data-ov-forms="off"></code> keeps a whole site on the browser’s own.</p></div>++ ${kit.snippet({ code: useSnippet, id: 'fm-use-code', label: 'Copy' })}++</section>++</article>`;++ return layout({++ title: 'Forms: OpenVibe’s own controls',++ description: '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. Live, with the code.',++ path: '/forms', active: '/forms', styles: ['css/forms.css'], scripts: ['js/forms-page.js'], shared: ['ov-forms.js'], body,++ });++}++++module.exports = { formsPage, ATTRS };nnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++// /forms (2026-11-03): OpenVibe's own form controls, live and copyable. The page draws plain controls in its first HTML++// (they work without JavaScript), loads the shared ov-forms.js, shows every attribute the select reads, and is in the++// menu, the sitemap and llms.txt.++const assert = require('node:assert/strict');++const { boot, check, done } = require('./helpers/boot');++const { ATTRS } = require('../server/views/forms');++++(async () => {++ const t = await boot();++ try {++ await check('the page: the select with state and badges, several and search, switches, fields, the attributes', async () => {++ const r = await t.get('/forms');++ assert.equal(r.status, 200);++ const html = r.text;++ assert.match(html, /<select id="fm-agent" name="agent" data-ov-title="Fallback agent">/, 'a plain select in the first HTML');++ assert.match(html, /data-state="ok" data-badge="20% used"/);++ assert.match(html, /data-href="#fm-attrs" data-href-label="Sign in"/, 'a choice that is not ready says how');++ assert.match(html, /<select id="fm-sites" name="sites" multiple/);++ assert.match(html, /<optgroup label="DeepSeek">/);++ assert.match(html, /<input type="checkbox" role="switch" checked>/);++ assert.match(html, /class="ov-field"/);++ assert.match(html, /class="ov-choice"/);++ assert.equal((html.match(/<tr><td><code>/g) || []).length, ATTRS.length, 'every attribute in the table');++ assert.match(html, /\/shared\/ov-forms\.js/, 'the shared script');++ assert.match(html, /\/js\/forms-page\.js/);++ assert.match(html, /data-ov-forms="off"|data-ov-forms="off"/, 'how a site keeps the browser\'s own');++ });++ await check('found: the menu, the sitemap and llms.txt', async () => {++ assert.ok((await t.get('/')).text.includes('/forms'), 'the menu');++ assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.design/forms'));++ assert.ok((await t.get('/llms.txt')).text.includes('/forms'));++ });++ } finally {++ await done(t);++ }++})();
@@ -229,6 +229,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
@@ -229,6 +229,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.275.0
- openvibe-contracts: v0.275.0
- openvibe-sdk: v0.82.0
- openvibe-sdk: v0.82.0
-- openvibe-shared: v4.108.0
+- openvibe-shared: v4.109.1
- openvibe-publishing: v1.5.0
- openvibe-publishing: v1.5.0
<!-- versions:end -->
<!-- versions:end -->
@@ -10,12 +10,12 @@
@@ -10,12 +10,12 @@
"domain": "openvibe.design",
"domain": "openvibe.design",
"contracts": "openvibe-contracts v0.275.0",
"contracts": "openvibe-contracts v0.275.0",
"sdk": "openvibe-sdk v0.82.0",
"sdk": "openvibe-sdk v0.82.0",
- "shared": "openvibe-shared v4.108.0",
+ "shared": "openvibe-shared v4.109.1",
"packages": [
"packages": [
"openvibe-contracts v0.275.0",
"openvibe-contracts v0.275.0",
"openvibe-publishing v1.5.0",
"openvibe-publishing v1.5.0",
"openvibe-sdk v0.82.0",
"openvibe-sdk v0.82.0",
- "openvibe-shared v4.108.0"
+ "openvibe-shared v4.109.1"
],
],
"summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore. The Arena queues one prompt for three models through OpenVibe.AI (client credentials, ai.run.create), stores sanitized HTML with reported model/provider/cost/time/tokens, renders it only in sandboxed CSP-capped iframes, draws blind pairs, records one vote per person per round, and keeps Elo ratings per category plus median cost and time. The prompts, outputs and votes are an open JSONL dataset; rounds and votes follow ADR-033 export and deletion.",
"summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore. The Arena queues one prompt for three models through OpenVibe.AI (client credentials, ai.run.create), stores sanitized HTML with reported model/provider/cost/time/tokens, renders it only in sandboxed CSP-capped iframes, draws blind pairs, records one vote per person per round, and keeps Elo ratings per category plus median cost and time. The prompts, outputs and votes are an open JSONL dataset; rounds and votes follow ADR-033 export and deletion.",
"works": [
"works": [
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.275.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.275.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.108.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.1",
"pg": "^8.23.1",
"pg": "^8.23.1",
"subset-font": "^2.9.0"
"subset-font": "^2.9.0"
},
},
@@ -1384,9 +1384,9 @@
@@ -1384,9 +1384,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.108.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.108.0",
- "integrity": "sha512-NPzwjxpzGIpFpIm4uw3+VwHfsxbJ2+cD9yuhRzSwolSwqv2kCmwaVtHJ0DA9gKO3K8PCBfIP3UpKzpuSbYEJ+g==",
+ "version": "4.109.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.1",
+ "integrity": "sha512-6zetGo5TixgKoEqa2yhK2huRWWOcBq/Eujz/HK+Aw+QQ90T07wHmeGOUznz60PNHQhghtJOEVyn3FdwKPQyuFg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.275.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.275.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.108.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.1",
"pg": "^8.23.1",
"pg": "^8.23.1",
"subset-font": "^2.9.0"
"subset-font": "^2.9.0"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+/* /forms: OpenVibe's own form controls (openvibe-shared ov-forms). Theme variables from the Frame; the controls are ov-forms.css's. */
+.fm { --fm-face: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; max-width: 1120px; margin: 0 auto; padding: clamp(18px, 4vw, 44px) clamp(16px, 3vw, 32px) 96px; color: var(--text-primary); }
+.fm-hero { max-width: 800px; padding-bottom: clamp(18px, 3vw, 28px); }
+.fm-eye { margin: 0 0 10px; color: var(--accent); font-size: 12px; font-weight: 750; letter-spacing: .12em; text-transform: uppercase; }
+.fm-h1 { margin: 0; font-family: var(--fm-face); font-weight: 900; font-size: clamp(32px, 5.2vw, 56px); line-height: 1.05; letter-spacing: -.02em; text-wrap: balance; }
+.fm-lede { margin: 14px 0 0; max-width: 64ch; color: var(--text-secondary); font-size: clamp(15px, 1.6vw, 17px); line-height: 1.6; }
+.fm-jump { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 18px; font-size: 14px; }
+.fm-jump a { color: var(--text-secondary); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); }
+.fm-jump a:hover { color: var(--text-primary); }
+.fm-sec { margin: 0; padding: clamp(22px, 3.5vw, 38px) 0; border-top: 1px solid var(--border); scroll-margin-top: 72px; }
+.fm-sec-head { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 8px 32px; align-items: baseline; margin-bottom: 18px; }
+.fm-sec-head h2 { margin: 0; font-family: var(--fm-face); font-weight: 900; font-size: clamp(22px, 3vw, 30px); line-height: 1.15; text-wrap: balance; }
+.fm-sec-head p { margin: 0; max-width: 64ch; color: var(--text-secondary); line-height: 1.55; }
+.fm code { font-size: .92em; }
+.fm-demo { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap: 16px; align-items: start; }
+.fm-live { display: grid; gap: 14px; padding: 20px; border-radius: 16px; border: 1px solid var(--border); background: var(--bg-secondary); min-width: 0; }
+.fm-stack { gap: 16px; }
+.fm-out { margin: 0; color: var(--text-secondary); font-size: 13px; }
+.fm-code { display: grid; gap: 12px; min-width: 0; }
+.fm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 16px; padding: 20px; border-radius: 16px; border: 1px solid var(--border); background: var(--bg-secondary); }
+.fm-row { display: flex; flex-wrap: wrap; gap: 14px 28px; padding: 20px; border-radius: 16px; border: 1px solid var(--border); background: var(--bg-secondary); }
+.fm-check { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; font-size: 15px; }
+.fm-range input { width: 160px; }
+.fm-table-wrap { overflow-x: auto; border-radius: 14px; border: 1px solid var(--border); }
+.fm-table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.fm-table th, .fm-table td { padding: 11px 14px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border); }
+.fm-table th { color: var(--text-secondary); font-size: 12px; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; background: var(--bg-secondary); }
+.fm-table tr:last-child td { border-bottom: 0; }
+.fm-table td:first-child { white-space: nowrap; }
+@media (max-width: 760px) { .fm-sec-head, .fm-demo { grid-template-columns: 1fr; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+// /forms: say what the first demo's select holds, so a visitor sees the hidden select follow OpenVibe's list.
+(function () {
+ 'use strict';
+ document.querySelectorAll('[data-fm-out]').forEach(function (out) {
+ var select = document.getElementById(out.getAttribute('data-fm-out'));
+ if (!select) return;
+ var say = function () { out.textContent = select.value || '(none)'; };
+ select.addEventListener('change', say);
+ say();
+ });
+})();
@@ -36,6 +36,7 @@ function publicPages(themes = []) {
@@ -36,6 +36,7 @@ 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: '/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: '/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).' },
{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },
{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
@@ -90,6 +91,7 @@ function createDiscoveryRoutes(ctx) {
@@ -90,6 +91,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *', html: true },
{ title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *', 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: '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: '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' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
@@ -89,6 +89,8 @@ function createPageRoutes(ctx) {
@@ -89,6 +89,8 @@ function createPageRoutes(ctx) {
// ── How OpenVibe moves: the mark's motion and the shared effects kit (plan L45) ──
// ── How OpenVibe moves: the mark's motion and the shared effects kit (plan L45) ──
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('/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));
+ // ── 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));
r.get('/patterns', (req, res) => page(res, 200, patternsView.index(), req.viewer));
r.get('/patterns/mark/studies', (req, res) => res.sendFile(path.join(__dirname, '../../public/mark-studies.html')));
r.get('/patterns/mark/studies', (req, res) => res.sendFile(path.join(__dirname, '../../public/mark-studies.html')));
r.get('/patterns/:slug', (req, res) => {
r.get('/patterns/:slug', (req, res) => {
@@ -31,6 +31,7 @@ const NAV = [
@@ -31,6 +31,7 @@ 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: '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: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },
{ label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+/**
+ * /forms: OpenVibe's own form controls (openvibe-shared 4.109, ov-forms.js and ov-forms.css; the owner, 11-03: "a custom
+ * system for those select inputs … implemented in openvibe.design and shared across the network and all websites
+ * rather than using default stuff … mobile, desktop, etc. device specific support that looks amazing"). Every part is
+ * live on the page — the select (a menu here, a sheet on a phone), several choices, search, the switch, choice cards,
+ * fields — with the markup to copy and the attributes it reads. Server-rendered; the page has plain controls without
+ * JavaScript, and the navbar brings ov-forms.js like on every OpenVibe site.
+ *
+ * formsPage({ base })
+ */
+const kit = require('openvibe-shared/kit');
+const { layout, esc } = require('../render/layout');
+
+const AGENT_SELECT = `<select id="fm-agent" name="agent" data-ov-title="Fallback agent">
+ <option value="">None</option>
+ <option value="codex" data-state="ok" data-badge="20% used"
+ data-desc="ChatGPT plan · signed in on ada-laptop" selected>Codex</option>
+ <option value="opencode" data-state="warn" data-badge="88% used"
+ data-desc="5-hour window nearly full">OpenCode</option>
+ <option value="minimax" disabled data-state="off" data-desc="Not signed in on this machine"
+ data-href="#fm-attrs" data-href-label="Sign in">MiniMax Code</option>
+</select>`;
+
+const MODELS = [
+ ['OpenAI', ['gpt-5.5', 'gpt-5.5-mini', 'gpt-5.4-codex']],
+ ['Anthropic', ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5']],
+ ['DeepSeek', ['deepseek-v4', 'deepseek-v4-flash']],
+ ['Open models', ['kimi-k3', 'glm-5', 'qwen-4-coder', 'minimax-m3']],
+];
+
+const ATTRS = [
+ ['data-desc', 'option', 'A second line under the label.'],
+ ['data-badge', 'option', 'A short tag on the right: “Signed in”, “72% used”.'],
+ ['data-state', 'option', 'ok · warn · bad · off · new: a dot before the label, and the badge’s tone.'],
+ ['data-icon', 'option', 'One or two characters before the label: an emoji, a letter.'],
+ ['data-href · data-href-label', 'option', 'A link on the row (“Set up”), kept on a disabled choice: it says how to make it ready.'],
+ ['data-ov-search', 'select', 'on or off: a search box (on by itself from 9 choices).'],
+ ['data-ov-title', 'select', 'The phone sheet’s title; otherwise the select’s own label.'],
+ ['data-ov-select="bare"', 'select', 'No box of its own: for a select inside a chip or a toolbar.'],
+ ['multiple', 'select', 'Boxes on the rows; the list stays open; the button says “a, c” or “3 chosen”.'],
+ ['data-ov-native', 'any', 'Keeps the browser’s own control, on it and everything inside it.'],
+];
+
+function formsPage({ base = 'https://openvibe.design' } = {}) {
+ const fieldSnippet = `<div class="ov-field">
+ <label for="key">API key</label>
+ <input id="key" name="key" autocomplete="off">
+ <p class="ov-field-hint">Sealed, never shown again.</p>
+</div>`;
+ const choiceSnippet = `<div class="ov-choices">
+ <label class="ov-choice">
+ <input type="radio" name="plan" value="go">
+ <span class="ov-choice-body">
+ <b class="ov-choice-title">OpenCode Go</b>
+ <span class="ov-choice-desc">A month of open models</span>
+ </span>
+ </label>
+ …
+</div>`;
+ const useSnippet = `<!-- On an OpenVibe site: nothing. The navbar loads both files for a page with a form control. -->
+<!-- Anywhere else (MIT): -->
+<link rel="stylesheet" href="${base}/shared/ov-forms.css">
+<script src="${base}/shared/ov-forms.js" defer></script>`;
+ const body = `<article class="fm">
+<header class="fm-hero">
+ <p class="fm-eye">Forms</p>
+ <h1 class="fm-h1">Controls that look like OpenVibe, on every site</h1>
+ <p class="fm-lede">Every select is OpenVibe’s own: a menu on a computer, a sheet from the bottom on a phone. A choice can say whether it is ready, how much is used, and how to set it up. The form underneath is the browser’s, so everything that worked still works, and a page without JavaScript keeps its plain controls.</p>
+ <nav class="fm-jump" aria-label="On this page"><a href="#fm-select">The select</a><a href="#fm-more">Several, search, groups</a><a href="#fm-controls">Switches and boxes</a><a href="#fm-fields">Fields and choices</a><a href="#fm-attrs">What it reads</a><a href="#fm-use">Use it</a></nav>
+</header>
+
+<section class="fm-sec" id="fm-select" aria-labelledby="fm-select-h">
+ <div class="fm-sec-head"><h2 id="fm-select-h">The select</h2><p>Open it. On a phone it rises from the bottom with rows a finger tall; on a computer it opens under the button, or above it when there is more room. The keyboard works the whole way: arrows, Home and End, typing to jump, Enter, Escape.</p></div>
+ <div class="fm-demo">
+ <div class="fm-live">
+ <div class="ov-field"><label for="fm-agent">Fallback agent</label>${AGENT_SELECT}<p class="ov-field-hint">Used when Claude Code nears its weekly limit.</p></div>
+ <p class="fm-out" aria-live="polite">Chosen: <b data-fm-out="fm-agent">codex</b></p>
+ </div>
+ <div class="fm-code">${kit.snippet({ code: AGENT_SELECT, id: 'fm-agent-code', label: 'Copy' })}</div>
+ </div>
+</section>
+
+<section class="fm-sec" id="fm-more" aria-labelledby="fm-more-h">
+ <div class="fm-sec-head"><h2 id="fm-more-h">Several, search, groups</h2><p>A long list gets a search box. Groups become headings. A multiple select keeps its list open and counts what is chosen.</p></div>
+ <div class="fm-grid">
+ <div class="ov-field"><label for="fm-model">Model</label><select id="fm-model" name="model">${MODELS.map(([g, list]) => `<optgroup label="${esc(g)}">${list.map((m) => `<option${m === 'gpt-5.5' ? ' selected' : ''}>${esc(m)}</option>`).join('')}</optgroup>`).join('')}</select></div>
+ <div class="ov-field"><label for="fm-sites">Show on</label><select id="fm-sites" name="sites" multiple data-placeholder="No site">${['Live', 'Chat', 'Codes', 'Games', 'Space', 'Blog'].map((s, i) => `<option${i < 2 ? ' selected' : ''}>${s}</option>`).join('')}</select></div>
+ <div class="ov-field"><label for="fm-lang">Language</label><select id="fm-lang" name="lang">${[['🇬🇧', 'English'], ['🇪🇸', 'Español'], ['🇯🇵', '日本語'], ['🇧🇷', 'Português'], ['🇩🇪', 'Deutsch']].map(([f, l], i) => `<option data-icon="${f}"${i === 0 ? ' selected' : ''}>${l}</option>`).join('')}</select></div>
+ </div>
+</section>
+
+<section class="fm-sec" id="fm-controls" aria-labelledby="fm-controls-h">
+ <div class="fm-sec-head"><h2 id="fm-controls-h">Switches and boxes</h2><p>A checkbox with <code>role="switch"</code> is a switch. Checkboxes, radios and sliders take the site’s accent. Nothing here changes a size, so a site’s own layout never moves.</p></div>
+ <div class="fm-row">
+ <label class="fm-check"><input type="checkbox" role="switch" checked> Switch back when the week frees</label>
+ <label class="fm-check"><input type="checkbox" checked> Tell me when it switches</label>
+ <label class="fm-check"><input type="radio" name="fm-r" checked> Ask first</label>
+ <label class="fm-check"><input type="radio" name="fm-r"> Full access</label>
+ <label class="fm-check fm-range">Volume <input type="range" min="0" max="100" value="64"></label>
+ <label class="fm-check">A file <input type="file"></label>
+ </div>
+</section>
+
+<section class="fm-sec" id="fm-fields" aria-labelledby="fm-fields-h">
+ <div class="fm-sec-head"><h2 id="fm-fields-h">Fields and choices</h2><p>Opt in with <code>.ov-field</code> for the whole field: a label, a control 44 px tall (16 px text on a phone, so it never zooms), a hint, an error. <code>.ov-choice</code> draws a choice as a card.</p></div>
+ <div class="fm-demo">
+ <div class="fm-live fm-stack">
+ <div class="ov-field"><label for="fm-key">API key</label><input id="fm-key" autocomplete="off" placeholder="sk-…"><p class="ov-field-hint">Sealed, never shown again.</p></div>
+ <div class="ov-field"><label for="fm-note">Note</label><textarea id="fm-note" placeholder="Anything it should know"></textarea></div>
+ <div class="ov-choices">
+ <label class="ov-choice"><input type="radio" name="fm-plan" value="go" checked><span class="ov-choice-body"><b class="ov-choice-title">OpenCode Go</b><span class="ov-choice-desc">A month of open models, one price.</span></span><span class="ov-sel-badge is-ok">Ready</span></label>
+ <label class="ov-choice"><input type="radio" name="fm-plan" value="keys"><span class="ov-choice-body"><b class="ov-choice-title">Your own keys</b><span class="ov-choice-desc">Pay each provider by use.</span></span></label>
+ </div>
+ </div>
+ <div class="fm-code">${kit.snippet({ code: fieldSnippet, id: 'fm-field-code', label: 'Copy' })}${kit.snippet({ code: choiceSnippet, id: 'fm-choice-code', label: 'Copy' })}</div>
+ </div>
+</section>
+
+<section class="fm-sec" id="fm-attrs" aria-labelledby="fm-attrs-h">
+ <div class="fm-sec-head"><h2 id="fm-attrs-h">What it reads</h2><p>Plain HTML attributes: a server that knows what a person has set up writes them, and every site draws them the same way.</p></div>
+ <div class="fm-table-wrap"><table class="fm-table"><thead><tr><th scope="col">Attribute</th><th scope="col">On</th><th scope="col">What it does</th></tr></thead><tbody>${ATTRS.map(([a, on, what]) => `<tr><td><code>${esc(a)}</code></td><td>${esc(on)}</td><td>${esc(what)}</td></tr>`).join('')}</tbody></table></div>
+</section>
+
+<section class="fm-sec" id="fm-use" aria-labelledby="fm-use-h">
+ <div class="fm-sec-head"><h2 id="fm-use-h">Use it</h2><p>On an OpenVibe site the navbar brings both files once the page settles. Anywhere else they are free (MIT). <code><html data-ov-forms="off"></code> keeps a whole site on the browser’s own.</p></div>
+ ${kit.snippet({ code: useSnippet, id: 'fm-use-code', label: 'Copy' })}
+</section>
+</article>`;
+ return layout({
+ title: 'Forms: OpenVibe’s own controls',
+ description: '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. Live, with the code.',
+ path: '/forms', active: '/forms', styles: ['css/forms.css'], scripts: ['js/forms-page.js'], shared: ['ov-forms.js'], body,
+ });
+}
+
+module.exports = { formsPage, ATTRS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+// /forms (2026-11-03): OpenVibe's own form controls, live and copyable. The page draws plain controls in its first HTML
+// (they work without JavaScript), loads the shared ov-forms.js, shows every attribute the select reads, and is in the
+// menu, the sitemap and llms.txt.
+const assert = require('node:assert/strict');
+const { boot, check, done } = require('./helpers/boot');
+const { ATTRS } = require('../server/views/forms');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('the page: the select with state and badges, several and search, switches, fields, the attributes', async () => {
+ const r = await t.get('/forms');
+ assert.equal(r.status, 200);
+ const html = r.text;
+ assert.match(html, /<select id="fm-agent" name="agent" data-ov-title="Fallback agent">/, 'a plain select in the first HTML');
+ assert.match(html, /data-state="ok" data-badge="20% used"/);
+ assert.match(html, /data-href="#fm-attrs" data-href-label="Sign in"/, 'a choice that is not ready says how');
+ assert.match(html, /<select id="fm-sites" name="sites" multiple/);
+ assert.match(html, /<optgroup label="DeepSeek">/);
+ assert.match(html, /<input type="checkbox" role="switch" checked>/);
+ assert.match(html, /class="ov-field"/);
+ assert.match(html, /class="ov-choice"/);
+ assert.equal((html.match(/<tr><td><code>/g) || []).length, ATTRS.length, 'every attribute in the table');
+ assert.match(html, /\/shared\/ov-forms\.js/, 'the shared script');
+ assert.match(html, /\/js\/forms-page\.js/);
+ assert.match(html, /data-ov-forms="off"|data-ov-forms="off"/, 'how a site keeps the browser\'s own');
+ });
+ await check('found: the menu, the sitemap and llms.txt', async () => {
+ assert.ok((await t.get('/')).text.includes('/forms'), 'the menu');
+ assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.design/forms'));
+ assert.ok((await t.get('/llms.txt')).text.includes('/forms'));
+ });
+ } finally {
+ await done(t);
+ }
+})();