openvibers/openvibe.shared Commits

4.109.0: OpenVibe's own select and form controls on every site (#285)

OP OpenVibers committed 9 days ago · 0c3fa237f1 · 12 files, +985 −8

ov-forms.js turns every <select> into a button that opens OpenVibe's list: a menu on a
computer, a sheet on a phone. The hidden select keeps the value, so forms, select.value
and change listeners work as before. Options can carry a description, a toned badge, a
state dot and a setup link. Search, groups, multiple choice, the full keyboard.
ov-forms.css: site-accent checkboxes, radios and sliders, role=switch switches, and the
opt-in .ov-field / .ov-input / .ov-choice look. The navbar loads both on a page with a
form control; data-ov-native or html[data-ov-forms=off] keeps the browser's own.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 26ac7e0..4439591 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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
ddiff --git a/README.md b/README.md
iindex 01314a7..23d8e1d 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
@@@ -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
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
```bash
ddiff --git a/STATUS.json b/STATUS.json
iindex 49a4020..d69cdcf 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/docs/forms.md b/docs/forms.md
nnew file mode 100644
iindex 0000000..effc2c9
---- /dev/null
++++ b/docs/forms.md
@@@ -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.
ddiff --git a/files.js b/files.js
iindex 2db0832..e87b91d 100644
---- a/files.js
++++ b/files.js
@@@ -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)
ddiff --git a/navbar.js b/navbar.js
iindex b8a771e..ed037c7 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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">
ddiff --git a/ov-forms.css b/ov-forms.css
nnew file mode 100644
iindex 0000000..49a7f56
---- /dev/null
++++ b/ov-forms.css
@@@ -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; }
++}
ddiff --git a/ov-forms.js b/ov-forms.js
nnew file mode 100644
iindex 0000000..05eff2e
---- /dev/null
++++ b/ov-forms.js
@@@ -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);
ddiff --git a/package-lock.json b/package-lock.json
iindex 38de591..ae69b6e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 1bd7e27..3b2217d 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 0605b10..901149e 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.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(', ')} };`;
ddiff --git a/test/ov-forms-chrome.test.js b/test/ov-forms-chrome.test.js
nnew file mode 100644
iindex 0000000..7d47830
---- /dev/null
++++ b/test/ov-forms-chrome.test.js
@@@ -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); });