openvibers/openvibe.shared Commits
4.109.0: OpenVibe's own select and form controls on every site (#285)
Changes
@@@ -4,6 +4,23 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.109.0 — 2026-11-03++++- **OpenVibe's own select and form controls, on every site (`ov-forms.js`, `ov-forms.css`, docs/forms.md).** The owner++ asked for "a custom system for those select inputs … shared across the network and all websites rather than using++ default stuff", one that works on phones and computers alike.++ - **The select.** Every `<select>` becomes a button that opens OpenVibe's list: a menu on a computer, a sheet from++ the bottom on a phone.++ - The hidden select keeps the value, so forms, `select.value` and `change` listeners work as before.++ - An option can carry a description, a toned badge, a state dot and a "Set up" link (`data-desc`, `data-badge`,++ `data-state`, `data-href`).++ - The list has search from 9 choices, groups, multiple choice and the full keyboard.++ - **The other controls.** Checkboxes, radios and sliders take the site's accent, and `role="switch"` draws a switch.++ `.ov-field`, `.ov-input` and `.ov-choice` give the full field look to a page that asks for it.++ - **Loading.** The navbar loads both files once the page settles, only on a page with a form control.++ - Turn it off with `<html data-ov-forms="off">`, `navbar.init({ forms: false })`, or `data-ov-native` on a control.++ - `test/ov-forms-chrome.test.js` covers it.++## 4.108.1 — 2026-11-03- **A page named after a domain keeps its Markdown twin in llms.txt.** 4.108.0 counted any last segment with a dot as a@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.108.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.109.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -808,6 +808,15 @@ to OpenVibe's own https hosts, pictures only from openvibe.media and openvibe.neand picture; no answer leaves the fallback link. `window.OpenVibeProfilePanelConfig = { api }` points it at anotherNetwork (development).++### Forms (`ov-forms.js`, `ov-forms.css`)++++OpenVibe's own select and form controls on every site: the navbar loads them on a page with a form control. Every++`<select>` becomes a button that opens a menu on a computer and a sheet on a phone. The hidden select keeps the value,++so forms, `select.value` and `change` listeners work as before. An option can say more: `data-desc`, `data-badge`,++`data-state` and a `data-href` setup link. Checkboxes, radios and sliders take the site's accent; `role="switch"` is a++switch; `.ov-field`, `.ov-input` and `.ov-choice` are the full field look. Turn it off with `data-ov-native` or++`<html data-ov-forms="off">`. See [docs/forms.md](docs/forms.md).++## Development```bash@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.108.1",++ "version": "4.109.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",nnew file mode 100644@@@ -0,0 +1,119 @@++# Forms: OpenVibe's own select and controls++++`ov-forms.js` and `ov-forms.css` (4.109.0) give every OpenVibe site one look for form controls in place of the++browser's defaults. The owner asked for this on 2026-11-03: "a custom system for those select inputs … shared across++the network and all websites rather than using default stuff … with mobile, desktop, etc. device specific support".++++## What a site gets with no work++++The navbar loads both files once the page settles, for any page with a `select`, a checkbox, a radio, a slider or a++file input. It loads them then, or the first time one appears.++++- **Every `<select>`** becomes a button that opens OpenVibe's own list:++ - on a computer, a menu under the button (or above it, when there is more room there);++ - on a phone (up to 640 px, or a touch screen up to 900 px), a sheet from the bottom with rows a finger tall, a++ title, a scrim, and a head you drag down to close.++- **The hidden `<select>` still holds the value.**++ - A form posts what it posted before.++ - `select.value = …`, `selectedIndex` and `change` listeners keep working.++ - A choice fires `input` and `change`.++ - With no script, the page has its plain select.++- **Checkboxes, radios, sliders and progress bars** take the site's accent.++- **`<input type="checkbox" role="switch">`** is a switch.++- **A file input's button** is a button.++++None of these change a size: every rule is `:where()`, so a site's own rule always wins.++++## Saying more about a choice++++```html++<select 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">Codex</option>++ <option value="aider" disabled data-state="off" data-desc="Not installed"++ data-href="/agents#aider" data-href-label="Install">Aider</option>++</select>++```++++| On `<option>` | What it draws |++|---|---|++| `data-desc` | a second line under the label |++| `data-badge` | a short tag on the right, toned by `data-state` |++| `data-state` | `ok`, `warn`, `bad`, `off` or `new`: a dot before the label, and the badge's colour |++| `data-icon` | one or two characters before the label (an emoji, a letter) |++| `data-href`, `data-href-label` | a link on the row ("Set up", "Sign in"). A disabled option keeps it, so a choice that is not ready says how to make it ready. |++++| On `<select>` | |++|---|---|++| `data-ov-search="on"` / `"off"` | a search box (on by itself from 9 choices) |++| `data-ov-title` | the phone sheet's title (otherwise the select's label) |++| `data-placeholder` | the button's words when nothing is chosen (a multiple select) |++| `multiple` | rows with boxes; the list stays open, and the button says "a, c" or "3 chosen" |++| `size="5"` | stays the browser's list box: it is already a list on the page |++| `data-ov-native` | stays the browser's own (also on any ancestor) |++++Groups (`<optgroup>`) are headings in the list. A `required` select left empty marks its button and takes the focus:++the browser cannot point its bubble at a hidden control.++++## The keyboard++++| Key | On the closed button | In the open list |++|---|---|---|++| Enter, Space, ↓ | opens the list | Enter or Space chooses |++| ↑ | opens it at the last choice | moves up |++| ↓ | | moves down |++| Home, End, PageUp, PageDown | | move |++| A letter | jumps to the next choice that starts with it | searches, or jumps when there is no search box |++| Escape | | closes it and gives the focus back |++| Tab | | closes it |++++The list is a `listbox` with `aria-activedescendant`. The button is named by the select's own label.++++## Opting in to the full field look++++```html++<div class="ov-field">++ <label for="key">API key</label>++ <input id="key" name="key" autocomplete="off">++ <p class="ov-field-hint">Stored sealed; never shown again.</p>++ <p class="ov-field-error" hidden>That key was refused.</p>++</div>++<div class="ov-choices">++ <label class="ov-choice"><input type="radio" name="h" value="codex">++ <span class="ov-choice-body"><b class="ov-choice-title">Codex</b><span class="ov-choice-desc">Your ChatGPT plan</span></span>++ <span class="ov-sel-badge is-ok">Ready</span></label>++</div>++```++++- `.ov-field`: a label, a control 44 px tall (16 px text on a touch screen, so a phone does not zoom), a hint and an++ error. A select inside it is as tall as its text inputs.++- `.ov-input`: the same look on one input.++- `.ov-choices` / `.ov-choice`: choices drawn as cards, with the checked one outlined in the accent.++++## Turning it off++++- **For a site:** `<html data-ov-forms="off">`, or `navbar.init({ forms: false })`.++- **For a part of a page, or one control:** `data-ov-native`.++- **To avoid the late paint:** a site that wants the look before first paint links `/shared/ov-forms.css` in its++ `<head>`; the navbar then adds only the script.++++## The API++++- `OVForms.enhance(root)`: enhances the selects under `root`. New markup is also found by itself.++- `OVForms.refresh(select)`: reads a select again.++- `OVForms.open(select)` and `OVForms.close()`: open or close its list.++++## Tests++++`test/ov-forms-chrome.test.js` runs in headless Chrome and checks:++- the menu, and the keyboard skipping a disabled choice;++- the badge and the setup link;++- a script that sets the value;++- search and groups;++- a multiple select;++- that the form posts the values;++- an empty required select;++- markup added later;++- the switch;++- on a phone, the sheet and its 44 px rows, with no page overflow.@@@ -33,6 +33,7 @@ const BROWSER = Object.freeze(['ov-look.js', // the Look panel: themes, the season, more on openvibe.design (navbar.js loads it on first open)'ov-fx.js', // the network's effects (data-fx): glint, glow, lift, press, ripple, tilt, reveal, count, confetti'ov-labels.js', // content labels (L1): each viewer's show / blur / hide; navbar.js loads it when a page has [data-ov-labels]++ 'ov-forms.js', // OpenVibe's own select (a menu on a computer, a sheet on a phone); navbar.js loads it when a page has a form control'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)'history.js', // cross-site history@@@ -70,6 +71,7 @@ const STYLES = Object.freeze(['profile-panel.css', // the panel profile-panel.js draws'player.css', // player.js's controls'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)++ 'ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)'thread.css', // the conversation UI's look (openvibe-shared/thread, ov-thread.js; plan L55 C4)'kit-layout.css', // the fluid scale, the layout primitives (.ov-stack, .ov-switcher…) and the opt-in .ov-base (4.78.0; after kit.css)@@@ -491,7 +491,7 @@const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '2c505c7b96a3', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5' };++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '2c505c7b96a3', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': 'd1ce994449ca', 'ov-forms.css': 'c99414a7ab49' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -556,6 +556,39 @@});labelWatch.observe(document.body, { childList: true, subtree: true });}++ // ov-forms.js and ov-forms.css (4.109.0, the owner 2026-11-03: "a custom system for those select inputs … shared++ // across the network and all websites rather than using default stuff"): OpenVibe's own select and form controls,++ // loaded once the page settles, for a page with a select or a checkbox, radio, slider or file input — now, or the++ // first time one appears. <html data-ov-forms="off"> keeps the browser's own; data-ov-native keeps one control's.++ const FORM_CONTROLS = 'select,input[type="checkbox"],input[type="radio"],input[type="range"],input[type="file"]';++ let formWatch = null;++ function loadForms() {++ if (root.OVForms || document.getElementById('ov-forms-loader')) return true;++ if (document.documentElement.getAttribute('data-ov-forms') === 'off') return true;++ if (!document.querySelector(FORM_CONTROLS)) return false;++ try {++ if (!document.querySelector('link[href*="ov-forms.css"]')) {++ const ln = document.createElement('link'); ln.rel = 'stylesheet'; ln.href = siblingV('ov-forms.css'); document.head.appendChild(ln);++ }++ const sc = document.createElement('script'); sc.id = 'ov-forms-loader'; sc.src = siblingV('ov-forms.js'); sc.async = true; document.head.appendChild(sc);++ } catch { /* */ }++ return true;++ }++ function watchForms() {++ if (formWatch || !document.querySelector || typeof MutationObserver !== 'function' || !document.body) return;++ const go = () => {++ if (loadForms()) return;++ let soon = 0;++ formWatch = new MutationObserver(() => {++ if (soon) return;++ soon = setTimeout(() => { soon = 0; if (loadForms() && formWatch) { formWatch.disconnect(); formWatch = true; } }, 250);++ });++ formWatch.observe(document.body, { childList: true, subtree: true });++ };++ formWatch = true;++ if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(() => { formWatch = null; go(); }, { timeout: 2000 });++ else { const t = setTimeout(() => { formWatch = null; go(); }, 1200); if (t && typeof t.unref === 'function') t.unref(); }++ }// A box that scrolls (a code block, a wide table, a list held to a height) with nothing focusable inside it cannot be// scrolled from a keyboard (WCAG 2.1.1; axe scrollable-region-focusable flagged pages on six sites, 2026-11-02). Each// such box gets tabindex="0", so Tab reaches it and the arrow keys scroll it. A box with a link or a button inside is@@@ -1771,7 +1804,7 @@}// The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,// or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).-- if (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers();++ if (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers(); if (_config.forms !== false) watchForms();nav.innerHTML = `${brandHTML(brand)}<div class="openvibe-navbar-links">nnew file mode 100644@@@ -0,0 +1,215 @@++/* openvibe-shared/ov-forms.css — OpenVibe's own form controls, on every site (docs/forms.md). ov-forms.js draws the++ * select (a button, its menu on a computer, its sheet on a phone); the rest is this file alone:++ *++ * every page checkboxes, radios, sliders and progress take the site's accent; a checkbox with role="switch" is a++ * switch; a file input's button is a button; nothing here moves a site's layout (all of it is++ * :where(), so a site's own rule always wins)++ * opted in .ov-field (a label, a control, a hint, an error) and .ov-input: the full field look;++ * .ov-choices / .ov-choice: a choice drawn as a card (radio or checkbox inside)++ * opted out data-ov-native on a control or any ancestor, or <html data-ov-forms="off">++ *++ * Colours come from the site's theme (--accent, --bg-card, --border, --text-primary …), with system colours when a site++ * has none, so light and dark both follow the site. */++++/* ── The select's button ─────────────────────────────────────────────────────────────────────────────────────── */++.ov-sel {++ --ovf-accent: var(--accent, #3b82f6);++ -webkit-appearance: none; appearance: none; box-sizing: border-box;++ display: inline-flex; align-items: center; gap: 8px; max-width: 100%; min-width: 0; min-height: 2.6em;++ margin: 0; padding: .45em .65em .45em .8em;++ font: inherit; line-height: 1.3; text-align: start; letter-spacing: inherit;++ color: var(--text-primary, CanvasText);++ background: var(--bg-input, var(--bg-tertiary, Field));++ border: 1px solid var(--border, color-mix(in srgb, CanvasText 20%, transparent));++ border-radius: var(--ov-radius-1, 10px);++ cursor: pointer; -webkit-tap-highlight-color: transparent;++ transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;++}++.ov-sel.is-block { display: flex; }++.ov-sel.is-full { width: 100%; }++.ov-sel[hidden] { display: none; }++.ov-sel:hover:not(:disabled) { border-color: color-mix(in srgb, var(--ovf-accent) 50%, var(--border, color-mix(in srgb, CanvasText 20%, transparent))); }++.ov-sel:focus-visible { outline: none; border-color: var(--ovf-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovf-accent) 32%, transparent); }++.ov-sel[aria-expanded="true"] { border-color: var(--ovf-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovf-accent) 18%, transparent); }++.ov-sel:disabled { opacity: .55; cursor: not-allowed; }++.ov-sel.is-invalid { border-color: var(--danger, #ef4444); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger, #ef4444) 22%, transparent); }++.ov-sel-value { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; }++.ov-sel-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ov-sel.is-empty .ov-sel-text { color: var(--text-muted, color-mix(in srgb, CanvasText 55%, Canvas)); }++.ov-sel-chev { flex: none; display: inline-grid; place-items: center; width: 18px; height: 18px; color: var(--text-muted, color-mix(in srgb, CanvasText 55%, Canvas)); }++.ov-sel-chev svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; transition: transform .18s ease; }++.ov-sel[aria-expanded="true"] .ov-sel-chev svg { transform: rotate(180deg); }++.ov-sel .ov-sel-badge { margin-inline-start: auto; }++.ov-sel-native {++ position: absolute !important; width: 1px !important; height: 1px !important; min-width: 0 !important; min-height: 0 !important;++ margin: 0 !important; padding: 0 !important; border: 0 !important; opacity: 0 !important; pointer-events: none !important;++ clip-path: inset(50%) !important; overflow: hidden !important;++}++++/* ── The list: the menu, the sheet, and their rows ───────────────────────────────────────────────────────────── */++.ov-sel-pop, .ov-sel-sheet {++ --ovf-accent: var(--accent, #3b82f6);++ --ovf-surface: var(--bg-card, var(--bg-secondary, Canvas));++ --ovf-text: var(--text-primary, CanvasText);++ --ovf-muted: var(--text-muted, var(--text-secondary, color-mix(in srgb, CanvasText 58%, Canvas)));++ --ovf-line: var(--border, color-mix(in srgb, CanvasText 14%, transparent));++ --ovf-hover: var(--bg-hover, color-mix(in srgb, CanvasText 7%, transparent));++ --ovf-ok: var(--success, #22c55e); --ovf-warn: var(--warning, #f59e0b); --ovf-bad: var(--danger, #ef4444);++ box-sizing: border-box; z-index: 2147483000; display: flex; flex-direction: column;++ color: var(--ovf-text); background: var(--ovf-surface);++ font-family: inherit; font-size: 14px; line-height: 1.35; text-align: start;++}++.ov-sel-pop *, .ov-sel-sheet *, .ov-sel-pop *::before, .ov-sel-sheet *::after { box-sizing: border-box; }++.ov-sel-pop {++ position: fixed; max-width: min(460px, calc(100vw - 16px)); padding: 6px; overflow: hidden;++ border: 1px solid var(--ovf-line); border-radius: 12px;++ box-shadow: 0 18px 48px -12px rgb(0 0 0 / .42), 0 3px 10px rgb(0 0 0 / .14);++ opacity: 0; transform: translateY(-4px) scale(.985); transform-origin: top center;++ transition: opacity .14s ease, transform .16s cubic-bezier(.2, .8, .2, 1);++}++.ov-sel-pop.is-up { transform: translateY(4px) scale(.985); transform-origin: bottom center; }++.ov-sel-pop.is-open { opacity: 1; transform: none; }++.ov-sel-sheet {++ position: fixed; left: 0; right: 0; bottom: 0; max-height: min(86dvh, 680px); max-height: min(86vh, 680px);++ padding: 0 max(8px, env(safe-area-inset-right, 0px)) max(10px, env(safe-area-inset-bottom, 0px)) max(8px, env(safe-area-inset-left, 0px));++ border-radius: var(--ov-radius-3, 20px) var(--ov-radius-3, 20px) 0 0;++ box-shadow: 0 -14px 44px rgb(0 0 0 / .38);++ font-size: 16px; transform: translateY(100%); transition: transform .26s cubic-bezier(.2, .85, .25, 1);++}++.ov-sel-sheet.is-open { transform: none; }++.ov-sel-scrim { position: fixed; inset: 0; z-index: 2147482999; background: rgb(6 10 16 / .5); opacity: 0; transition: opacity .2s ease; -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); }++.ov-sel-scrim.is-open { opacity: 1; }++html.ov-sel-locked, html.ov-sel-locked body { overflow: hidden; overscroll-behavior: none; }++.ov-sel-sheet-head { position: relative; display: flex; align-items: center; gap: 12px; padding: 18px 8px 8px 12px; touch-action: none; cursor: grab; }++.ov-sel-grip { position: absolute; top: 7px; left: 50%; width: 38px; height: 5px; margin-left: -19px; border-radius: 99px; background: color-mix(in srgb, var(--ovf-text) 22%, transparent); }++.ov-sel-sheet-title { flex: 1; min-width: 0; font-weight: 650; font-size: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ov-sel-done { min-height: 44px; padding: 0 12px; border: 0; border-radius: 10px; background: none; color: var(--ovf-accent); font: inherit; font-weight: 650; cursor: pointer; }++.ov-sel-done:focus-visible { outline: 2px solid var(--ovf-accent); outline-offset: 2px; }++++.ov-sel-search { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; padding: 0 10px; min-height: 40px; border-radius: 9px; background: var(--ovf-hover); color: var(--ovf-muted); cursor: text; }++.ov-sel-sheet .ov-sel-search { margin: 4px 4px 8px; min-height: 46px; border-radius: 12px; }++.ov-sel-search svg { flex: none; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }++.ov-sel-search input { flex: 1; min-width: 0; height: 100%; min-height: 36px; padding: 0; border: 0; outline: 0; background: none; color: var(--ovf-text); font: inherit; }++.ov-sel-sheet .ov-sel-search input { font-size: 16px; } /* a phone does not zoom into a 16 px field */++.ov-sel-search input::-webkit-search-cancel-button { cursor: pointer; }++++.ov-sel-list { flex: 1; min-height: 0; margin: 0; padding: 0; list-style: none; overflow-y: auto; overscroll-behavior: contain; outline: none; scrollbar-width: thin; }++.ov-sel-opt {++ display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 8px 10px; margin: 1px 0;++ border-radius: 8px; cursor: pointer; scroll-margin: 6px; -webkit-tap-highlight-color: transparent;++}++.ov-sel-sheet .ov-sel-opt { min-height: 54px; padding: 10px 12px; border-radius: 12px; }++.ov-sel-opt:hover, .ov-sel-opt.is-active { background: var(--ovf-hover); }++.ov-sel-opt.is-active { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovf-accent) 35%, transparent); }++@media (hover: none) { .ov-sel-opt.is-active:not(:focus-within) { box-shadow: none; } }++.ov-sel-opt[aria-selected="true"] { background: color-mix(in srgb, var(--ovf-accent) 11%, transparent); }++.ov-sel-opt[aria-selected="true"] .ov-sel-label { font-weight: 650; }++.ov-sel-opt.is-disabled { cursor: default; }++.ov-sel-opt.is-disabled .ov-sel-words { opacity: .55; }++.ov-sel-words { flex: 1; min-width: 0; display: grid; gap: 2px; }++.ov-sel-label { overflow-wrap: anywhere; }++.ov-sel-desc { color: var(--ovf-muted); font-size: .86em; line-height: 1.35; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }++.ov-sel-mark { flex: none; display: inline-grid; place-items: center; width: 18px; height: 18px; color: var(--ovf-accent); }++.ov-sel-mark svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round; opacity: 0; transform: scale(.6); transition: opacity .12s ease, transform .14s ease; }++.ov-sel-opt[aria-selected="true"] .ov-sel-mark svg { opacity: 1; transform: none; }++.ov-sel-mark.is-box { border: 1.5px solid color-mix(in srgb, var(--ovf-text) 35%, transparent); border-radius: 5px; color: #fff; }++.ov-sel-mark.is-box svg { width: 13px; height: 13px; stroke-width: 3; }++.ov-sel-opt[aria-selected="true"] .ov-sel-mark.is-box { background: var(--ovf-accent); border-color: var(--ovf-accent); color: var(--on-accent, #fff); }++.ov-sel-icon { flex: none; display: inline-grid; place-items: center; min-width: 22px; height: 22px; font-size: 15px; line-height: 1; }++.ov-sel-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: color-mix(in srgb, CanvasText 30%, transparent); }++.ov-sel-dot.is-ok { background: var(--ovf-ok, var(--success, #22c55e)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success, #22c55e) 20%, transparent); }++.ov-sel-dot.is-warn { background: var(--ovf-warn, var(--warning, #f59e0b)); }++.ov-sel-dot.is-bad { background: var(--ovf-bad, var(--danger, #ef4444)); }++.ov-sel-dot.is-new { background: var(--accent, #3b82f6); }++.ov-sel-badge {++ flex: none; display: inline-flex; align-items: center; min-height: 20px; padding: 1px 8px; border-radius: 999px;++ font-size: 11.5px; font-weight: 600; line-height: 1.3; white-space: nowrap;++ color: var(--text-secondary, color-mix(in srgb, CanvasText 70%, Canvas)); background: color-mix(in srgb, CanvasText 8%, transparent);++}++.ov-sel-badge.is-ok { color: var(--success-text, var(--success, #15803d)); background: color-mix(in srgb, var(--success, #22c55e) 15%, transparent); }++.ov-sel-badge.is-warn { color: var(--warning-text, var(--warning, #b45309)); background: color-mix(in srgb, var(--warning, #f59e0b) 17%, transparent); }++.ov-sel-badge.is-bad { color: var(--danger-text, var(--danger, #b91c1c)); background: color-mix(in srgb, var(--danger, #ef4444) 15%, transparent); }++.ov-sel-badge.is-new { color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }++.ov-sel-link { flex: none; display: inline-flex; align-items: center; min-height: 30px; padding: 0 10px; border-radius: 999px; color: var(--ovf-accent); background: color-mix(in srgb, var(--ovf-accent) 12%, transparent); font-size: 12.5px; font-weight: 650; text-decoration: none; white-space: nowrap; }++.ov-sel-sheet .ov-sel-link { min-height: 38px; padding: 0 14px; font-size: 14px; }++.ov-sel-link:hover { background: color-mix(in srgb, var(--ovf-accent) 20%, transparent); }++.ov-sel-link:focus-visible { outline: 2px solid var(--ovf-accent); outline-offset: 2px; }++.ov-sel-group { padding: 10px 10px 4px; color: var(--ovf-muted); font-size: 11px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; }++.ov-sel-group:first-child { padding-top: 4px; }++.ov-sel-none { padding: 18px 12px; color: var(--ovf-muted); text-align: center; }++++/* ── Every page: the browser's own boxes, in the site's colours (nothing here changes a size) ─────────────────── */++:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"], input[type="radio"], input[type="range"], progress, meter):where(:not([data-ov-native], [data-ov-native] *)) {++ accent-color: var(--accent, #3b82f6);++}++:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"], input[type="radio"], input[type="range"]):where(:not([data-ov-native], [data-ov-native] *)):focus-visible {++ outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px;++}++:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"][role="switch"]):where(:not([data-ov-native], [data-ov-native] *)) {++ -webkit-appearance: none; appearance: none; box-sizing: border-box; flex: none; vertical-align: middle;++ width: 42px; height: 24px; margin: 0; border: 0; border-radius: 999px; cursor: pointer;++ background-color: color-mix(in srgb, CanvasText 26%, transparent);++ background-image: radial-gradient(circle, #fff 0 8.5px, transparent 9px);++ background-repeat: no-repeat; background-size: 20px 20px; background-position: 2px 50%;++ box-shadow: inset 0 0 0 1px color-mix(in srgb, CanvasText 10%, transparent);++ transition: background-color .18s ease, background-position .2s cubic-bezier(.3, .8, .3, 1);++}++:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"][role="switch"]:checked):where(:not([data-ov-native], [data-ov-native] *)) {++ background-color: var(--accent, #3b82f6); background-position: calc(100% - 2px) 50%;++}++:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"][role="switch"]:disabled) { opacity: .5; cursor: not-allowed; }++:where(html:not([data-ov-forms="off"])) :where(input[type="file"]:not([data-ov-native]))::file-selector-button {++ font: inherit; font-weight: 600; margin-inline-end: .75em; padding: .38em .85em; cursor: pointer;++ color: var(--text-primary, CanvasText); background: var(--bg-hover, color-mix(in srgb, CanvasText 8%, transparent));++ border: 1px solid var(--border, color-mix(in srgb, CanvasText 18%, transparent)); border-radius: 8px;++}++++/* ── Opted in: a field, an input, choices as cards ──────────────────────────────────────────────────────────── */++.ov-field { --ovf-accent: var(--accent, #3b82f6); display: grid; gap: 6px; min-width: 0; }++.ov-field > label, .ov-field-label { font-size: 13px; font-weight: 650; color: var(--text-primary, CanvasText); }++.ov-field-hint { margin: 0; font-size: 12.5px; line-height: 1.45; color: var(--text-muted, color-mix(in srgb, CanvasText 58%, Canvas)); }++.ov-field-error { margin: 0; font-size: 12.5px; line-height: 1.45; color: var(--danger-text, var(--danger, #dc2626)); }++.ov-input,++.ov-field :where(input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="color"], [type="hidden"]), textarea) {++ -webkit-appearance: none; appearance: none; box-sizing: border-box; width: 100%; min-width: 0; min-height: 44px; margin: 0;++ padding: 10px 12px; font: inherit; line-height: 1.4; color: var(--text-primary, CanvasText);++ background: var(--bg-input, var(--bg-tertiary, Field));++ border: 1px solid var(--border, color-mix(in srgb, CanvasText 20%, transparent)); border-radius: var(--ov-radius-1, 10px);++ transition: border-color .15s ease, box-shadow .15s ease;++}++.ov-field textarea, textarea.ov-input { min-height: 96px; resize: vertical; }++.ov-field .ov-sel { min-height: 44px; padding-inline-start: 12px; }++.ov-input::placeholder, .ov-field :is(input, textarea)::placeholder { color: var(--text-muted, color-mix(in srgb, CanvasText 45%, Canvas)); opacity: 1; }++.ov-input:hover, .ov-field :is(input, textarea):hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, color-mix(in srgb, CanvasText 20%, transparent))); }++.ov-input:focus-visible, .ov-field :is(input, textarea):focus-visible, .ov-input:focus, .ov-field :is(input, textarea):focus {++ outline: none; border-color: var(--accent, #3b82f6); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent);++}++.ov-input[aria-invalid="true"], .ov-field :is(input, textarea)[aria-invalid="true"], .ov-input:user-invalid, .ov-field :is(input, textarea):user-invalid {++ border-color: var(--danger, #ef4444);++}++@media (pointer: coarse) { .ov-input, .ov-field :is(input, textarea), .ov-sel { font-size: max(16px, 1em); } }++++.ov-choices { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr)); gap: 10px; }++.ov-choice {++ position: relative; display: flex; align-items: flex-start; gap: 12px; min-height: 64px; padding: 14px; box-sizing: border-box;++ border: 1px solid var(--border, color-mix(in srgb, CanvasText 18%, transparent)); border-radius: var(--ov-radius-2, 14px);++ background: var(--bg-card, transparent); cursor: pointer; transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;++}++.ov-choice:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, transparent)); }++.ov-choice:has(input:checked) { border-color: var(--accent, #3b82f6); box-shadow: 0 0 0 1px var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 7%, var(--bg-card, transparent)); }++.ov-choice:has(input:focus-visible) { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }++.ov-choice:has(input:disabled) { opacity: .6; cursor: not-allowed; }++.ov-choice > input { flex: none; margin: 3px 0 0; width: 18px; height: 18px; accent-color: var(--accent, #3b82f6); }++.ov-choice-body { display: grid; gap: 3px; min-width: 0; }++.ov-choice-title { font-weight: 650; }++.ov-choice-desc { font-size: 12.5px; line-height: 1.45; color: var(--text-muted, color-mix(in srgb, CanvasText 58%, Canvas)); }++.ov-choice .ov-sel-badge { position: absolute; top: 12px; right: 12px; }++++@media (prefers-reduced-motion: reduce) {++ .ov-sel, .ov-sel-chev svg, .ov-sel-pop, .ov-sel-sheet, .ov-sel-scrim, .ov-sel-mark svg, .ov-choice, .ov-input { transition: none !important; }++ :where(input[type="checkbox"][role="switch"]) { transition: none; }++}++@media (forced-colors: active) {++ .ov-sel, .ov-sel-pop, .ov-sel-sheet { border: 1px solid CanvasText; }++ .ov-sel-opt.is-active, .ov-sel-opt[aria-selected="true"] { outline: 2px solid Highlight; }++}nnew file mode 100644@@@ -0,0 +1,427 @@++/**++ * openvibe-shared/ov-forms.js — OpenVibe's own select, on every site (the owner, 2026-11-03: "a custom system for those++ * select inputs … shared across the network and all websites rather than using default stuff … mobile, desktop, etc.++ * device specific support that looks amazing"). The look is ov-forms.css; docs/forms.md describes both.++ *++ * Every <select> becomes a button that opens OpenVibe's own list: a menu under it on a computer, a sheet from the++ * bottom on a phone. The <select> stays in the form, hidden, and holds the value: a form posts as it did, a script that++ * sets select.value or listens for change still works, and with no script the page has its plain select.++ *++ * An <option> may say more than its label:++ * data-desc="…" a second line under it++ * data-badge="…" a short tag on its right ("Signed in", "72% used")++ * data-state="ok|warn|bad|off|new" the tag's tone, and a dot before the label++ * data-icon="…" one or two characters before the label (an emoji, a letter)++ * data-href="/setup" a link on the row ("Set up"), for a choice that is not ready: disabled options keep it++ * data-href-label="…" the link's words++ * A <select> may say:++ * data-ov-native keep the browser's own select (also on any ancestor, or <html data-ov-forms="off">)++ * data-ov-search="on|off" a search box (on by itself from 9 choices)++ * data-ov-title="…" the phone sheet's title (else the select's label)++ *++ * OVForms.enhance(root) every select under root (the navbar calls it once the page settles, and for new markup)++ * OVForms.refresh(select) read the select again (its options changed in a way the page did not announce)++ * OVForms.open(select) open its list++ *++ * Keyboard: Enter, Space, ↓ or ↑ open it; ↑ ↓ Home End move; typing searches (or jumps, without a search box); Enter++ * chooses; Escape and Tab close. A multiple select keeps its list open and says how many are chosen.++ */++(function (root) {++ 'use strict';++ if (!root || !root.document || root.OVForms) return;++ const doc = root.document;++ const SEARCH_FROM = 9;++ const PHONE = '(max-width: 640px), (pointer: coarse) and (max-width: 900px)';++ const done = new WeakMap(); // select → its state++ let open = null; // the state whose list is open++ let uid = 0;++ const reduce = () => !!(root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches);++ const phone = () => !!(root.matchMedia && root.matchMedia(PHONE).matches);++ const el = (tag, cls, attrs) => {++ const n = doc.createElement(tag);++ if (cls) n.className = cls;++ if (attrs) for (const k of Object.keys(attrs)) if (attrs[k] != null) n.setAttribute(k, attrs[k]);++ return n;++ };++ const CHEVRON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>';++ const TICK = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';++ const LOUPE = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/></svg>';++++ // ── The look: ov-forms.css beside this file, once (the navbar adds it with its own ?v=; a page may have it already).++ function stylesheet() {++ const have = doc.querySelector('link[href*="ov-forms.css"]');++ if (have) return have;++ const me = doc.currentScript || doc.querySelector('script[src*="ov-forms.js"]');++ const src = me && me.src ? me.src : '/shared/ov-forms.js';++ const link = el('link', null, { rel: 'stylesheet', href: src.replace(/ov-forms\.js(\?[^#]*)?.*$/, 'ov-forms.css$1') });++ doc.head.appendChild(link);++ return link;++ }++ const sheetReady = new Promise((resolve) => {++ const link = stylesheet();++ if (link.sheet) { resolve(); return; }++ link.addEventListener('load', () => resolve(), { once: true });++ link.addEventListener('error', () => resolve(false), { once: true });++ setTimeout(resolve, 4000);++ });++++ // A select drawn as a list box (a size above 1) stays the browser's: it is already a list on the page.++ const skip = (s) => !s || (s.hasAttribute('size') && s.size > 1)++ || s.closest('[data-ov-native]') || doc.documentElement.getAttribute('data-ov-forms') === 'off';++++ /** The words that name a select: its label(s), aria-label, or the title the page gave its sheet. */++ function nameOf(s) {++ if (s.dataset.ovTitle) return s.dataset.ovTitle;++ const labels = s.labels ? Array.prototype.slice.call(s.labels) : [];++ for (const l of labels) {++ const copy = l.cloneNode(true);++ copy.querySelectorAll('select,.ov-sel,small,.ov-sel-native').forEach((n) => n.remove());++ const t = copy.textContent.replace(/\s+/g, ' ').trim();++ if (t) return t;++ }++ return s.getAttribute('aria-label') || s.title || 'Choose';++ }++++ /** One option as the list draws it. */++ function itemOf(o, i) {++ const d = o.dataset || {};++ return {++ i, value: o.value, label: (o.label || o.textContent || '').replace(/\s+/g, ' ').trim(),++ desc: d.desc || '', badge: d.badge || '', state: d.state || '', icon: d.icon || '',++ href: d.href || '', hrefLabel: d.hrefLabel || 'Set up',++ disabled: o.disabled || !!(o.parentNode && o.parentNode.disabled), group: o.parentNode && o.parentNode.tagName === 'OPTGROUP' ? o.parentNode.label : '',++ hidden: o.hidden,++ };++ }++++ function enhanceOne(s) {++ if (done.has(s) || skip(s)) return;++ const st = { s, id: `ov-sel-${++uid}` };++ done.set(s, st);++ const cs = root.getComputedStyle(s);++ const parent = s.parentElement;++ // The button takes the select's place in the layout: its box, its margins, its share of a row or a grid.++ const btn = el('button', 'ov-sel', { type: 'button', id: `${st.id}-btn`, 'aria-haspopup': 'listbox', 'aria-expanded': 'false' });++ btn.innerHTML = `<span class="ov-sel-value"></span><span class="ov-sel-chev">${CHEVRON}</span>`;++ const keep = ['margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'flex', 'flex-basis', 'flex-grow', 'flex-shrink', 'align-self', 'justify-self',++ 'grid-column', 'grid-row', 'order', 'max-width', 'vertical-align', 'font-size', 'border-radius'];++ for (const p of keep) { const v = cs.getPropertyValue(p); if (v && v !== 'auto' && v !== 'normal' && v !== '0px' && v !== 'none') btn.style.setProperty(p, v); }++ if (cs.display === 'block' || cs.display === 'flex' || cs.display === 'grid') btn.classList.add('is-block');++ const pw = parent ? parent.clientWidth - parseFloat(root.getComputedStyle(parent).paddingLeft || 0) - parseFloat(root.getComputedStyle(parent).paddingRight || 0) : 0;++ if (s.offsetWidth && pw && s.offsetWidth >= pw - 1) btn.classList.add('is-full');++ else if (s.offsetWidth) btn.style.minWidth = `${s.offsetWidth}px`;++ // Inside an .ov-field the field's own height rules (44 px, the same as its text inputs); elsewhere the select's.++ if (s.offsetHeight && !s.closest('.ov-field')) btn.style.minHeight = `${s.offsetHeight}px`;++ if (s.className) btn.dataset.from = s.className.slice(0, 120);++ s.insertAdjacentElement('afterend', btn);++ s.classList.add('ov-sel-native');++ s.tabIndex = -1;++ s.setAttribute('aria-hidden', 'true');++ st.btn = btn;++ const ids = (s.labels ? Array.prototype.slice.call(s.labels) : []).map((l) => { if (!l.id) l.id = `${st.id}-l${Math.random().toString(36).slice(2, 6)}`; return l.id; });++ btn.setAttribute('aria-labelledby', [...ids, `${st.id}-btn`].join(' '));++ if (!ids.length) btn.setAttribute('aria-label', nameOf(s));++++ // A label's click and a script's focus() land on the hidden select: the button takes them.++ s.addEventListener('focus', () => btn.focus());++ s.addEventListener('change', () => sync(st));++ s.addEventListener('input', () => sync(st));++ s.addEventListener('invalid', (e) => { e.preventDefault(); btn.classList.add('is-invalid'); btn.setAttribute('aria-invalid', 'true'); btn.title = s.validationMessage || ''; btn.focus(); });++ const form = s.form;++ if (form) form.addEventListener('reset', () => setTimeout(() => sync(st), 0));++ // A script that sets the value or the index says nothing: these two are watched on this select only.++ for (const prop of ['value', 'selectedIndex']) {++ const d = Object.getOwnPropertyDescriptor(root.HTMLSelectElement.prototype, prop);++ if (!d || !d.set) continue;++ try {++ Object.defineProperty(s, prop, { configurable: true, enumerable: true, get() { return d.get.call(this); }, set(v) { d.set.call(this, v); sync(st); } });++ } catch { /* a frozen element: the change events still sync */ }++ }++ if (typeof root.MutationObserver === 'function') {++ st.watch = new root.MutationObserver(() => { if (!s.isConnected) { teardown(st); return; } sync(st); if (open === st) draw(st); });++ st.watch.observe(s, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled', 'hidden', 'selected', 'label', 'data-desc', 'data-badge', 'data-state', 'data-href', 'class', 'style', 'required'] });++ }++ btn.addEventListener('click', () => (open === st ? close() : show(st)));++ btn.addEventListener('keydown', (e) => {++ if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key) && open !== st) { e.preventDefault(); show(st, e.key === 'ArrowUp' ? -1 : 0); return; }++ if (!open && e.key.length === 1 && /\S/.test(e.key) && !s.multiple) jump(st, e.key);++ });++ sync(st);++ }++++ function teardown(st) {++ if (st.watch) st.watch.disconnect();++ if (open === st) close();++ if (st.btn && st.btn.isConnected) st.btn.remove();++ done.delete(st.s);++ }++++ /** The button says what is chosen, and mirrors the select's state. */++ function sync(st) {++ const { s, btn } = st;++ if (!btn) return;++ const chosen = Array.prototype.filter.call(s.options, (o) => o.selected);++ const value = btn.querySelector('.ov-sel-value');++ value.textContent = '';++ if (s.multiple) {++ value.textContent = chosen.length === 0 ? (s.dataset.placeholder || 'None') : chosen.length <= 2 ? chosen.map((o) => itemOf(o).label).join(', ') : `${chosen.length} chosen`;++ btn.classList.toggle('is-empty', !chosen.length);++ } else {++ const o = chosen[0] || s.options[s.selectedIndex] || null;++ const it = o ? itemOf(o, o.index) : null;++ if (it && it.state) value.appendChild(el('span', `ov-sel-dot is-${it.state}`, { 'aria-hidden': 'true' }));++ else if (it && it.icon) { const ic = el('span', 'ov-sel-icon', { 'aria-hidden': 'true' }); ic.textContent = it.icon; value.appendChild(ic); }++ const text = el('span', 'ov-sel-text');++ text.textContent = it ? it.label : (s.dataset.placeholder || '');++ value.appendChild(text);++ if (it && it.badge) { const b = el('span', `ov-sel-badge${it.state ? ` is-${it.state}` : ''}`); b.textContent = it.badge; value.appendChild(b); }++ btn.classList.toggle('is-empty', !it || it.value === '');++ }++ btn.disabled = s.disabled;++ btn.hidden = s.hidden || root.getComputedStyle(s).display === 'none';++ if (s.validity && s.validity.valid) { btn.classList.remove('is-invalid'); btn.removeAttribute('aria-invalid'); btn.removeAttribute('title'); }++ if (s.required) btn.setAttribute('aria-required', 'true'); else btn.removeAttribute('aria-required');++ }++++ /** Typing on the closed button: the next choice whose label starts with what was typed. */++ function jump(st, ch) {++ st.typed = (Date.now() - (st.typedAt || 0) < 700 ? st.typed || '' : '') + ch.toLowerCase();++ st.typedAt = Date.now();++ const opts = Array.prototype.slice.call(st.s.options);++ const from = st.s.selectedIndex + (st.typed.length === 1 ? 1 : 0);++ const order = [...opts.slice(from), ...opts.slice(0, from)];++ const hit = order.find((o) => !o.disabled && !o.hidden && itemOf(o).label.toLowerCase().startsWith(st.typed));++ if (hit) choose(st, hit.index);++ }++++ function choose(st, index, keepOpen) {++ const { s } = st;++ const o = s.options[index];++ if (!o || o.disabled) return;++ if (s.multiple) o.selected = !o.selected;++ else { if (s.selectedIndex === index) { if (!keepOpen) close(true); return; } o.selected = true; }++ s.dispatchEvent(new root.Event('input', { bubbles: true }));++ s.dispatchEvent(new root.Event('change', { bubbles: true }));++ sync(st);++ if (s.multiple || keepOpen) draw(st); else close(true);++ }++++ // ── The list: a menu under the button, or a sheet from the bottom on a phone ──++ function show(st, startAt) {++ if (open) close();++ if (st.s.disabled) return;++ open = st;++ const sheet = phone();++ st.sheet = sheet;++ const items = Array.prototype.map.call(st.s.options, itemOf).filter((x) => !x.hidden);++ const searchy = st.s.dataset.ovSearch === 'on' || (st.s.dataset.ovSearch !== 'off' && items.length >= SEARCH_FROM);++ const wrap = el('div', sheet ? 'ov-sel-sheet' : 'ov-sel-pop', { id: `${st.id}-pop`, role: sheet ? 'dialog' : null, 'aria-modal': sheet ? 'true' : null, 'aria-label': sheet ? nameOf(st.s) : null });++ if (sheet) {++ const head = el('div', 'ov-sel-sheet-head');++ head.innerHTML = '<span class="ov-sel-grip" aria-hidden="true"></span>';++ const title = el('span', 'ov-sel-sheet-title');++ title.textContent = nameOf(st.s);++ const doneBtn = el('button', 'ov-sel-done', { type: 'button' });++ doneBtn.textContent = st.s.multiple ? 'Done' : 'Close';++ doneBtn.addEventListener('click', () => close(true));++ head.append(title, doneBtn);++ wrap.appendChild(head);++ dragToClose(head, wrap);++ }++ if (searchy) {++ const box = el('label', 'ov-sel-search');++ box.innerHTML = LOUPE;++ const input = el('input', null, { type: 'search', placeholder: 'Search', autocomplete: 'off', spellcheck: 'false', 'aria-controls': `${st.id}-list`, 'aria-label': `Search ${nameOf(st.s)}` });++ input.addEventListener('input', () => { st.query = input.value.trim().toLowerCase(); st.active = null; draw(st); });++ box.appendChild(input);++ wrap.appendChild(box);++ st.input = input;++ } else st.input = null;++ const list = el('ul', 'ov-sel-list', { id: `${st.id}-list`, role: 'listbox', tabindex: searchy ? null : '-1', 'aria-multiselectable': st.s.multiple ? 'true' : null, 'aria-label': nameOf(st.s) });++ wrap.appendChild(list);++ st.wrap = wrap; st.list = list; st.query = '';++ const current = st.s.selectedIndex;++ st.active = startAt === -1 ? last(st, items) : (current >= 0 ? current : null);++ if (sheet) {++ st.scrim = el('div', 'ov-sel-scrim');++ st.scrim.addEventListener('click', () => close(true));++ doc.body.append(st.scrim, wrap);++ doc.documentElement.classList.add('ov-sel-locked');++ } else doc.body.appendChild(wrap);++ draw(st);++ st.btn.setAttribute('aria-expanded', 'true');++ st.btn.setAttribute('aria-controls', `${st.id}-pop`);++ if (!sheet) place(st);++ requestAnimationFrame(() => {++ wrap.classList.add('is-open');++ if (st.scrim) st.scrim.classList.add('is-open');++ (st.input && !sheet ? st.input : list).focus({ preventScroll: true });++ const a = list.querySelector('[aria-selected="true"], .is-active');++ if (a && a.scrollIntoView) a.scrollIntoView({ block: 'nearest' });++ });++ wrap.addEventListener('keydown', (e) => keys(st, e));++ list.addEventListener('pointerdown', (e) => { if (e.target.closest('.ov-sel-opt')) e.preventDefault(); });++ list.addEventListener('click', (e) => {++ if (e.target.closest('a')) return; // a row's own link goes where it says++ const row = e.target.closest('.ov-sel-opt');++ if (row && row.getAttribute('aria-disabled') !== 'true') choose(st, Number(row.dataset.i));++ });++ }++++ function last(st, items) { for (let k = items.length - 1; k >= 0; k--) if (!items[k].disabled) return items[k].i; return null; }++++ /** The rows that match the search, in groups when the select has them. */++ function draw(st) {++ const { s, list } = st;++ if (!list) return;++ const q = st.query || '';++ const items = Array.prototype.map.call(s.options, itemOf).filter((x) => !x.hidden && (!q || `${x.label} ${x.desc} ${x.badge}`.toLowerCase().includes(q)));++ list.textContent = '';++ let group = null;++ for (const it of items) {++ if (it.group && it.group !== group) {++ group = it.group;++ const g = el('li', 'ov-sel-group', { role: 'presentation' });++ g.textContent = group;++ list.appendChild(g);++ }++ const selected = s.options[it.i].selected;++ const row = el('li', `ov-sel-opt${it.disabled ? ' is-disabled' : ''}${st.active === it.i ? ' is-active' : ''}`, {++ id: `${st.id}-o${it.i}`, role: 'option', 'aria-selected': selected ? 'true' : 'false', 'aria-disabled': it.disabled ? 'true' : null, 'data-i': String(it.i),++ });++ const mark = el('span', `ov-sel-mark${s.multiple ? ' is-box' : ''}`, { 'aria-hidden': 'true' });++ mark.innerHTML = TICK;++ const lead = it.state ? el('span', `ov-sel-dot is-${it.state}`, { 'aria-hidden': 'true' }) : it.icon ? el('span', 'ov-sel-icon', { 'aria-hidden': 'true' }) : null;++ if (lead && it.icon && !it.state) lead.textContent = it.icon;++ const words = el('span', 'ov-sel-words');++ const label = el('span', 'ov-sel-label');++ label.textContent = it.label || ' ';++ words.appendChild(label);++ if (it.desc) { const d = el('span', 'ov-sel-desc'); d.textContent = it.desc; words.appendChild(d); }++ row.append(...[s.multiple ? mark : null, lead, words].filter(Boolean));++ if (it.badge) { const b = el('span', `ov-sel-badge${it.state ? ` is-${it.state}` : ''}`); b.textContent = it.badge; row.appendChild(b); }++ if (it.href) { const a = el('a', 'ov-sel-link', { href: it.href }); a.textContent = it.hrefLabel; row.appendChild(a); }++ if (!s.multiple) row.appendChild(mark);++ list.appendChild(row);++ }++ if (!items.length) { const none = el('li', 'ov-sel-none', { role: 'presentation' }); none.textContent = q ? `Nothing matches “${st.query}”` : 'Nothing to choose'; list.appendChild(none); }++ const active = st.active != null && list.querySelector(`#${st.id}-o${st.active}`);++ if (!active) { const first = list.querySelector('.ov-sel-opt:not(.is-disabled)'); st.active = first ? Number(first.dataset.i) : null; if (first) first.classList.add('is-active'); }++ const target = st.input || list;++ if (st.active != null) target.setAttribute('aria-activedescendant', `${st.id}-o${st.active}`); else target.removeAttribute('aria-activedescendant');++ }++++ function move(st, step, to) {++ const rows = Array.prototype.slice.call(st.list.querySelectorAll('.ov-sel-opt:not(.is-disabled)'));++ if (!rows.length) return;++ let k = rows.findIndex((r) => Number(r.dataset.i) === st.active);++ if (to === 'first') k = 0; else if (to === 'last') k = rows.length - 1; else k = Math.max(0, Math.min(rows.length - 1, (k < 0 ? (step > 0 ? -1 : rows.length) : k) + step));++ st.list.querySelectorAll('.is-active').forEach((r) => r.classList.remove('is-active'));++ rows[k].classList.add('is-active');++ st.active = Number(rows[k].dataset.i);++ (st.input || st.list).setAttribute('aria-activedescendant', rows[k].id);++ rows[k].scrollIntoView({ block: 'nearest' });++ }++++ function keys(st, e) {++ const k = e.key;++ if (k === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); return; }++ if (k === 'Tab') { close(false); return; }++ if (k === 'ArrowDown') { e.preventDefault(); move(st, 1); return; }++ if (k === 'ArrowUp') { e.preventDefault(); move(st, -1); return; }++ if (k === 'PageDown') { e.preventDefault(); move(st, 8); return; }++ if (k === 'PageUp') { e.preventDefault(); move(st, -8); return; }++ if ((k === 'Home' || k === 'End') && !st.input) { e.preventDefault(); move(st, 0, k === 'Home' ? 'first' : 'last'); return; }++ if (k === 'Enter' || (k === ' ' && !st.input)) { e.preventDefault(); if (st.active != null) choose(st, st.active); return; }++ if (!st.input && k.length === 1 && /\S/.test(k)) {++ st.typed = (Date.now() - (st.typedAt || 0) < 700 ? st.typed || '' : '') + k.toLowerCase();++ st.typedAt = Date.now();++ const hit = Array.prototype.find.call(st.list.querySelectorAll('.ov-sel-opt:not(.is-disabled)'), (r) => r.textContent.toLowerCase().startsWith(st.typed));++ if (hit) { st.active = Number(hit.dataset.i); st.list.querySelectorAll('.is-active').forEach((r) => r.classList.remove('is-active')); hit.classList.add('is-active'); st.list.setAttribute('aria-activedescendant', hit.id); hit.scrollIntoView({ block: 'nearest' }); }++ }++ }++++ /** The menu sits under the button, or over it when there is more room above; never wider than the screen. */++ function place(st) {++ const { btn, wrap } = st;++ if (!wrap || st.sheet) return;++ const r = btn.getBoundingClientRect();++ const vw = root.innerWidth; const vh = root.innerHeight;++ const gap = 6; const edge = 8;++ const below = vh - r.bottom - gap - edge; const above = r.top - gap - edge;++ const up = below < 220 && above > below;++ const room = Math.max(140, Math.min(420, up ? above : below));++ wrap.style.minWidth = `${Math.min(r.width, vw - 2 * edge)}px`;++ wrap.style.maxHeight = `${room}px`;++ const w = Math.min(Math.max(wrap.offsetWidth, r.width), vw - 2 * edge);++ const left = Math.max(edge, Math.min(r.left, vw - edge - w));++ wrap.style.left = `${left}px`;++ if (up) { wrap.style.top = ''; wrap.style.bottom = `${vh - r.top + gap}px`; wrap.classList.add('is-up'); }++ else { wrap.style.bottom = ''; wrap.style.top = `${r.bottom + gap}px`; wrap.classList.remove('is-up'); }++ }++++ /** The phone sheet follows a finger down from its head, and closes past a third of its height. */++ function dragToClose(handle, sheet) {++ let y0 = null; let dy = 0;++ handle.addEventListener('pointerdown', (e) => { if (e.target.closest('button')) return; y0 = e.clientY; dy = 0; handle.setPointerCapture && handle.setPointerCapture(e.pointerId); sheet.style.transition = 'none'; });++ handle.addEventListener('pointermove', (e) => { if (y0 == null) return; dy = Math.max(0, e.clientY - y0); sheet.style.transform = `translateY(${dy}px)`; });++ const end = () => { if (y0 == null) return; y0 = null; sheet.style.transition = ''; sheet.style.transform = ''; if (dy > Math.min(160, sheet.offsetHeight / 3)) close(true); };++ handle.addEventListener('pointerup', end);++ handle.addEventListener('pointercancel', end);++ }++++ function close(refocus) {++ const st = open;++ if (!st) return;++ open = null;++ st.btn.setAttribute('aria-expanded', 'false');++ st.btn.removeAttribute('aria-controls');++ const wrap = st.wrap; const scrim = st.scrim;++ st.wrap = null; st.list = null; st.scrim = null; st.input = null;++ doc.documentElement.classList.remove('ov-sel-locked');++ const gone = () => { if (wrap) wrap.remove(); if (scrim) scrim.remove(); };++ if (wrap) wrap.classList.remove('is-open');++ if (scrim) scrim.classList.remove('is-open');++ if (st.sheet && !reduce()) setTimeout(gone, 220); else gone();++ if (refocus) st.btn.focus({ preventScroll: true });++ }++++ doc.addEventListener('pointerdown', (e) => {++ if (!open || open.sheet) return;++ if (open.wrap && open.wrap.contains(e.target)) return;++ if (open.btn.contains(e.target)) return;++ close(false);++ }, true);++ root.addEventListener('resize', () => { if (open && !open.sheet) place(open); if (open && open.sheet !== phone()) close(false); });++ doc.addEventListener('scroll', (e) => { if (open && !open.sheet && !(open.wrap && open.wrap.contains(e.target))) place(open); }, true);++++ function enhance(scope) {++ const base = scope && scope.querySelectorAll ? scope : doc;++ if (base.tagName === 'SELECT') { enhanceOne(base); return; }++ Array.prototype.forEach.call(base.querySelectorAll('select'), enhanceOne);++ }++++ // New markup (a boosted page move, a dialog, a list a script drew): looked at once the page is quiet again.++ let soon = 0;++ function watch() {++ if (typeof root.MutationObserver !== 'function' || !doc.body) return;++ new root.MutationObserver((records) => {++ for (const r of records) for (const n of r.addedNodes) {++ if (n.nodeType === 1 && (n.tagName === 'SELECT' || (n.querySelector && n.querySelector('select')))) {++ if (!soon) soon = setTimeout(() => { soon = 0; enhance(doc); }, 60);++ return;++ }++ }++ }).observe(doc.body, { childList: true, subtree: true });++ }++++ root.OVForms = {++ enhance: (scope) => sheetReady.then((ok) => { if (ok !== false) enhance(scope); }),++ refresh: (s) => { const st = done.get(s); if (st) { sync(st); if (open === st) draw(st); } },++ open: (s) => { const st = done.get(s); if (st) show(st); },++ close: () => close(true),++ };++ const start = () => sheetReady.then((ok) => { if (ok === false) return; enhance(doc); watch(); });++ if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', start, { once: true });++ else start();++})(typeof window !== 'undefined' ? window : undefined);@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.108.1",++ "version": "4.109.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.108.1",++ "version": "4.109.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.108.1",++ "version": "4.109.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -54,6 +54,7 @@"./openvibe-sw": "./openvibe-sw.js","./ov-icons": "./ov-icons.js","./ov-glyphs": "./ov-glyphs.js",++ "./ov-forms": "./ov-forms.js","./ov-labels": "./ov-labels.js","./ov-look": "./ov-look.js","./ov-fx": "./ov-fx.js",@@@ -9,7 +9,7 @@ const path = require('path');const { hashAsset } = require('../assets');const ROOT = path.join(__dirname, '..');--const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js'];++const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css'];const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;nnew file mode 100644@@@ -0,0 +1,154 @@++'use strict';++// ov-forms.js and ov-forms.css (4.109.0) in a real headless Chrome: every select becomes OpenVibe's own (a menu on a++// computer, a sheet on a phone), the hidden select still holds the value a form posts, a script that sets the value++// is followed, the keyboard works, options say their state, and data-ov-native keeps the browser's own. Skipped++// without Chrome.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('ov-forms-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const LONG = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'];++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Forms</title><link rel="icon" href="data:,">++<link rel="stylesheet" href="/ov-forms.css"></head><body style="font:15px system-ui;padding:16px">++<form id="f" action="/sent">++<label for="agent">Agent</label>++<select id="agent" name="agent" required style="width:100%">++ <option value="">Choose…</option>++ <option value="claude-code" data-state="ok" data-badge="Signed in" data-desc="Your own Claude login, on your machine">Claude Code</option>++ <option value="codex" data-state="warn" data-badge="91% used">Codex</option>++ <option value="opencode" disabled data-state="off" data-href="/setup/opencode" data-href-label="Set up">OpenCode</option>++</select>++<label>Many <select id="many" name="many" multiple><option>a</option><option>b</option><option>c</option></select></label>++<label>Long <select id="long" name="long"><optgroup label="Small">${LONG.slice(0, 6).map((x) => `<option>${x}</option>`).join('')}</optgroup><optgroup label="Big">${LONG.slice(6).map((x) => `<option>${x}</option>`).join('')}</optgroup></select></label>++<span data-ov-native><select id="raw" name="raw"><option>x</option></select></span>++<label><input type="checkbox" role="switch" id="sw"> A switch</label>++<button id="send">Send</button>++</form>++<script>window.__changes = 0; document.getElementById('agent').addEventListener('change', () => window.__changes++);++document.getElementById('f').addEventListener('submit', (e) => { e.preventDefault(); const d = new FormData(e.target); window.__sent = { agent: d.get('agent'), many: d.getAll('many'), long: d.get('long') }; });</script>++<script src="/ov-forms.js"></script></body></html>`;++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }++ if (u.pathname === '/ov-forms.js' || u.pathname === '/ov-forms.css') {++ res.writeHead(200, { 'content-type': u.pathname.endsWith('.css') ? 'text/css' : 'application/javascript' });++ return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1))));++ }++ res.writeHead(404); return res.end();++});++++const key = (k, target = 'document.activeElement') => `(${target}).dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(k)}, bubbles: true, cancelable: true }))`;++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-forms-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ try {++ // ── A computer: the menu ──++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 1280 });++ await p.goto(`${base}/`, { maxMs: 5000 });++ await wait(250);++ const btn = "document.querySelector('#agent + .ov-sel')";++ assert.strictEqual(await p.evaluate("document.getElementById('agent').classList.contains('ov-sel-native')"), true, 'the select is enhanced');++ assert.strictEqual(await p.evaluate(`${btn}.textContent.trim()`), 'Choose…');++ assert.strictEqual(await p.evaluate("!!document.querySelector('#raw + .ov-sel')"), false, 'data-ov-native keeps the browser\'s own');++ assert.strictEqual(await p.evaluate(`${btn}.getAttribute('aria-labelledby').split(' ').some((id) => document.getElementById(id) && document.getElementById(id).tagName === 'LABEL')`), true, 'the button is named by the select\'s label');++ assert.strictEqual(await p.evaluate(`${btn}.getBoundingClientRect().width === document.getElementById('f').getBoundingClientRect().width`), true, 'a full-width select gives a full-width button');++++ await p.evaluate(`${btn}.click()`);++ await wait(80);++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop.is-open')"), true, 'a click opens the menu');++ assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop [role=option]').length"), 4);++ assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop [role=option][aria-disabled=true] a.ov-sel-link').getAttribute('href')"), '/setup/opencode', 'a choice that is not ready links to its setup');++ assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-desc').textContent"), 'Your own Claude login, on your machine');++ assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-badge.is-warn').textContent"), '91% used');++ await p.evaluate(key('ArrowDown'));++ await p.evaluate(key('ArrowDown'));++ await p.evaluate(key('ArrowDown')); // past the end: OpenCode is disabled, Codex stays the last++ await p.evaluate(key('Enter'));++ await wait(60);++ assert.strictEqual(await p.evaluate("document.getElementById('agent').value"), 'codex', 'the keyboard chooses, skipping a disabled choice');++ assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and the select says change, as a person\'s choice would');++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'and the menu closes');++ assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'with the focus back on the button');++ assert.match(await p.evaluate(`${btn}.textContent`), /Codex.*91% used/, 'the button shows the choice and its badge');++++ await p.evaluate("document.getElementById('agent').value = 'claude-code'");++ assert.match(await p.evaluate(`${btn}.textContent`), /Claude Code/, 'a script that sets the value is followed');++++ // A long list has a search box, and groups.++ await p.evaluate("document.querySelector('#long + .ov-sel').click()");++ await wait(80);++ assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-group').length"), 2);++ await p.evaluate("(() => { const i = document.querySelector('.ov-sel-pop .ov-sel-search input'); i.value = 'tw'; i.dispatchEvent(new Event('input', { bubbles: true })); })()");++ assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ov-sel-pop .ov-sel-label')].map((x) => x.textContent)"), ['two', 'twelve'], 'typing narrows the list');++ await p.evaluate(key('Enter', "document.querySelector('.ov-sel-pop .ov-sel-search input')"));++ await wait(60);++ assert.strictEqual(await p.evaluate("document.getElementById('long').value"), 'two');++++ // A multiple select keeps its menu open and counts.++ await p.evaluate("document.querySelector('#many + .ov-sel').click()");++ await wait(80);++ await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[0].click()");++ await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[2].click()");++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), true, 'the menu stays open while choosing several');++ await p.evaluate(key('Escape', "document.querySelector('.ov-sel-pop')"));++ await wait(30);++ assert.strictEqual(await p.evaluate("document.querySelector('#many + .ov-sel').textContent.trim()"), 'a, c');++++ // A click elsewhere closes the menu; the form posts what was chosen.++ await p.evaluate("document.querySelector('#agent + .ov-sel').click()");++ await wait(60);++ await p.evaluate("document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))");++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'a click elsewhere closes it');++ await p.evaluate("document.getElementById('send').click()");++ assert.deepStrictEqual(await p.evaluate('window.__sent'), { agent: 'claude-code', many: ['a', 'c'], long: 'two' }, 'the form posts the hidden selects\' values');++++ // A required select left empty: the button says so, not a bubble on a hidden control.++ await p.evaluate("document.getElementById('agent').value = ''");++ await p.evaluate("document.getElementById('f').requestSubmit()");++ assert.strictEqual(await p.evaluate(`${btn}.classList.contains('is-invalid')`), true, 'an empty required select marks its button');++ assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'and takes the focus');++++ // Markup added later is enhanced too.++ await p.evaluate("document.body.insertAdjacentHTML('beforeend', '<select id=\"late\"><option>x</option><option>y</option></select>')");++ await wait(200);++ assert.strictEqual(await p.evaluate("!!document.querySelector('#late + .ov-sel')"), true, 'a select added later is enhanced');++ assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('sw')).appearance"), 'none', 'a checkbox with role=switch is a switch');++ await p.close();++++ // ── A phone: the sheet ──++ const phone = await h.openPage(browser, context, { width: 390 });++ await phone.goto(`${base}/`, { maxMs: 5000 });++ await wait(250);++ await phone.evaluate("document.querySelector('#agent + .ov-sel').click()");++ await wait(320);++ assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet.is-open') && !!document.querySelector('.ov-sel-scrim.is-open')"), true, 'a phone gets a sheet over a scrim');++ assert.strictEqual(await phone.evaluate("document.querySelector('.ov-sel-sheet-title').textContent"), 'Agent', 'titled with the select\'s label');++ assert.strictEqual(await phone.evaluate("document.documentElement.classList.contains('ov-sel-locked')"), true, 'the page under it does not scroll');++ assert.ok(await phone.evaluate("document.querySelector('.ov-sel-sheet .ov-sel-opt').getBoundingClientRect().height") >= 44, 'rows are a finger tall');++ await phone.evaluate("document.querySelectorAll('.ov-sel-sheet .ov-sel-opt')[1].click()");++ await wait(320);++ assert.strictEqual(await phone.evaluate("document.getElementById('agent').value"), 'claude-code');++ assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet') || document.documentElement.classList.contains('ov-sel-locked')"), false, 'choosing closes the sheet and frees the page');++ assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390, 'nothing is wider than the phone');++ await phone.close();++ console.log('ov-forms-chrome: the menu, the sheet, the keyboard, search, several choices, the form\'s values and native opt-outs ok');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -4,6 +4,23 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,23 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.109.0 — 2026-11-03
+
+- **OpenVibe's own select and form controls, on every site (`ov-forms.js`, `ov-forms.css`, docs/forms.md).** The owner
+ asked for "a custom system for those select inputs … shared across the network and all websites rather than using
+ default stuff", one that works on phones and computers alike.
+ - **The select.** Every `<select>` becomes a button that opens OpenVibe's list: a menu on a computer, a sheet from
+ the bottom on a phone.
+ - The hidden select keeps the value, so forms, `select.value` and `change` listeners work as before.
+ - An option can carry a description, a toned badge, a state dot and a "Set up" link (`data-desc`, `data-badge`,
+ `data-state`, `data-href`).
+ - The list has search from 9 choices, groups, multiple choice and the full keyboard.
+ - **The other controls.** Checkboxes, radios and sliders take the site's accent, and `role="switch"` draws a switch.
+ `.ov-field`, `.ov-input` and `.ov-choice` give the full field look to a page that asks for it.
+ - **Loading.** The navbar loads both files once the page settles, only on a page with a form control.
+ - Turn it off with `<html data-ov-forms="off">`, `navbar.init({ forms: false })`, or `data-ov-native` on a control.
+ - `test/ov-forms-chrome.test.js` covers it.
+
## 4.108.1 — 2026-11-03
## 4.108.1 — 2026-11-03
- **A page named after a domain keeps its Markdown twin in llms.txt.** 4.108.0 counted any last segment with a dot as a
- **A page named after a domain keeps its Markdown twin in llms.txt.** 4.108.0 counted any last segment with a dot as a
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.108.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.109.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -808,6 +808,15 @@ to OpenVibe's own https hosts, pictures only from openvibe.media and openvibe.ne
@@ -808,6 +808,15 @@ to OpenVibe's own https hosts, pictures only from openvibe.media and openvibe.ne
and picture; no answer leaves the fallback link. `window.OpenVibeProfilePanelConfig = { api }` points it at another
and picture; no answer leaves the fallback link. `window.OpenVibeProfilePanelConfig = { api }` points it at another
Network (development).
Network (development).
+### Forms (`ov-forms.js`, `ov-forms.css`)
+
+OpenVibe's own select and form controls on every site: the navbar loads them on a page with a form control. Every
+`<select>` becomes a button that opens a menu on a computer and a sheet on a phone. The hidden select keeps the value,
+so forms, `select.value` and `change` listeners work as before. An option can say more: `data-desc`, `data-badge`,
+`data-state` and a `data-href` setup link. Checkboxes, radios and sliders take the site's accent; `role="switch"` is a
+switch; `.ov-field`, `.ov-input` and `.ov-choice` are the full field look. Turn it off with `data-ov-native` or
+`<html data-ov-forms="off">`. See [docs/forms.md](docs/forms.md).
+
## Development
## Development
```bash
```bash
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.108.1",
+ "version": "4.109.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+# Forms: OpenVibe's own select and controls
+
+`ov-forms.js` and `ov-forms.css` (4.109.0) give every OpenVibe site one look for form controls in place of the
+browser's defaults. The owner asked for this on 2026-11-03: "a custom system for those select inputs … shared across
+the network and all websites rather than using default stuff … with mobile, desktop, etc. device specific support".
+
+## What a site gets with no work
+
+The navbar loads both files once the page settles, for any page with a `select`, a checkbox, a radio, a slider or a
+file input. It loads them then, or the first time one appears.
+
+- **Every `<select>`** becomes a button that opens OpenVibe's own list:
+ - on a computer, a menu under the button (or above it, when there is more room there);
+ - on a phone (up to 640 px, or a touch screen up to 900 px), a sheet from the bottom with rows a finger tall, a
+ title, a scrim, and a head you drag down to close.
+- **The hidden `<select>` still holds the value.**
+ - A form posts what it posted before.
+ - `select.value = …`, `selectedIndex` and `change` listeners keep working.
+ - A choice fires `input` and `change`.
+ - With no script, the page has its plain select.
+- **Checkboxes, radios, sliders and progress bars** take the site's accent.
+- **`<input type="checkbox" role="switch">`** is a switch.
+- **A file input's button** is a button.
+
+None of these change a size: every rule is `:where()`, so a site's own rule always wins.
+
+## Saying more about a choice
+
+```html
+<select 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">Codex</option>
+ <option value="aider" disabled data-state="off" data-desc="Not installed"
+ data-href="/agents#aider" data-href-label="Install">Aider</option>
+</select>
+```
+
+| On `<option>` | What it draws |
+|---|---|
+| `data-desc` | a second line under the label |
+| `data-badge` | a short tag on the right, toned by `data-state` |
+| `data-state` | `ok`, `warn`, `bad`, `off` or `new`: a dot before the label, and the badge's colour |
+| `data-icon` | one or two characters before the label (an emoji, a letter) |
+| `data-href`, `data-href-label` | a link on the row ("Set up", "Sign in"). A disabled option keeps it, so a choice that is not ready says how to make it ready. |
+
+| On `<select>` | |
+|---|---|
+| `data-ov-search="on"` / `"off"` | a search box (on by itself from 9 choices) |
+| `data-ov-title` | the phone sheet's title (otherwise the select's label) |
+| `data-placeholder` | the button's words when nothing is chosen (a multiple select) |
+| `multiple` | rows with boxes; the list stays open, and the button says "a, c" or "3 chosen" |
+| `size="5"` | stays the browser's list box: it is already a list on the page |
+| `data-ov-native` | stays the browser's own (also on any ancestor) |
+
+Groups (`<optgroup>`) are headings in the list. A `required` select left empty marks its button and takes the focus:
+the browser cannot point its bubble at a hidden control.
+
+## The keyboard
+
+| Key | On the closed button | In the open list |
+|---|---|---|
+| Enter, Space, ↓ | opens the list | Enter or Space chooses |
+| ↑ | opens it at the last choice | moves up |
+| ↓ | | moves down |
+| Home, End, PageUp, PageDown | | move |
+| A letter | jumps to the next choice that starts with it | searches, or jumps when there is no search box |
+| Escape | | closes it and gives the focus back |
+| Tab | | closes it |
+
+The list is a `listbox` with `aria-activedescendant`. The button is named by the select's own label.
+
+## Opting in to the full field look
+
+```html
+<div class="ov-field">
+ <label for="key">API key</label>
+ <input id="key" name="key" autocomplete="off">
+ <p class="ov-field-hint">Stored sealed; never shown again.</p>
+ <p class="ov-field-error" hidden>That key was refused.</p>
+</div>
+<div class="ov-choices">
+ <label class="ov-choice"><input type="radio" name="h" value="codex">
+ <span class="ov-choice-body"><b class="ov-choice-title">Codex</b><span class="ov-choice-desc">Your ChatGPT plan</span></span>
+ <span class="ov-sel-badge is-ok">Ready</span></label>
+</div>
+```
+
+- `.ov-field`: a label, a control 44 px tall (16 px text on a touch screen, so a phone does not zoom), a hint and an
+ error. A select inside it is as tall as its text inputs.
+- `.ov-input`: the same look on one input.
+- `.ov-choices` / `.ov-choice`: choices drawn as cards, with the checked one outlined in the accent.
+
+## Turning it off
+
+- **For a site:** `<html data-ov-forms="off">`, or `navbar.init({ forms: false })`.
+- **For a part of a page, or one control:** `data-ov-native`.
+- **To avoid the late paint:** a site that wants the look before first paint links `/shared/ov-forms.css` in its
+ `<head>`; the navbar then adds only the script.
+
+## The API
+
+- `OVForms.enhance(root)`: enhances the selects under `root`. New markup is also found by itself.
+- `OVForms.refresh(select)`: reads a select again.
+- `OVForms.open(select)` and `OVForms.close()`: open or close its list.
+
+## Tests
+
+`test/ov-forms-chrome.test.js` runs in headless Chrome and checks:
+- the menu, and the keyboard skipping a disabled choice;
+- the badge and the setup link;
+- a script that sets the value;
+- search and groups;
+- a multiple select;
+- that the form posts the values;
+- an empty required select;
+- markup added later;
+- the switch;
+- on a phone, the sheet and its 44 px rows, with no page overflow.
@@ -33,6 +33,7 @@ const BROWSER = Object.freeze([
@@ -33,6 +33,7 @@ const BROWSER = Object.freeze([
'ov-look.js', // the Look panel: themes, the season, more on openvibe.design (navbar.js loads it on first open)
'ov-look.js', // the Look panel: themes, the season, more on openvibe.design (navbar.js loads it on first open)
'ov-fx.js', // the network's effects (data-fx): glint, glow, lift, press, ripple, tilt, reveal, count, confetti
'ov-fx.js', // the network's effects (data-fx): glint, glow, lift, press, ripple, tilt, reveal, count, confetti
'ov-labels.js', // content labels (L1): each viewer's show / blur / hide; navbar.js loads it when a page has [data-ov-labels]
'ov-labels.js', // content labels (L1): each viewer's show / blur / hide; navbar.js loads it when a page has [data-ov-labels]
+ 'ov-forms.js', // OpenVibe's own select (a menu on a computer, a sheet on a phone); navbar.js loads it when a page has a form control
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'history.js', // cross-site history
'history.js', // cross-site history
@@ -70,6 +71,7 @@ const STYLES = Object.freeze([
@@ -70,6 +71,7 @@ const STYLES = Object.freeze([
'profile-panel.css', // the panel profile-panel.js draws
'profile-panel.css', // the panel profile-panel.js draws
'player.css', // player.js's controls
'player.css', // player.js's controls
'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
+ 'ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)
'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)
'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)
'thread.css', // the conversation UI's look (openvibe-shared/thread, ov-thread.js; plan L55 C4)
'thread.css', // the conversation UI's look (openvibe-shared/thread, ov-thread.js; plan L55 C4)
'kit-layout.css', // the fluid scale, the layout primitives (.ov-stack, .ov-switcher…) and the opt-in .ov-base (4.78.0; after kit.css)
'kit-layout.css', // the fluid scale, the layout primitives (.ov-stack, .ov-switcher…) and the opt-in .ov-base (4.78.0; after kit.css)
@@ -491,7 +491,7 @@
@@ -491,7 +491,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '2c505c7b96a3', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5' };
+ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '2c505c7b96a3', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': 'd1ce994449ca', 'ov-forms.css': 'c99414a7ab49' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -556,6 +556,39 @@
@@ -556,6 +556,39 @@
});
});
labelWatch.observe(document.body, { childList: true, subtree: true });
labelWatch.observe(document.body, { childList: true, subtree: true });
}
}
+ // ov-forms.js and ov-forms.css (4.109.0, the owner 2026-11-03: "a custom system for those select inputs … shared
+ // across the network and all websites rather than using default stuff"): OpenVibe's own select and form controls,
+ // loaded once the page settles, for a page with a select or a checkbox, radio, slider or file input — now, or the
+ // first time one appears. <html data-ov-forms="off"> keeps the browser's own; data-ov-native keeps one control's.
+ const FORM_CONTROLS = 'select,input[type="checkbox"],input[type="radio"],input[type="range"],input[type="file"]';
+ let formWatch = null;
+ function loadForms() {
+ if (root.OVForms || document.getElementById('ov-forms-loader')) return true;
+ if (document.documentElement.getAttribute('data-ov-forms') === 'off') return true;
+ if (!document.querySelector(FORM_CONTROLS)) return false;
+ try {
+ if (!document.querySelector('link[href*="ov-forms.css"]')) {
+ const ln = document.createElement('link'); ln.rel = 'stylesheet'; ln.href = siblingV('ov-forms.css'); document.head.appendChild(ln);
+ }
+ const sc = document.createElement('script'); sc.id = 'ov-forms-loader'; sc.src = siblingV('ov-forms.js'); sc.async = true; document.head.appendChild(sc);
+ } catch { /* */ }
+ return true;
+ }
+ function watchForms() {
+ if (formWatch || !document.querySelector || typeof MutationObserver !== 'function' || !document.body) return;
+ const go = () => {
+ if (loadForms()) return;
+ let soon = 0;
+ formWatch = new MutationObserver(() => {
+ if (soon) return;
+ soon = setTimeout(() => { soon = 0; if (loadForms() && formWatch) { formWatch.disconnect(); formWatch = true; } }, 250);
+ });
+ formWatch.observe(document.body, { childList: true, subtree: true });
+ };
+ formWatch = true;
+ if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(() => { formWatch = null; go(); }, { timeout: 2000 });
+ else { const t = setTimeout(() => { formWatch = null; go(); }, 1200); if (t && typeof t.unref === 'function') t.unref(); }
+ }
// A box that scrolls (a code block, a wide table, a list held to a height) with nothing focusable inside it cannot be
// A box that scrolls (a code block, a wide table, a list held to a height) with nothing focusable inside it cannot be
// scrolled from a keyboard (WCAG 2.1.1; axe scrollable-region-focusable flagged pages on six sites, 2026-11-02). Each
// scrolled from a keyboard (WCAG 2.1.1; axe scrollable-region-focusable flagged pages on six sites, 2026-11-02). Each
// such box gets tabindex="0", so Tab reaches it and the arrow keys scroll it. A box with a link or a button inside is
// such box gets tabindex="0", so Tab reaches it and the arrow keys scroll it. A box with a link or a button inside is
@@ -1771,7 +1804,7 @@
@@ -1771,7 +1804,7 @@
}
}
// The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,
// The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,
// or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).
// or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).
- if (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers();
+ if (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers(); if (_config.forms !== false) watchForms();
nav.innerHTML = `
nav.innerHTML = `
${brandHTML(brand)}
${brandHTML(brand)}
<div class="openvibe-navbar-links">
<div class="openvibe-navbar-links">
new file mode 100644
new file mode 100644
@@ -0,0 +1,215 @@
@@ -0,0 +1,215 @@
+/* openvibe-shared/ov-forms.css — OpenVibe's own form controls, on every site (docs/forms.md). ov-forms.js draws the
+ * select (a button, its menu on a computer, its sheet on a phone); the rest is this file alone:
+ *
+ * every page checkboxes, radios, sliders and progress take the site's accent; a checkbox with role="switch" is a
+ * switch; a file input's button is a button; nothing here moves a site's layout (all of it is
+ * :where(), so a site's own rule always wins)
+ * opted in .ov-field (a label, a control, a hint, an error) and .ov-input: the full field look;
+ * .ov-choices / .ov-choice: a choice drawn as a card (radio or checkbox inside)
+ * opted out data-ov-native on a control or any ancestor, or <html data-ov-forms="off">
+ *
+ * Colours come from the site's theme (--accent, --bg-card, --border, --text-primary …), with system colours when a site
+ * has none, so light and dark both follow the site. */
+
+/* ── The select's button ─────────────────────────────────────────────────────────────────────────────────────── */
+.ov-sel {
+ --ovf-accent: var(--accent, #3b82f6);
+ -webkit-appearance: none; appearance: none; box-sizing: border-box;
+ display: inline-flex; align-items: center; gap: 8px; max-width: 100%; min-width: 0; min-height: 2.6em;
+ margin: 0; padding: .45em .65em .45em .8em;
+ font: inherit; line-height: 1.3; text-align: start; letter-spacing: inherit;
+ color: var(--text-primary, CanvasText);
+ background: var(--bg-input, var(--bg-tertiary, Field));
+ border: 1px solid var(--border, color-mix(in srgb, CanvasText 20%, transparent));
+ border-radius: var(--ov-radius-1, 10px);
+ cursor: pointer; -webkit-tap-highlight-color: transparent;
+ transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
+}
+.ov-sel.is-block { display: flex; }
+.ov-sel.is-full { width: 100%; }
+.ov-sel[hidden] { display: none; }
+.ov-sel:hover:not(:disabled) { border-color: color-mix(in srgb, var(--ovf-accent) 50%, var(--border, color-mix(in srgb, CanvasText 20%, transparent))); }
+.ov-sel:focus-visible { outline: none; border-color: var(--ovf-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovf-accent) 32%, transparent); }
+.ov-sel[aria-expanded="true"] { border-color: var(--ovf-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovf-accent) 18%, transparent); }
+.ov-sel:disabled { opacity: .55; cursor: not-allowed; }
+.ov-sel.is-invalid { border-color: var(--danger, #ef4444); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger, #ef4444) 22%, transparent); }
+.ov-sel-value { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; }
+.ov-sel-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ov-sel.is-empty .ov-sel-text { color: var(--text-muted, color-mix(in srgb, CanvasText 55%, Canvas)); }
+.ov-sel-chev { flex: none; display: inline-grid; place-items: center; width: 18px; height: 18px; color: var(--text-muted, color-mix(in srgb, CanvasText 55%, Canvas)); }
+.ov-sel-chev svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; transition: transform .18s ease; }
+.ov-sel[aria-expanded="true"] .ov-sel-chev svg { transform: rotate(180deg); }
+.ov-sel .ov-sel-badge { margin-inline-start: auto; }
+.ov-sel-native {
+ position: absolute !important; width: 1px !important; height: 1px !important; min-width: 0 !important; min-height: 0 !important;
+ margin: 0 !important; padding: 0 !important; border: 0 !important; opacity: 0 !important; pointer-events: none !important;
+ clip-path: inset(50%) !important; overflow: hidden !important;
+}
+
+/* ── The list: the menu, the sheet, and their rows ───────────────────────────────────────────────────────────── */
+.ov-sel-pop, .ov-sel-sheet {
+ --ovf-accent: var(--accent, #3b82f6);
+ --ovf-surface: var(--bg-card, var(--bg-secondary, Canvas));
+ --ovf-text: var(--text-primary, CanvasText);
+ --ovf-muted: var(--text-muted, var(--text-secondary, color-mix(in srgb, CanvasText 58%, Canvas)));
+ --ovf-line: var(--border, color-mix(in srgb, CanvasText 14%, transparent));
+ --ovf-hover: var(--bg-hover, color-mix(in srgb, CanvasText 7%, transparent));
+ --ovf-ok: var(--success, #22c55e); --ovf-warn: var(--warning, #f59e0b); --ovf-bad: var(--danger, #ef4444);
+ box-sizing: border-box; z-index: 2147483000; display: flex; flex-direction: column;
+ color: var(--ovf-text); background: var(--ovf-surface);
+ font-family: inherit; font-size: 14px; line-height: 1.35; text-align: start;
+}
+.ov-sel-pop *, .ov-sel-sheet *, .ov-sel-pop *::before, .ov-sel-sheet *::after { box-sizing: border-box; }
+.ov-sel-pop {
+ position: fixed; max-width: min(460px, calc(100vw - 16px)); padding: 6px; overflow: hidden;
+ border: 1px solid var(--ovf-line); border-radius: 12px;
+ box-shadow: 0 18px 48px -12px rgb(0 0 0 / .42), 0 3px 10px rgb(0 0 0 / .14);
+ opacity: 0; transform: translateY(-4px) scale(.985); transform-origin: top center;
+ transition: opacity .14s ease, transform .16s cubic-bezier(.2, .8, .2, 1);
+}
+.ov-sel-pop.is-up { transform: translateY(4px) scale(.985); transform-origin: bottom center; }
+.ov-sel-pop.is-open { opacity: 1; transform: none; }
+.ov-sel-sheet {
+ position: fixed; left: 0; right: 0; bottom: 0; max-height: min(86dvh, 680px); max-height: min(86vh, 680px);
+ padding: 0 max(8px, env(safe-area-inset-right, 0px)) max(10px, env(safe-area-inset-bottom, 0px)) max(8px, env(safe-area-inset-left, 0px));
+ border-radius: var(--ov-radius-3, 20px) var(--ov-radius-3, 20px) 0 0;
+ box-shadow: 0 -14px 44px rgb(0 0 0 / .38);
+ font-size: 16px; transform: translateY(100%); transition: transform .26s cubic-bezier(.2, .85, .25, 1);
+}
+.ov-sel-sheet.is-open { transform: none; }
+.ov-sel-scrim { position: fixed; inset: 0; z-index: 2147482999; background: rgb(6 10 16 / .5); opacity: 0; transition: opacity .2s ease; -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); }
+.ov-sel-scrim.is-open { opacity: 1; }
+html.ov-sel-locked, html.ov-sel-locked body { overflow: hidden; overscroll-behavior: none; }
+.ov-sel-sheet-head { position: relative; display: flex; align-items: center; gap: 12px; padding: 18px 8px 8px 12px; touch-action: none; cursor: grab; }
+.ov-sel-grip { position: absolute; top: 7px; left: 50%; width: 38px; height: 5px; margin-left: -19px; border-radius: 99px; background: color-mix(in srgb, var(--ovf-text) 22%, transparent); }
+.ov-sel-sheet-title { flex: 1; min-width: 0; font-weight: 650; font-size: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ov-sel-done { min-height: 44px; padding: 0 12px; border: 0; border-radius: 10px; background: none; color: var(--ovf-accent); font: inherit; font-weight: 650; cursor: pointer; }
+.ov-sel-done:focus-visible { outline: 2px solid var(--ovf-accent); outline-offset: 2px; }
+
+.ov-sel-search { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; padding: 0 10px; min-height: 40px; border-radius: 9px; background: var(--ovf-hover); color: var(--ovf-muted); cursor: text; }
+.ov-sel-sheet .ov-sel-search { margin: 4px 4px 8px; min-height: 46px; border-radius: 12px; }
+.ov-sel-search svg { flex: none; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
+.ov-sel-search input { flex: 1; min-width: 0; height: 100%; min-height: 36px; padding: 0; border: 0; outline: 0; background: none; color: var(--ovf-text); font: inherit; }
+.ov-sel-sheet .ov-sel-search input { font-size: 16px; } /* a phone does not zoom into a 16 px field */
+.ov-sel-search input::-webkit-search-cancel-button { cursor: pointer; }
+
+.ov-sel-list { flex: 1; min-height: 0; margin: 0; padding: 0; list-style: none; overflow-y: auto; overscroll-behavior: contain; outline: none; scrollbar-width: thin; }
+.ov-sel-opt {
+ display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 8px 10px; margin: 1px 0;
+ border-radius: 8px; cursor: pointer; scroll-margin: 6px; -webkit-tap-highlight-color: transparent;
+}
+.ov-sel-sheet .ov-sel-opt { min-height: 54px; padding: 10px 12px; border-radius: 12px; }
+.ov-sel-opt:hover, .ov-sel-opt.is-active { background: var(--ovf-hover); }
+.ov-sel-opt.is-active { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovf-accent) 35%, transparent); }
+@media (hover: none) { .ov-sel-opt.is-active:not(:focus-within) { box-shadow: none; } }
+.ov-sel-opt[aria-selected="true"] { background: color-mix(in srgb, var(--ovf-accent) 11%, transparent); }
+.ov-sel-opt[aria-selected="true"] .ov-sel-label { font-weight: 650; }
+.ov-sel-opt.is-disabled { cursor: default; }
+.ov-sel-opt.is-disabled .ov-sel-words { opacity: .55; }
+.ov-sel-words { flex: 1; min-width: 0; display: grid; gap: 2px; }
+.ov-sel-label { overflow-wrap: anywhere; }
+.ov-sel-desc { color: var(--ovf-muted); font-size: .86em; line-height: 1.35; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
+.ov-sel-mark { flex: none; display: inline-grid; place-items: center; width: 18px; height: 18px; color: var(--ovf-accent); }
+.ov-sel-mark svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round; opacity: 0; transform: scale(.6); transition: opacity .12s ease, transform .14s ease; }
+.ov-sel-opt[aria-selected="true"] .ov-sel-mark svg { opacity: 1; transform: none; }
+.ov-sel-mark.is-box { border: 1.5px solid color-mix(in srgb, var(--ovf-text) 35%, transparent); border-radius: 5px; color: #fff; }
+.ov-sel-mark.is-box svg { width: 13px; height: 13px; stroke-width: 3; }
+.ov-sel-opt[aria-selected="true"] .ov-sel-mark.is-box { background: var(--ovf-accent); border-color: var(--ovf-accent); color: var(--on-accent, #fff); }
+.ov-sel-icon { flex: none; display: inline-grid; place-items: center; min-width: 22px; height: 22px; font-size: 15px; line-height: 1; }
+.ov-sel-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: color-mix(in srgb, CanvasText 30%, transparent); }
+.ov-sel-dot.is-ok { background: var(--ovf-ok, var(--success, #22c55e)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success, #22c55e) 20%, transparent); }
+.ov-sel-dot.is-warn { background: var(--ovf-warn, var(--warning, #f59e0b)); }
+.ov-sel-dot.is-bad { background: var(--ovf-bad, var(--danger, #ef4444)); }
+.ov-sel-dot.is-new { background: var(--accent, #3b82f6); }
+.ov-sel-badge {
+ flex: none; display: inline-flex; align-items: center; min-height: 20px; padding: 1px 8px; border-radius: 999px;
+ font-size: 11.5px; font-weight: 600; line-height: 1.3; white-space: nowrap;
+ color: var(--text-secondary, color-mix(in srgb, CanvasText 70%, Canvas)); background: color-mix(in srgb, CanvasText 8%, transparent);
+}
+.ov-sel-badge.is-ok { color: var(--success-text, var(--success, #15803d)); background: color-mix(in srgb, var(--success, #22c55e) 15%, transparent); }
+.ov-sel-badge.is-warn { color: var(--warning-text, var(--warning, #b45309)); background: color-mix(in srgb, var(--warning, #f59e0b) 17%, transparent); }
+.ov-sel-badge.is-bad { color: var(--danger-text, var(--danger, #b91c1c)); background: color-mix(in srgb, var(--danger, #ef4444) 15%, transparent); }
+.ov-sel-badge.is-new { color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
+.ov-sel-link { flex: none; display: inline-flex; align-items: center; min-height: 30px; padding: 0 10px; border-radius: 999px; color: var(--ovf-accent); background: color-mix(in srgb, var(--ovf-accent) 12%, transparent); font-size: 12.5px; font-weight: 650; text-decoration: none; white-space: nowrap; }
+.ov-sel-sheet .ov-sel-link { min-height: 38px; padding: 0 14px; font-size: 14px; }
+.ov-sel-link:hover { background: color-mix(in srgb, var(--ovf-accent) 20%, transparent); }
+.ov-sel-link:focus-visible { outline: 2px solid var(--ovf-accent); outline-offset: 2px; }
+.ov-sel-group { padding: 10px 10px 4px; color: var(--ovf-muted); font-size: 11px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; }
+.ov-sel-group:first-child { padding-top: 4px; }
+.ov-sel-none { padding: 18px 12px; color: var(--ovf-muted); text-align: center; }
+
+/* ── Every page: the browser's own boxes, in the site's colours (nothing here changes a size) ─────────────────── */
+:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"], input[type="radio"], input[type="range"], progress, meter):where(:not([data-ov-native], [data-ov-native] *)) {
+ accent-color: var(--accent, #3b82f6);
+}
+:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"], input[type="radio"], input[type="range"]):where(:not([data-ov-native], [data-ov-native] *)):focus-visible {
+ outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px;
+}
+:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"][role="switch"]):where(:not([data-ov-native], [data-ov-native] *)) {
+ -webkit-appearance: none; appearance: none; box-sizing: border-box; flex: none; vertical-align: middle;
+ width: 42px; height: 24px; margin: 0; border: 0; border-radius: 999px; cursor: pointer;
+ background-color: color-mix(in srgb, CanvasText 26%, transparent);
+ background-image: radial-gradient(circle, #fff 0 8.5px, transparent 9px);
+ background-repeat: no-repeat; background-size: 20px 20px; background-position: 2px 50%;
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, CanvasText 10%, transparent);
+ transition: background-color .18s ease, background-position .2s cubic-bezier(.3, .8, .3, 1);
+}
+:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"][role="switch"]:checked):where(:not([data-ov-native], [data-ov-native] *)) {
+ background-color: var(--accent, #3b82f6); background-position: calc(100% - 2px) 50%;
+}
+:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"][role="switch"]:disabled) { opacity: .5; cursor: not-allowed; }
+:where(html:not([data-ov-forms="off"])) :where(input[type="file"]:not([data-ov-native]))::file-selector-button {
+ font: inherit; font-weight: 600; margin-inline-end: .75em; padding: .38em .85em; cursor: pointer;
+ color: var(--text-primary, CanvasText); background: var(--bg-hover, color-mix(in srgb, CanvasText 8%, transparent));
+ border: 1px solid var(--border, color-mix(in srgb, CanvasText 18%, transparent)); border-radius: 8px;
+}
+
+/* ── Opted in: a field, an input, choices as cards ──────────────────────────────────────────────────────────── */
+.ov-field { --ovf-accent: var(--accent, #3b82f6); display: grid; gap: 6px; min-width: 0; }
+.ov-field > label, .ov-field-label { font-size: 13px; font-weight: 650; color: var(--text-primary, CanvasText); }
+.ov-field-hint { margin: 0; font-size: 12.5px; line-height: 1.45; color: var(--text-muted, color-mix(in srgb, CanvasText 58%, Canvas)); }
+.ov-field-error { margin: 0; font-size: 12.5px; line-height: 1.45; color: var(--danger-text, var(--danger, #dc2626)); }
+.ov-input,
+.ov-field :where(input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="color"], [type="hidden"]), textarea) {
+ -webkit-appearance: none; appearance: none; box-sizing: border-box; width: 100%; min-width: 0; min-height: 44px; margin: 0;
+ padding: 10px 12px; font: inherit; line-height: 1.4; color: var(--text-primary, CanvasText);
+ background: var(--bg-input, var(--bg-tertiary, Field));
+ border: 1px solid var(--border, color-mix(in srgb, CanvasText 20%, transparent)); border-radius: var(--ov-radius-1, 10px);
+ transition: border-color .15s ease, box-shadow .15s ease;
+}
+.ov-field textarea, textarea.ov-input { min-height: 96px; resize: vertical; }
+.ov-field .ov-sel { min-height: 44px; padding-inline-start: 12px; }
+.ov-input::placeholder, .ov-field :is(input, textarea)::placeholder { color: var(--text-muted, color-mix(in srgb, CanvasText 45%, Canvas)); opacity: 1; }
+.ov-input:hover, .ov-field :is(input, textarea):hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, color-mix(in srgb, CanvasText 20%, transparent))); }
+.ov-input:focus-visible, .ov-field :is(input, textarea):focus-visible, .ov-input:focus, .ov-field :is(input, textarea):focus {
+ outline: none; border-color: var(--accent, #3b82f6); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent);
+}
+.ov-input[aria-invalid="true"], .ov-field :is(input, textarea)[aria-invalid="true"], .ov-input:user-invalid, .ov-field :is(input, textarea):user-invalid {
+ border-color: var(--danger, #ef4444);
+}
+@media (pointer: coarse) { .ov-input, .ov-field :is(input, textarea), .ov-sel { font-size: max(16px, 1em); } }
+
+.ov-choices { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr)); gap: 10px; }
+.ov-choice {
+ position: relative; display: flex; align-items: flex-start; gap: 12px; min-height: 64px; padding: 14px; box-sizing: border-box;
+ border: 1px solid var(--border, color-mix(in srgb, CanvasText 18%, transparent)); border-radius: var(--ov-radius-2, 14px);
+ background: var(--bg-card, transparent); cursor: pointer; transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
+}
+.ov-choice:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, transparent)); }
+.ov-choice:has(input:checked) { border-color: var(--accent, #3b82f6); box-shadow: 0 0 0 1px var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 7%, var(--bg-card, transparent)); }
+.ov-choice:has(input:focus-visible) { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
+.ov-choice:has(input:disabled) { opacity: .6; cursor: not-allowed; }
+.ov-choice > input { flex: none; margin: 3px 0 0; width: 18px; height: 18px; accent-color: var(--accent, #3b82f6); }
+.ov-choice-body { display: grid; gap: 3px; min-width: 0; }
+.ov-choice-title { font-weight: 650; }
+.ov-choice-desc { font-size: 12.5px; line-height: 1.45; color: var(--text-muted, color-mix(in srgb, CanvasText 58%, Canvas)); }
+.ov-choice .ov-sel-badge { position: absolute; top: 12px; right: 12px; }
+
+@media (prefers-reduced-motion: reduce) {
+ .ov-sel, .ov-sel-chev svg, .ov-sel-pop, .ov-sel-sheet, .ov-sel-scrim, .ov-sel-mark svg, .ov-choice, .ov-input { transition: none !important; }
+ :where(input[type="checkbox"][role="switch"]) { transition: none; }
+}
+@media (forced-colors: active) {
+ .ov-sel, .ov-sel-pop, .ov-sel-sheet { border: 1px solid CanvasText; }
+ .ov-sel-opt.is-active, .ov-sel-opt[aria-selected="true"] { outline: 2px solid Highlight; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,427 @@
@@ -0,0 +1,427 @@
+/**
+ * openvibe-shared/ov-forms.js — OpenVibe's own select, on every site (the owner, 2026-11-03: "a custom system for those
+ * select inputs … shared across the network and all websites rather than using default stuff … mobile, desktop, etc.
+ * device specific support that looks amazing"). The look is ov-forms.css; docs/forms.md describes both.
+ *
+ * Every <select> becomes a button that opens OpenVibe's own list: a menu under it on a computer, a sheet from the
+ * bottom on a phone. The <select> stays in the form, hidden, and holds the value: a form posts as it did, a script that
+ * sets select.value or listens for change still works, and with no script the page has its plain select.
+ *
+ * An <option> may say more than its label:
+ * data-desc="…" a second line under it
+ * data-badge="…" a short tag on its right ("Signed in", "72% used")
+ * data-state="ok|warn|bad|off|new" the tag's tone, and a dot before the label
+ * data-icon="…" one or two characters before the label (an emoji, a letter)
+ * data-href="/setup" a link on the row ("Set up"), for a choice that is not ready: disabled options keep it
+ * data-href-label="…" the link's words
+ * A <select> may say:
+ * data-ov-native keep the browser's own select (also on any ancestor, or <html data-ov-forms="off">)
+ * data-ov-search="on|off" a search box (on by itself from 9 choices)
+ * data-ov-title="…" the phone sheet's title (else the select's label)
+ *
+ * OVForms.enhance(root) every select under root (the navbar calls it once the page settles, and for new markup)
+ * OVForms.refresh(select) read the select again (its options changed in a way the page did not announce)
+ * OVForms.open(select) open its list
+ *
+ * Keyboard: Enter, Space, ↓ or ↑ open it; ↑ ↓ Home End move; typing searches (or jumps, without a search box); Enter
+ * chooses; Escape and Tab close. A multiple select keeps its list open and says how many are chosen.
+ */
+(function (root) {
+ 'use strict';
+ if (!root || !root.document || root.OVForms) return;
+ const doc = root.document;
+ const SEARCH_FROM = 9;
+ const PHONE = '(max-width: 640px), (pointer: coarse) and (max-width: 900px)';
+ const done = new WeakMap(); // select → its state
+ let open = null; // the state whose list is open
+ let uid = 0;
+ const reduce = () => !!(root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches);
+ const phone = () => !!(root.matchMedia && root.matchMedia(PHONE).matches);
+ const el = (tag, cls, attrs) => {
+ const n = doc.createElement(tag);
+ if (cls) n.className = cls;
+ if (attrs) for (const k of Object.keys(attrs)) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
+ return n;
+ };
+ const CHEVRON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>';
+ const TICK = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';
+ const LOUPE = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/></svg>';
+
+ // ── The look: ov-forms.css beside this file, once (the navbar adds it with its own ?v=; a page may have it already).
+ function stylesheet() {
+ const have = doc.querySelector('link[href*="ov-forms.css"]');
+ if (have) return have;
+ const me = doc.currentScript || doc.querySelector('script[src*="ov-forms.js"]');
+ const src = me && me.src ? me.src : '/shared/ov-forms.js';
+ const link = el('link', null, { rel: 'stylesheet', href: src.replace(/ov-forms\.js(\?[^#]*)?.*$/, 'ov-forms.css$1') });
+ doc.head.appendChild(link);
+ return link;
+ }
+ const sheetReady = new Promise((resolve) => {
+ const link = stylesheet();
+ if (link.sheet) { resolve(); return; }
+ link.addEventListener('load', () => resolve(), { once: true });
+ link.addEventListener('error', () => resolve(false), { once: true });
+ setTimeout(resolve, 4000);
+ });
+
+ // A select drawn as a list box (a size above 1) stays the browser's: it is already a list on the page.
+ const skip = (s) => !s || (s.hasAttribute('size') && s.size > 1)
+ || s.closest('[data-ov-native]') || doc.documentElement.getAttribute('data-ov-forms') === 'off';
+
+ /** The words that name a select: its label(s), aria-label, or the title the page gave its sheet. */
+ function nameOf(s) {
+ if (s.dataset.ovTitle) return s.dataset.ovTitle;
+ const labels = s.labels ? Array.prototype.slice.call(s.labels) : [];
+ for (const l of labels) {
+ const copy = l.cloneNode(true);
+ copy.querySelectorAll('select,.ov-sel,small,.ov-sel-native').forEach((n) => n.remove());
+ const t = copy.textContent.replace(/\s+/g, ' ').trim();
+ if (t) return t;
+ }
+ return s.getAttribute('aria-label') || s.title || 'Choose';
+ }
+
+ /** One option as the list draws it. */
+ function itemOf(o, i) {
+ const d = o.dataset || {};
+ return {
+ i, value: o.value, label: (o.label || o.textContent || '').replace(/\s+/g, ' ').trim(),
+ desc: d.desc || '', badge: d.badge || '', state: d.state || '', icon: d.icon || '',
+ href: d.href || '', hrefLabel: d.hrefLabel || 'Set up',
+ disabled: o.disabled || !!(o.parentNode && o.parentNode.disabled), group: o.parentNode && o.parentNode.tagName === 'OPTGROUP' ? o.parentNode.label : '',
+ hidden: o.hidden,
+ };
+ }
+
+ function enhanceOne(s) {
+ if (done.has(s) || skip(s)) return;
+ const st = { s, id: `ov-sel-${++uid}` };
+ done.set(s, st);
+ const cs = root.getComputedStyle(s);
+ const parent = s.parentElement;
+ // The button takes the select's place in the layout: its box, its margins, its share of a row or a grid.
+ const btn = el('button', 'ov-sel', { type: 'button', id: `${st.id}-btn`, 'aria-haspopup': 'listbox', 'aria-expanded': 'false' });
+ btn.innerHTML = `<span class="ov-sel-value"></span><span class="ov-sel-chev">${CHEVRON}</span>`;
+ const keep = ['margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'flex', 'flex-basis', 'flex-grow', 'flex-shrink', 'align-self', 'justify-self',
+ 'grid-column', 'grid-row', 'order', 'max-width', 'vertical-align', 'font-size', 'border-radius'];
+ for (const p of keep) { const v = cs.getPropertyValue(p); if (v && v !== 'auto' && v !== 'normal' && v !== '0px' && v !== 'none') btn.style.setProperty(p, v); }
+ if (cs.display === 'block' || cs.display === 'flex' || cs.display === 'grid') btn.classList.add('is-block');
+ const pw = parent ? parent.clientWidth - parseFloat(root.getComputedStyle(parent).paddingLeft || 0) - parseFloat(root.getComputedStyle(parent).paddingRight || 0) : 0;
+ if (s.offsetWidth && pw && s.offsetWidth >= pw - 1) btn.classList.add('is-full');
+ else if (s.offsetWidth) btn.style.minWidth = `${s.offsetWidth}px`;
+ // Inside an .ov-field the field's own height rules (44 px, the same as its text inputs); elsewhere the select's.
+ if (s.offsetHeight && !s.closest('.ov-field')) btn.style.minHeight = `${s.offsetHeight}px`;
+ if (s.className) btn.dataset.from = s.className.slice(0, 120);
+ s.insertAdjacentElement('afterend', btn);
+ s.classList.add('ov-sel-native');
+ s.tabIndex = -1;
+ s.setAttribute('aria-hidden', 'true');
+ st.btn = btn;
+ const ids = (s.labels ? Array.prototype.slice.call(s.labels) : []).map((l) => { if (!l.id) l.id = `${st.id}-l${Math.random().toString(36).slice(2, 6)}`; return l.id; });
+ btn.setAttribute('aria-labelledby', [...ids, `${st.id}-btn`].join(' '));
+ if (!ids.length) btn.setAttribute('aria-label', nameOf(s));
+
+ // A label's click and a script's focus() land on the hidden select: the button takes them.
+ s.addEventListener('focus', () => btn.focus());
+ s.addEventListener('change', () => sync(st));
+ s.addEventListener('input', () => sync(st));
+ s.addEventListener('invalid', (e) => { e.preventDefault(); btn.classList.add('is-invalid'); btn.setAttribute('aria-invalid', 'true'); btn.title = s.validationMessage || ''; btn.focus(); });
+ const form = s.form;
+ if (form) form.addEventListener('reset', () => setTimeout(() => sync(st), 0));
+ // A script that sets the value or the index says nothing: these two are watched on this select only.
+ for (const prop of ['value', 'selectedIndex']) {
+ const d = Object.getOwnPropertyDescriptor(root.HTMLSelectElement.prototype, prop);
+ if (!d || !d.set) continue;
+ try {
+ Object.defineProperty(s, prop, { configurable: true, enumerable: true, get() { return d.get.call(this); }, set(v) { d.set.call(this, v); sync(st); } });
+ } catch { /* a frozen element: the change events still sync */ }
+ }
+ if (typeof root.MutationObserver === 'function') {
+ st.watch = new root.MutationObserver(() => { if (!s.isConnected) { teardown(st); return; } sync(st); if (open === st) draw(st); });
+ st.watch.observe(s, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled', 'hidden', 'selected', 'label', 'data-desc', 'data-badge', 'data-state', 'data-href', 'class', 'style', 'required'] });
+ }
+ btn.addEventListener('click', () => (open === st ? close() : show(st)));
+ btn.addEventListener('keydown', (e) => {
+ if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key) && open !== st) { e.preventDefault(); show(st, e.key === 'ArrowUp' ? -1 : 0); return; }
+ if (!open && e.key.length === 1 && /\S/.test(e.key) && !s.multiple) jump(st, e.key);
+ });
+ sync(st);
+ }
+
+ function teardown(st) {
+ if (st.watch) st.watch.disconnect();
+ if (open === st) close();
+ if (st.btn && st.btn.isConnected) st.btn.remove();
+ done.delete(st.s);
+ }
+
+ /** The button says what is chosen, and mirrors the select's state. */
+ function sync(st) {
+ const { s, btn } = st;
+ if (!btn) return;
+ const chosen = Array.prototype.filter.call(s.options, (o) => o.selected);
+ const value = btn.querySelector('.ov-sel-value');
+ value.textContent = '';
+ if (s.multiple) {
+ value.textContent = chosen.length === 0 ? (s.dataset.placeholder || 'None') : chosen.length <= 2 ? chosen.map((o) => itemOf(o).label).join(', ') : `${chosen.length} chosen`;
+ btn.classList.toggle('is-empty', !chosen.length);
+ } else {
+ const o = chosen[0] || s.options[s.selectedIndex] || null;
+ const it = o ? itemOf(o, o.index) : null;
+ if (it && it.state) value.appendChild(el('span', `ov-sel-dot is-${it.state}`, { 'aria-hidden': 'true' }));
+ else if (it && it.icon) { const ic = el('span', 'ov-sel-icon', { 'aria-hidden': 'true' }); ic.textContent = it.icon; value.appendChild(ic); }
+ const text = el('span', 'ov-sel-text');
+ text.textContent = it ? it.label : (s.dataset.placeholder || '');
+ value.appendChild(text);
+ if (it && it.badge) { const b = el('span', `ov-sel-badge${it.state ? ` is-${it.state}` : ''}`); b.textContent = it.badge; value.appendChild(b); }
+ btn.classList.toggle('is-empty', !it || it.value === '');
+ }
+ btn.disabled = s.disabled;
+ btn.hidden = s.hidden || root.getComputedStyle(s).display === 'none';
+ if (s.validity && s.validity.valid) { btn.classList.remove('is-invalid'); btn.removeAttribute('aria-invalid'); btn.removeAttribute('title'); }
+ if (s.required) btn.setAttribute('aria-required', 'true'); else btn.removeAttribute('aria-required');
+ }
+
+ /** Typing on the closed button: the next choice whose label starts with what was typed. */
+ function jump(st, ch) {
+ st.typed = (Date.now() - (st.typedAt || 0) < 700 ? st.typed || '' : '') + ch.toLowerCase();
+ st.typedAt = Date.now();
+ const opts = Array.prototype.slice.call(st.s.options);
+ const from = st.s.selectedIndex + (st.typed.length === 1 ? 1 : 0);
+ const order = [...opts.slice(from), ...opts.slice(0, from)];
+ const hit = order.find((o) => !o.disabled && !o.hidden && itemOf(o).label.toLowerCase().startsWith(st.typed));
+ if (hit) choose(st, hit.index);
+ }
+
+ function choose(st, index, keepOpen) {
+ const { s } = st;
+ const o = s.options[index];
+ if (!o || o.disabled) return;
+ if (s.multiple) o.selected = !o.selected;
+ else { if (s.selectedIndex === index) { if (!keepOpen) close(true); return; } o.selected = true; }
+ s.dispatchEvent(new root.Event('input', { bubbles: true }));
+ s.dispatchEvent(new root.Event('change', { bubbles: true }));
+ sync(st);
+ if (s.multiple || keepOpen) draw(st); else close(true);
+ }
+
+ // ── The list: a menu under the button, or a sheet from the bottom on a phone ──
+ function show(st, startAt) {
+ if (open) close();
+ if (st.s.disabled) return;
+ open = st;
+ const sheet = phone();
+ st.sheet = sheet;
+ const items = Array.prototype.map.call(st.s.options, itemOf).filter((x) => !x.hidden);
+ const searchy = st.s.dataset.ovSearch === 'on' || (st.s.dataset.ovSearch !== 'off' && items.length >= SEARCH_FROM);
+ const wrap = el('div', sheet ? 'ov-sel-sheet' : 'ov-sel-pop', { id: `${st.id}-pop`, role: sheet ? 'dialog' : null, 'aria-modal': sheet ? 'true' : null, 'aria-label': sheet ? nameOf(st.s) : null });
+ if (sheet) {
+ const head = el('div', 'ov-sel-sheet-head');
+ head.innerHTML = '<span class="ov-sel-grip" aria-hidden="true"></span>';
+ const title = el('span', 'ov-sel-sheet-title');
+ title.textContent = nameOf(st.s);
+ const doneBtn = el('button', 'ov-sel-done', { type: 'button' });
+ doneBtn.textContent = st.s.multiple ? 'Done' : 'Close';
+ doneBtn.addEventListener('click', () => close(true));
+ head.append(title, doneBtn);
+ wrap.appendChild(head);
+ dragToClose(head, wrap);
+ }
+ if (searchy) {
+ const box = el('label', 'ov-sel-search');
+ box.innerHTML = LOUPE;
+ const input = el('input', null, { type: 'search', placeholder: 'Search', autocomplete: 'off', spellcheck: 'false', 'aria-controls': `${st.id}-list`, 'aria-label': `Search ${nameOf(st.s)}` });
+ input.addEventListener('input', () => { st.query = input.value.trim().toLowerCase(); st.active = null; draw(st); });
+ box.appendChild(input);
+ wrap.appendChild(box);
+ st.input = input;
+ } else st.input = null;
+ const list = el('ul', 'ov-sel-list', { id: `${st.id}-list`, role: 'listbox', tabindex: searchy ? null : '-1', 'aria-multiselectable': st.s.multiple ? 'true' : null, 'aria-label': nameOf(st.s) });
+ wrap.appendChild(list);
+ st.wrap = wrap; st.list = list; st.query = '';
+ const current = st.s.selectedIndex;
+ st.active = startAt === -1 ? last(st, items) : (current >= 0 ? current : null);
+ if (sheet) {
+ st.scrim = el('div', 'ov-sel-scrim');
+ st.scrim.addEventListener('click', () => close(true));
+ doc.body.append(st.scrim, wrap);
+ doc.documentElement.classList.add('ov-sel-locked');
+ } else doc.body.appendChild(wrap);
+ draw(st);
+ st.btn.setAttribute('aria-expanded', 'true');
+ st.btn.setAttribute('aria-controls', `${st.id}-pop`);
+ if (!sheet) place(st);
+ requestAnimationFrame(() => {
+ wrap.classList.add('is-open');
+ if (st.scrim) st.scrim.classList.add('is-open');
+ (st.input && !sheet ? st.input : list).focus({ preventScroll: true });
+ const a = list.querySelector('[aria-selected="true"], .is-active');
+ if (a && a.scrollIntoView) a.scrollIntoView({ block: 'nearest' });
+ });
+ wrap.addEventListener('keydown', (e) => keys(st, e));
+ list.addEventListener('pointerdown', (e) => { if (e.target.closest('.ov-sel-opt')) e.preventDefault(); });
+ list.addEventListener('click', (e) => {
+ if (e.target.closest('a')) return; // a row's own link goes where it says
+ const row = e.target.closest('.ov-sel-opt');
+ if (row && row.getAttribute('aria-disabled') !== 'true') choose(st, Number(row.dataset.i));
+ });
+ }
+
+ function last(st, items) { for (let k = items.length - 1; k >= 0; k--) if (!items[k].disabled) return items[k].i; return null; }
+
+ /** The rows that match the search, in groups when the select has them. */
+ function draw(st) {
+ const { s, list } = st;
+ if (!list) return;
+ const q = st.query || '';
+ const items = Array.prototype.map.call(s.options, itemOf).filter((x) => !x.hidden && (!q || `${x.label} ${x.desc} ${x.badge}`.toLowerCase().includes(q)));
+ list.textContent = '';
+ let group = null;
+ for (const it of items) {
+ if (it.group && it.group !== group) {
+ group = it.group;
+ const g = el('li', 'ov-sel-group', { role: 'presentation' });
+ g.textContent = group;
+ list.appendChild(g);
+ }
+ const selected = s.options[it.i].selected;
+ const row = el('li', `ov-sel-opt${it.disabled ? ' is-disabled' : ''}${st.active === it.i ? ' is-active' : ''}`, {
+ id: `${st.id}-o${it.i}`, role: 'option', 'aria-selected': selected ? 'true' : 'false', 'aria-disabled': it.disabled ? 'true' : null, 'data-i': String(it.i),
+ });
+ const mark = el('span', `ov-sel-mark${s.multiple ? ' is-box' : ''}`, { 'aria-hidden': 'true' });
+ mark.innerHTML = TICK;
+ const lead = it.state ? el('span', `ov-sel-dot is-${it.state}`, { 'aria-hidden': 'true' }) : it.icon ? el('span', 'ov-sel-icon', { 'aria-hidden': 'true' }) : null;
+ if (lead && it.icon && !it.state) lead.textContent = it.icon;
+ const words = el('span', 'ov-sel-words');
+ const label = el('span', 'ov-sel-label');
+ label.textContent = it.label || ' ';
+ words.appendChild(label);
+ if (it.desc) { const d = el('span', 'ov-sel-desc'); d.textContent = it.desc; words.appendChild(d); }
+ row.append(...[s.multiple ? mark : null, lead, words].filter(Boolean));
+ if (it.badge) { const b = el('span', `ov-sel-badge${it.state ? ` is-${it.state}` : ''}`); b.textContent = it.badge; row.appendChild(b); }
+ if (it.href) { const a = el('a', 'ov-sel-link', { href: it.href }); a.textContent = it.hrefLabel; row.appendChild(a); }
+ if (!s.multiple) row.appendChild(mark);
+ list.appendChild(row);
+ }
+ if (!items.length) { const none = el('li', 'ov-sel-none', { role: 'presentation' }); none.textContent = q ? `Nothing matches “${st.query}”` : 'Nothing to choose'; list.appendChild(none); }
+ const active = st.active != null && list.querySelector(`#${st.id}-o${st.active}`);
+ if (!active) { const first = list.querySelector('.ov-sel-opt:not(.is-disabled)'); st.active = first ? Number(first.dataset.i) : null; if (first) first.classList.add('is-active'); }
+ const target = st.input || list;
+ if (st.active != null) target.setAttribute('aria-activedescendant', `${st.id}-o${st.active}`); else target.removeAttribute('aria-activedescendant');
+ }
+
+ function move(st, step, to) {
+ const rows = Array.prototype.slice.call(st.list.querySelectorAll('.ov-sel-opt:not(.is-disabled)'));
+ if (!rows.length) return;
+ let k = rows.findIndex((r) => Number(r.dataset.i) === st.active);
+ if (to === 'first') k = 0; else if (to === 'last') k = rows.length - 1; else k = Math.max(0, Math.min(rows.length - 1, (k < 0 ? (step > 0 ? -1 : rows.length) : k) + step));
+ st.list.querySelectorAll('.is-active').forEach((r) => r.classList.remove('is-active'));
+ rows[k].classList.add('is-active');
+ st.active = Number(rows[k].dataset.i);
+ (st.input || st.list).setAttribute('aria-activedescendant', rows[k].id);
+ rows[k].scrollIntoView({ block: 'nearest' });
+ }
+
+ function keys(st, e) {
+ const k = e.key;
+ if (k === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); return; }
+ if (k === 'Tab') { close(false); return; }
+ if (k === 'ArrowDown') { e.preventDefault(); move(st, 1); return; }
+ if (k === 'ArrowUp') { e.preventDefault(); move(st, -1); return; }
+ if (k === 'PageDown') { e.preventDefault(); move(st, 8); return; }
+ if (k === 'PageUp') { e.preventDefault(); move(st, -8); return; }
+ if ((k === 'Home' || k === 'End') && !st.input) { e.preventDefault(); move(st, 0, k === 'Home' ? 'first' : 'last'); return; }
+ if (k === 'Enter' || (k === ' ' && !st.input)) { e.preventDefault(); if (st.active != null) choose(st, st.active); return; }
+ if (!st.input && k.length === 1 && /\S/.test(k)) {
+ st.typed = (Date.now() - (st.typedAt || 0) < 700 ? st.typed || '' : '') + k.toLowerCase();
+ st.typedAt = Date.now();
+ const hit = Array.prototype.find.call(st.list.querySelectorAll('.ov-sel-opt:not(.is-disabled)'), (r) => r.textContent.toLowerCase().startsWith(st.typed));
+ if (hit) { st.active = Number(hit.dataset.i); st.list.querySelectorAll('.is-active').forEach((r) => r.classList.remove('is-active')); hit.classList.add('is-active'); st.list.setAttribute('aria-activedescendant', hit.id); hit.scrollIntoView({ block: 'nearest' }); }
+ }
+ }
+
+ /** The menu sits under the button, or over it when there is more room above; never wider than the screen. */
+ function place(st) {
+ const { btn, wrap } = st;
+ if (!wrap || st.sheet) return;
+ const r = btn.getBoundingClientRect();
+ const vw = root.innerWidth; const vh = root.innerHeight;
+ const gap = 6; const edge = 8;
+ const below = vh - r.bottom - gap - edge; const above = r.top - gap - edge;
+ const up = below < 220 && above > below;
+ const room = Math.max(140, Math.min(420, up ? above : below));
+ wrap.style.minWidth = `${Math.min(r.width, vw - 2 * edge)}px`;
+ wrap.style.maxHeight = `${room}px`;
+ const w = Math.min(Math.max(wrap.offsetWidth, r.width), vw - 2 * edge);
+ const left = Math.max(edge, Math.min(r.left, vw - edge - w));
+ wrap.style.left = `${left}px`;
+ if (up) { wrap.style.top = ''; wrap.style.bottom = `${vh - r.top + gap}px`; wrap.classList.add('is-up'); }
+ else { wrap.style.bottom = ''; wrap.style.top = `${r.bottom + gap}px`; wrap.classList.remove('is-up'); }
+ }
+
+ /** The phone sheet follows a finger down from its head, and closes past a third of its height. */
+ function dragToClose(handle, sheet) {
+ let y0 = null; let dy = 0;
+ handle.addEventListener('pointerdown', (e) => { if (e.target.closest('button')) return; y0 = e.clientY; dy = 0; handle.setPointerCapture && handle.setPointerCapture(e.pointerId); sheet.style.transition = 'none'; });
+ handle.addEventListener('pointermove', (e) => { if (y0 == null) return; dy = Math.max(0, e.clientY - y0); sheet.style.transform = `translateY(${dy}px)`; });
+ const end = () => { if (y0 == null) return; y0 = null; sheet.style.transition = ''; sheet.style.transform = ''; if (dy > Math.min(160, sheet.offsetHeight / 3)) close(true); };
+ handle.addEventListener('pointerup', end);
+ handle.addEventListener('pointercancel', end);
+ }
+
+ function close(refocus) {
+ const st = open;
+ if (!st) return;
+ open = null;
+ st.btn.setAttribute('aria-expanded', 'false');
+ st.btn.removeAttribute('aria-controls');
+ const wrap = st.wrap; const scrim = st.scrim;
+ st.wrap = null; st.list = null; st.scrim = null; st.input = null;
+ doc.documentElement.classList.remove('ov-sel-locked');
+ const gone = () => { if (wrap) wrap.remove(); if (scrim) scrim.remove(); };
+ if (wrap) wrap.classList.remove('is-open');
+ if (scrim) scrim.classList.remove('is-open');
+ if (st.sheet && !reduce()) setTimeout(gone, 220); else gone();
+ if (refocus) st.btn.focus({ preventScroll: true });
+ }
+
+ doc.addEventListener('pointerdown', (e) => {
+ if (!open || open.sheet) return;
+ if (open.wrap && open.wrap.contains(e.target)) return;
+ if (open.btn.contains(e.target)) return;
+ close(false);
+ }, true);
+ root.addEventListener('resize', () => { if (open && !open.sheet) place(open); if (open && open.sheet !== phone()) close(false); });
+ doc.addEventListener('scroll', (e) => { if (open && !open.sheet && !(open.wrap && open.wrap.contains(e.target))) place(open); }, true);
+
+ function enhance(scope) {
+ const base = scope && scope.querySelectorAll ? scope : doc;
+ if (base.tagName === 'SELECT') { enhanceOne(base); return; }
+ Array.prototype.forEach.call(base.querySelectorAll('select'), enhanceOne);
+ }
+
+ // New markup (a boosted page move, a dialog, a list a script drew): looked at once the page is quiet again.
+ let soon = 0;
+ function watch() {
+ if (typeof root.MutationObserver !== 'function' || !doc.body) return;
+ new root.MutationObserver((records) => {
+ for (const r of records) for (const n of r.addedNodes) {
+ if (n.nodeType === 1 && (n.tagName === 'SELECT' || (n.querySelector && n.querySelector('select')))) {
+ if (!soon) soon = setTimeout(() => { soon = 0; enhance(doc); }, 60);
+ return;
+ }
+ }
+ }).observe(doc.body, { childList: true, subtree: true });
+ }
+
+ root.OVForms = {
+ enhance: (scope) => sheetReady.then((ok) => { if (ok !== false) enhance(scope); }),
+ refresh: (s) => { const st = done.get(s); if (st) { sync(st); if (open === st) draw(st); } },
+ open: (s) => { const st = done.get(s); if (st) show(st); },
+ close: () => close(true),
+ };
+ const start = () => sheetReady.then((ok) => { if (ok === false) return; enhance(doc); watch(); });
+ if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', start, { once: true });
+ else start();
+})(typeof window !== 'undefined' ? window : undefined);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.108.1",
+ "version": "4.109.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.108.1",
+ "version": "4.109.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.108.1",
+ "version": "4.109.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -54,6 +54,7 @@
@@ -54,6 +54,7 @@
"./openvibe-sw": "./openvibe-sw.js",
"./openvibe-sw": "./openvibe-sw.js",
"./ov-icons": "./ov-icons.js",
"./ov-icons": "./ov-icons.js",
"./ov-glyphs": "./ov-glyphs.js",
"./ov-glyphs": "./ov-glyphs.js",
+ "./ov-forms": "./ov-forms.js",
"./ov-labels": "./ov-labels.js",
"./ov-labels": "./ov-labels.js",
"./ov-look": "./ov-look.js",
"./ov-look": "./ov-look.js",
"./ov-fx": "./ov-fx.js",
"./ov-fx": "./ov-fx.js",
@@ -9,7 +9,7 @@ const path = require('path');
@@ -9,7 +9,7 @@ const path = require('path');
const { hashAsset } = require('../assets');
const { hashAsset } = require('../assets');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
-const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js'];
+const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css'];
const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));
const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));
const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;
const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+'use strict';
+// ov-forms.js and ov-forms.css (4.109.0) in a real headless Chrome: every select becomes OpenVibe's own (a menu on a
+// computer, a sheet on a phone), the hidden select still holds the value a form posts, a script that sets the value
+// is followed, the keyboard works, options say their state, and data-ov-native keeps the browser's own. Skipped
+// without Chrome.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('ov-forms-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const LONG = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'];
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Forms</title><link rel="icon" href="data:,">
+<link rel="stylesheet" href="/ov-forms.css"></head><body style="font:15px system-ui;padding:16px">
+<form id="f" action="/sent">
+<label for="agent">Agent</label>
+<select id="agent" name="agent" required style="width:100%">
+ <option value="">Choose…</option>
+ <option value="claude-code" data-state="ok" data-badge="Signed in" data-desc="Your own Claude login, on your machine">Claude Code</option>
+ <option value="codex" data-state="warn" data-badge="91% used">Codex</option>
+ <option value="opencode" disabled data-state="off" data-href="/setup/opencode" data-href-label="Set up">OpenCode</option>
+</select>
+<label>Many <select id="many" name="many" multiple><option>a</option><option>b</option><option>c</option></select></label>
+<label>Long <select id="long" name="long"><optgroup label="Small">${LONG.slice(0, 6).map((x) => `<option>${x}</option>`).join('')}</optgroup><optgroup label="Big">${LONG.slice(6).map((x) => `<option>${x}</option>`).join('')}</optgroup></select></label>
+<span data-ov-native><select id="raw" name="raw"><option>x</option></select></span>
+<label><input type="checkbox" role="switch" id="sw"> A switch</label>
+<button id="send">Send</button>
+</form>
+<script>window.__changes = 0; document.getElementById('agent').addEventListener('change', () => window.__changes++);
+document.getElementById('f').addEventListener('submit', (e) => { e.preventDefault(); const d = new FormData(e.target); window.__sent = { agent: d.get('agent'), many: d.getAll('many'), long: d.get('long') }; });</script>
+<script src="/ov-forms.js"></script></body></html>`;
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }
+ if (u.pathname === '/ov-forms.js' || u.pathname === '/ov-forms.css') {
+ res.writeHead(200, { 'content-type': u.pathname.endsWith('.css') ? 'text/css' : 'application/javascript' });
+ return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1))));
+ }
+ res.writeHead(404); return res.end();
+});
+
+const key = (k, target = 'document.activeElement') => `(${target}).dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(k)}, bubbles: true, cancelable: true }))`;
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-forms-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ try {
+ // ── A computer: the menu ──
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 1280 });
+ await p.goto(`${base}/`, { maxMs: 5000 });
+ await wait(250);
+ const btn = "document.querySelector('#agent + .ov-sel')";
+ assert.strictEqual(await p.evaluate("document.getElementById('agent').classList.contains('ov-sel-native')"), true, 'the select is enhanced');
+ assert.strictEqual(await p.evaluate(`${btn}.textContent.trim()`), 'Choose…');
+ assert.strictEqual(await p.evaluate("!!document.querySelector('#raw + .ov-sel')"), false, 'data-ov-native keeps the browser\'s own');
+ assert.strictEqual(await p.evaluate(`${btn}.getAttribute('aria-labelledby').split(' ').some((id) => document.getElementById(id) && document.getElementById(id).tagName === 'LABEL')`), true, 'the button is named by the select\'s label');
+ assert.strictEqual(await p.evaluate(`${btn}.getBoundingClientRect().width === document.getElementById('f').getBoundingClientRect().width`), true, 'a full-width select gives a full-width button');
+
+ await p.evaluate(`${btn}.click()`);
+ await wait(80);
+ assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop.is-open')"), true, 'a click opens the menu');
+ assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop [role=option]').length"), 4);
+ assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop [role=option][aria-disabled=true] a.ov-sel-link').getAttribute('href')"), '/setup/opencode', 'a choice that is not ready links to its setup');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-desc').textContent"), 'Your own Claude login, on your machine');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-badge.is-warn').textContent"), '91% used');
+ await p.evaluate(key('ArrowDown'));
+ await p.evaluate(key('ArrowDown'));
+ await p.evaluate(key('ArrowDown')); // past the end: OpenCode is disabled, Codex stays the last
+ await p.evaluate(key('Enter'));
+ await wait(60);
+ assert.strictEqual(await p.evaluate("document.getElementById('agent').value"), 'codex', 'the keyboard chooses, skipping a disabled choice');
+ assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and the select says change, as a person\'s choice would');
+ assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'and the menu closes');
+ assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'with the focus back on the button');
+ assert.match(await p.evaluate(`${btn}.textContent`), /Codex.*91% used/, 'the button shows the choice and its badge');
+
+ await p.evaluate("document.getElementById('agent').value = 'claude-code'");
+ assert.match(await p.evaluate(`${btn}.textContent`), /Claude Code/, 'a script that sets the value is followed');
+
+ // A long list has a search box, and groups.
+ await p.evaluate("document.querySelector('#long + .ov-sel').click()");
+ await wait(80);
+ assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-group').length"), 2);
+ await p.evaluate("(() => { const i = document.querySelector('.ov-sel-pop .ov-sel-search input'); i.value = 'tw'; i.dispatchEvent(new Event('input', { bubbles: true })); })()");
+ assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ov-sel-pop .ov-sel-label')].map((x) => x.textContent)"), ['two', 'twelve'], 'typing narrows the list');
+ await p.evaluate(key('Enter', "document.querySelector('.ov-sel-pop .ov-sel-search input')"));
+ await wait(60);
+ assert.strictEqual(await p.evaluate("document.getElementById('long').value"), 'two');
+
+ // A multiple select keeps its menu open and counts.
+ await p.evaluate("document.querySelector('#many + .ov-sel').click()");
+ await wait(80);
+ await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[0].click()");
+ await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[2].click()");
+ assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), true, 'the menu stays open while choosing several');
+ await p.evaluate(key('Escape', "document.querySelector('.ov-sel-pop')"));
+ await wait(30);
+ assert.strictEqual(await p.evaluate("document.querySelector('#many + .ov-sel').textContent.trim()"), 'a, c');
+
+ // A click elsewhere closes the menu; the form posts what was chosen.
+ await p.evaluate("document.querySelector('#agent + .ov-sel').click()");
+ await wait(60);
+ await p.evaluate("document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))");
+ assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'a click elsewhere closes it');
+ await p.evaluate("document.getElementById('send').click()");
+ assert.deepStrictEqual(await p.evaluate('window.__sent'), { agent: 'claude-code', many: ['a', 'c'], long: 'two' }, 'the form posts the hidden selects\' values');
+
+ // A required select left empty: the button says so, not a bubble on a hidden control.
+ await p.evaluate("document.getElementById('agent').value = ''");
+ await p.evaluate("document.getElementById('f').requestSubmit()");
+ assert.strictEqual(await p.evaluate(`${btn}.classList.contains('is-invalid')`), true, 'an empty required select marks its button');
+ assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'and takes the focus');
+
+ // Markup added later is enhanced too.
+ await p.evaluate("document.body.insertAdjacentHTML('beforeend', '<select id=\"late\"><option>x</option><option>y</option></select>')");
+ await wait(200);
+ assert.strictEqual(await p.evaluate("!!document.querySelector('#late + .ov-sel')"), true, 'a select added later is enhanced');
+ assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('sw')).appearance"), 'none', 'a checkbox with role=switch is a switch');
+ await p.close();
+
+ // ── A phone: the sheet ──
+ const phone = await h.openPage(browser, context, { width: 390 });
+ await phone.goto(`${base}/`, { maxMs: 5000 });
+ await wait(250);
+ await phone.evaluate("document.querySelector('#agent + .ov-sel').click()");
+ await wait(320);
+ assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet.is-open') && !!document.querySelector('.ov-sel-scrim.is-open')"), true, 'a phone gets a sheet over a scrim');
+ assert.strictEqual(await phone.evaluate("document.querySelector('.ov-sel-sheet-title').textContent"), 'Agent', 'titled with the select\'s label');
+ assert.strictEqual(await phone.evaluate("document.documentElement.classList.contains('ov-sel-locked')"), true, 'the page under it does not scroll');
+ assert.ok(await phone.evaluate("document.querySelector('.ov-sel-sheet .ov-sel-opt').getBoundingClientRect().height") >= 44, 'rows are a finger tall');
+ await phone.evaluate("document.querySelectorAll('.ov-sel-sheet .ov-sel-opt')[1].click()");
+ await wait(320);
+ assert.strictEqual(await phone.evaluate("document.getElementById('agent').value"), 'claude-code');
+ assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet') || document.documentElement.classList.contains('ov-sel-locked')"), false, 'choosing closes the sheet and frees the page');
+ assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390, 'nothing is wider than the phone');
+ await phone.close();
+ console.log('ov-forms-chrome: the menu, the sheet, the keyboard, search, several choices, the form\'s values and native opt-outs ok');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });