openvibers/openvibe.shared Commits

4.175.0: the model chip and its choice sheet (#393)

OP OpenVibers committed 3 days ago · 5a0b431fac · 10 files, +143 −5

* The model chip and its choice sheet

ov-ai-choice.js renders a chip (the maker mark and the model's name) beside any
Generate button and a sheet grouped Recommended, Fastest, Cheapest, Best
quality, Uncensored 18+ (only where the person confirmed), Your keys and Your
machines. Each row says its estimated cost a run and its speed; a check marks
the choice. The footer saves "Use this for every <task>" and links to Exchange.

Server rendering (openvibe-shared/ov-ai-choice) keeps the control named before
hydration, and refuses a task or label that would break out of the markup. The
sheet sets no scroll behind it and an outside tap closes it.

Version 4.174.0.

* Export the browser script and bump STATUS and README for 4.174.0

openvibe-shared/ov-ai-choice.js is the script a page loads; openvibe-shared/ov-ai-choice
renders the markup. Both are explicit export entries, as ov-thread has. STATUS.json
and the README status line name 4.174.0, which the release checks read.

* The chip still works when its maker templates are absent

mark() read the server-rendered maker template and threw when a page placed the
chip by hand without them, on the first click that drew the sheet. It now falls
back to the neutral mark, so the chip keeps its name and its rows work.

* Merge main; 4.175.0: the model chip and its choice sheet

Main released 4.174.0 (the Home width and Edit button) while this branch was open.
The chip lands as 4.175.0 on top of it; STATUS.json and the README status line
name the same release.

* Merge main and release model choice sheet as 4.176.0

* Model sheet design: one list in the order you pick, the recommendation badged, kit buttons

The sheet listed the same models up to four times (Recommended, Fastest, Cheapest, Best quality, then All). Now a segmented order over one list; the owner's recommendation carries a badge; Your keys, Your machines and 18+ follow. The chip and buttons used ovk-button classes the kit does not have (it is ovk-btn); the chip names the chosen model once the choices load, not its raw id; a round close button; the order fits a 390 px phone.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 498359e..9041272 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,15 @@
# Changelog
++## 4.176.0 — 2026-11-09
++
++- **The model chip and its choice sheet** (`ai-choice.js`, `ov-ai-choice.js`, `ov-ai-choice.css`): a chip beside any
++ Generate button showing the maker mark and the model's name, opening a sheet with one list of the models in the
++ order the person picks (Recommended, Fastest, Cheapest, Best quality; the owner's recommendation badged), then Your
++ keys, Your machines and Uncensored 18+ (only where the person confirmed). Each row says what it costs a run and how
++ fast it is; a check marks the choice. The footer saves "Use this for every <task>" and links to
++ Exchange. Server rendering keeps the control named before hydration; the sheet sets no scroll behind it and an
++ outside tap closes it.
++
## 4.175.0 — 2026-11-09
- **OpenVibe Link**: the shared navigation's Machines entry points at openvibe.link/machines instead of the old
ddiff --git a/README.md b/README.md
iindex e88d0c2..20f401a 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.175.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.176.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.
ddiff --git a/STATUS.json b/STATUS.json
iindex 84a7f18..f380759 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.175.0",
++ "version": "4.176.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/ai-choice.js b/ai-choice.js
nnew file mode 100644
iindex 0000000..16d8382
---- /dev/null
++++ b/ai-choice.js
@@@ -0,0 +1,9 @@
++'use strict';
++const marks = require('./ai-marks');
++ const esc = value => String(value == null ? '' : value).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
++ function render({ task, label = task, endpoint = '/api/ai/choices', saveEndpoint = '/api/ai/choices', choice = null, name = 'choice' } = {}) {
++ const templates = marks.FAMILIES.map(m => `<template data-maker="${esc(m)}">${marks.mark(m, { label: false })}</template>`).join('');
++ return `<span class="ov-ai-choice" data-ai-choice data-task="${esc(task)}" data-task-label="${esc(label)}" data-endpoint="${esc(endpoint)}" data-save-endpoint="${esc(saveEndpoint)}"><button class="ovk-btn ovk-btn--quiet ov-ai-chip" type="button" aria-haspopup="dialog" data-ai-open>${marks.mark(choice?.model || 'OpenVibe', { label: false })}<span data-ai-label>${esc(choice?.model || 'Choose model')}</span><span aria-hidden="true">▾</span></button><input type="hidden" name="${esc(name)}" value="${choice ? esc(JSON.stringify(choice)) : ''}" data-ai-value>${templates}<template data-maker="Other">${marks.mark('Other', {label:false})}</template></span>`;
++ }
++
++module.exports = { render };
ddiff --git a/files.js b/files.js
iindex a7bf942..943a5ba 100644
---- a/files.js
++++ b/files.js
@@@ -13,6 +13,7 @@
const path = require('path');
const BROWSER = Object.freeze([
++ 'ov-ai-choice.js', // the model chip and its choice sheet (openvibe-shared/ov-ai-choice)
'navbar.js', // shared top bar (eager on every site)
'nav-icons.js', // navbar glyphs beyond its own, fetched by navbar.js after first paint
'theme-loader.js', // applies the theme before first paint
@@@ -94,6 +95,7 @@ const STYLES = Object.freeze([
'ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)
'ov-locker.css', // ov-locker.js's look: the locked card, the locker sheets, the recovery kit (docs/locker.md)
'ov-wallet.css',
++ 'ov-ai-choice.css', // the model chip and its choice sheet (ov-ai-choice.js)
'ov-share.css', // ov-share.js's look: the sheet, the recipient's offer banner, the not-on-the-list gate (docs/share.md)
'ov-upload.css', // picker, queue and ov-progress states
'messages.css', // the room and bubble views' look
ddiff --git a/ov-ai-choice.css b/ov-ai-choice.css
nnew file mode 100644
iindex 0000000..9d9bcfc
---- /dev/null
++++ b/ov-ai-choice.css
@@@ -0,0 +1,15 @@
++.ov-ai-choice{display:inline-flex;max-width:100%;vertical-align:middle}.ov-ai-chip{display:inline-flex;align-items:center;gap:.5rem;min-height:44px;max-width:100%}.ov-ai-chip [data-ai-label]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ov-ai-sheet{box-sizing:border-box;width:min(560px,calc(100vw - 32px));max-height:85dvh;padding:0;border:1px solid var(--border,#687080);border-radius:20px;background:var(--bg-primary,#171b24);color:var(--text-primary,#edf0f5);box-shadow:0 24px 80px #0007}.ov-ai-sheet::backdrop{background:#0008}.ov-ai-sheet header,.ov-ai-sheet footer{display:flex;gap:12px;align-items:center;padding:16px 20px}.ov-ai-sheet header h2{font-size:1.25rem;margin:0;flex:1}.ov-ai-sheet footer{flex-wrap:wrap;border-top:1px solid var(--border,#687080)}.ov-ai-sheet footer p{margin:0;width:100%}.ov-ai-sheet a{color:var(--accent,#87b8ff);min-height:44px;display:inline-flex;align-items:center}.ov-ai-options{overflow-y:auto;max-height:55dvh;padding:0 12px 16px;overscroll-behavior:contain}.ov-ai-options h3{font-size:.82rem;color:var(--text-secondary,#aab2c0);margin:20px 8px 8px}.ov-ai-option{display:flex;align-items:center;gap:12px;width:100%;min-height:60px;padding:10px 8px;border:0;border-radius:10px;color:inherit;background:transparent;text-align:left;font:inherit;cursor:pointer}.ov-ai-option:hover,.ov-ai-option[aria-pressed=true]{background:color-mix(in srgb,var(--accent,#87b8ff) 14%,transparent)}.ov-ai-option>span:nth-child(2){flex:1;min-width:0}.ov-ai-option strong,.ov-ai-option small{display:block;overflow-wrap:anywhere}.ov-ai-option small,.ov-ai-muted{font-size:13px;color:var(--text-secondary,#aab2c0)}.ov-ai-muted{padding:0 8px}.ov-ai-sheet button{min-height:44px}.ov-ai-sheet button:focus-visible{outline:2px solid var(--accent,#87b8ff);outline-offset:2px}.ov-ai-skeleton{height:320px;margin:20px;border-radius:12px;background:linear-gradient(180deg,transparent,var(--border,#687080),transparent);opacity:.3}.ov-aim{display:inline-grid;place-items:center;flex:none;width:24px;height:24px}.ov-aim svg{display:block}@media(max-width:600px){.ov-ai-sheet{width:100%;max-width:100%;margin:auto 0 0;max-height:90dvh;border-radius:20px 20px 0 0}.ov-ai-sheet footer{padding-bottom:max(16px,env(safe-area-inset-bottom))}}@media(prefers-color-scheme:light){:root:not([data-theme=dark]) .ov-ai-sheet{background:var(--bg-primary,#fff);color:var(--text-primary,#17202b)}}
++
++/* Opus's design pass (11-09): the header names the task, a segmented order over one list, the recommendation badged,
++ kit buttons. */
++.ov-ai-sheet header{align-items:flex-start}.ov-ai-sheet header>div{flex:1;min-width:0}.ov-ai-sub{margin:2px 0 0;color:var(--text-secondary,#aab2c0);font-size:13px}
++.ov-ai-x{flex:none;display:inline-grid;place-items:center;width:40px;height:40px;min-height:40px;border:0;border-radius:50%;background:color-mix(in srgb,var(--text-primary,#edf0f5) 8%,transparent);color:inherit;font-size:22px;line-height:1;cursor:pointer}
++.ov-ai-x:hover{background:color-mix(in srgb,var(--text-primary,#edf0f5) 14%,transparent)}
++.ov-ai-order{display:flex;gap:2px;margin:0 20px 8px;padding:3px;border:1px solid var(--border,#687080);border-radius:999px;overflow-x:auto;scrollbar-width:none}
++.ov-ai-order::-webkit-scrollbar{display:none}
++.ov-ai-order button{flex:1 0 auto;min-height:36px;padding:0 12px;border:0;border-radius:999px;background:transparent;color:var(--text-secondary,#aab2c0);font:inherit;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap}
++.ov-ai-order button[aria-pressed=true]{background:color-mix(in srgb,var(--accent,#87b8ff) 20%,transparent);color:var(--text-primary,#edf0f5)}
++.ov-ai-rec{display:inline-block;margin-left:6px;padding:1px 7px;border-radius:999px;background:color-mix(in srgb,var(--accent,#87b8ff) 18%,transparent);color:var(--text-primary,#edf0f5);font-size:11px;font-style:normal;font-weight:700;vertical-align:1px}
++.ov-ai-option>span:last-child{flex:none;width:20px;text-align:center;color:var(--accent,#87b8ff);font-weight:800}
++.ov-ai-sheet footer .ovk-btn{flex:none}
++@media(max-width:420px){.ov-ai-order{margin:0 14px 8px}.ov-ai-order button{padding:0 8px;font-size:12.5px}}
ddiff --git a/ov-ai-choice.js b/ov-ai-choice.js
nnew file mode 100644
iindex 0000000..8643014
---- /dev/null
++++ b/ov-ai-choice.js
@@@ -0,0 +1,92 @@
++/* One task's model chip and choice sheet. Server rendering keeps the control named before hydration. */
++(function (root, factory) {
++ root.OpenVibeAiChoice = factory(); root.OpenVibeAiChoice.init();
++}(typeof window === 'undefined' ? globalThis : window, function () {
++ 'use strict';
++ function init(scope) {
++ (scope || document).querySelectorAll('[data-ai-choice]').forEach(host => {
++ if (host.dataset.ready) return; host.dataset.ready = '1';
++ const chip = host.querySelector('[data-ai-open]'), value = host.querySelector('[data-ai-value]');
++ let selected = value.value ? JSON.parse(value.value) : null, data = null, dialog = null, oldOverflow = '';
++ const same = (a,b) => a && b && a.model === b.model && a.source === b.source && a.machine === b.machine;
++ function mark(model) {
++ const family = /anthropic|claude/i.test(model) ? 'Anthropic' : /gpt|openai/i.test(model) ? 'OpenAI' : /gemini|google/i.test(model) ? 'Google' : /deepseek/i.test(model) ? 'DeepSeek' : /qwen/i.test(model) ? 'Qwen' : /llama|meta/i.test(model) ? 'Meta' : /mistral/i.test(model) ? 'Mistral' : /minimax/i.test(model) ? 'MiniMax' : /kimi|moonshot/i.test(model) ? 'Kimi' : /glm|zai/i.test(model) ? 'GLM' : 'Other';
++ // The server render carries one template per maker. A page that placed the chip by hand has none: the
++ // chip still names the model, with a neutral mark, rather than breaking on the first click.
++ const template = host.querySelector(`template[data-maker="${family}"]`) || host.querySelector('template[data-maker="Other"]');
++ if (template) return template.content.cloneNode(true);
++ const span = document.createElement('span');
++ span.className = 'ov-aim ov-aim--other';
++ span.setAttribute('aria-hidden', 'true');
++ return span;
++ }
++ function choose(row, picked = true) {
++ selected = {model:row.model,source:row.source,...(row.machine ? {machine:row.machine}: {})};
++ if (picked) value.value = JSON.stringify(selected);
++ host.querySelector('[data-ai-label]').textContent = row.name || row.model;
++ chip.querySelector('.ov-aim')?.remove(); chip.prepend(mark(row.model));
++ if (picked) host.dispatchEvent(new CustomEvent('ai-choice', {bubbles:true,detail:selected}));
++ }
++ async function load() {
++ const response = await fetch(`${host.dataset.endpoint}?task=${encodeURIComponent(host.dataset.task)}`, {credentials:'same-origin',headers:{Accept:'application/json'}});
++ const body = await response.json(); if (!response.ok) throw new Error(body.detail || 'Models are unavailable. Try again.');
++ data = body; const opts = data.options || {}; const known = [...(opts.openvibe || []), ...(opts.keys || []), ...(opts.machines || [])].find(r => same(r, selected || data.choice)); if (known) choose(known, false); else if (!selected && data.choice) choose(data.choice, false); return body;
++ }
++ function close() { if (!dialog) return; dialog.close(); dialog.remove(); dialog=null; document.documentElement.style.overflow=oldOverflow; chip.focus(); }
++ function rows(parent, title, list, empty, rec) {
++ const section=document.createElement('section'), h=document.createElement('h3');h.textContent=title;section.append(h);
++ if (!list.length) {const p=document.createElement('p');p.className='ov-ai-muted';p.textContent=empty || 'No measured options yet.';section.append(p);}
++ list.forEach(row=>{
++ const b=document.createElement('button');b.type='button';b.className='ov-ai-option';b.setAttribute('aria-pressed',String(same(selected,row)));b.append(mark(row.maker || row.model));
++ const text=document.createElement('span'), name=document.createElement('strong'), detail=document.createElement('small');name.textContent=row.name || row.model;
++ detail.textContent=[row.price_estimate_usd == null ? 'Price not measured' : row.price_estimate_usd === 0 ? row.source === 'openvibe' ? 'Free' : 'Your capacity' : `$${row.price_estimate_usd.toFixed(4)} estimated per run`,row.speed_ms ? `${Math.round(row.speed_ms/1000)} s` : 'Speed not measured',row.machine_name].filter(Boolean).join(' · ');
++ if(rec && same(rec,row)){const badge=document.createElement('em');badge.className='ov-ai-rec';badge.textContent='Recommended';name.append(' ',badge);}text.append(name,detail);b.append(text);const tick=document.createElement('span');tick.textContent=same(selected,row) ? '✓' : '';tick.setAttribute('aria-hidden','true');b.append(tick);
++ b.addEventListener('click',()=>{choose(row);draw();});section.append(b);
++ });parent.append(section);
++ }
++ // One list of the models, in the order the person picks (Recommended, Fastest, Cheapest, Best quality),
++ // then their own keys and machines; the owner's recommendation carries a badge. Opus's design, 11-09: the
++ // first version listed the same models four times over.
++ let order = 'recommended';
++ const ORDERS = [['recommended', 'Recommended'], ['fastest', 'Fastest'], ['cheapest', 'Cheapest'], ['quality', 'Best quality']];
++ function ordered(list) {
++ const known = (k, desc) => { const a = list.filter(x => x[k] != null).sort((x, y) => desc ? y[k] - x[k] : x[k] - y[k]); return a.concat(list.filter(x => x[k] == null)); };
++ if (order === 'fastest') return known('speed_ms');
++ if (order === 'cheapest') return known('price_estimate_usd');
++ if (order === 'quality') return known('quality', true);
++ const rec = list.find(x => same(x, data.recommended || data.choice));
++ return rec ? [rec, ...list.filter(x => x !== rec)] : list;
++ }
++ function draw() {
++ const scrollTop = dialog.querySelector('.ov-ai-options') ? dialog.querySelector('.ov-ai-options').scrollTop : 0;
++ dialog.replaceChildren();
++ const header = document.createElement('header'), titles = document.createElement('div'), title = document.createElement('h2'), sub = document.createElement('p'), x = document.createElement('button');
++ title.textContent = 'Choose a model'; title.id = 'ov-ai-title';
++ sub.textContent = `For ${host.dataset.taskLabel || 'this task'}`; sub.className = 'ov-ai-sub';
++ titles.append(title, sub);
++ x.type = 'button'; x.className = 'ov-ai-x'; x.setAttribute('aria-label', 'Close'); x.textContent = '×'; x.addEventListener('click', close);
++ header.append(titles, x); dialog.append(header); dialog.setAttribute('aria-labelledby', title.id);
++ const sorter = document.createElement('div'); sorter.className = 'ov-ai-order'; sorter.setAttribute('role', 'group'); sorter.setAttribute('aria-label', 'Order the models');
++ ORDERS.forEach(([id, label]) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.setAttribute('aria-pressed', String(order === id)); b.addEventListener('click', () => { order = id; draw(); }); sorter.append(b); });
++ dialog.append(sorter);
++ const content = document.createElement('div'); content.className = 'ov-ai-options'; dialog.append(content);
++ const all = data.options.openvibe || [], standard = all.filter(x => !x.adult);
++ rows(content, 'Models', ordered(standard), 'No models are available right now.', data.recommended || data.choice);
++ rows(content, 'Your keys', data.options.keys || [], 'Add your own provider key on Exchange.');
++ rows(content, 'Your machines', data.options.machines || [], 'Connect a computer with OpenVibe Link.');
++ if (data.adult) rows(content, 'Uncensored 18+', all.filter(x => x.adult));
++ content.scrollTop = scrollTop;
++ const footer=document.createElement('footer'), save=document.createElement('button'), manage=document.createElement('a'), status=document.createElement('p');status.setAttribute('role','status');save.type='button';save.className='ovk-btn ovk-btn--primary';save.textContent=`Use this for every ${host.dataset.taskLabel}`;save.disabled=!selected;manage.href='https://openvibe.exchange/console';manage.textContent='Manage on Exchange';
++ save.addEventListener('click',async()=>{save.disabled=true;try {const r=await fetch(`${host.dataset.saveEndpoint}/${encodeURIComponent(host.dataset.task)}`,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify(selected)});if(!r.ok) {const e=await r.json();throw new Error(e.detail || 'Sign in on Exchange to save your choice.');}status.textContent='Saved for this task.';}catch(e){status.textContent=e.message;}finally{save.disabled=false;}});
++ footer.append(save,manage,status);dialog.append(footer);
++ }
++ chip.addEventListener('click',async()=>{
++ if(dialog) return;dialog=document.createElement('dialog');dialog.className='ov-ai-sheet';dialog.innerHTML='<div class="ov-ai-skeleton" role="status" aria-label="Loading models"></div>';document.body.append(dialog);oldOverflow=document.documentElement.style.overflow;document.documentElement.style.overflow='hidden';dialog.showModal();
++ dialog.addEventListener('cancel',e=>{e.preventDefault();close();});dialog.addEventListener('click',e=>{if(e.target===dialog){const r=dialog.getBoundingClientRect();if(e.clientX<r.left || e.clientX>r.right || e.clientY<r.top || e.clientY>r.bottom) close();}});
++ try {await load();if(dialog) draw();}catch(e){if(dialog){dialog.textContent=e.message;const b=document.createElement('button');b.type='button';b.textContent='Close';b.className='ovk-btn ovk-btn--quiet';b.addEventListener('click',close);dialog.append(b);}}
++ });
++ load().catch(()=>{});
++ });
++ }
++ return {init};
++}));
ddiff --git a/package-lock.json b/package-lock.json
iindex c1fa90d..aebce6d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.175.0",
++ "version": "4.176.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.175.0",
++ "version": "4.176.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex e3d5ac2..53c1c99 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.175.0",
++ "version": "4.176.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -126,6 +126,8 @@
"./thread": "./thread.js",
"./messages": "./messages.js",
"./ov-thread": "./ov-thread.js",
++ "./ov-ai-choice": "./ai-choice.js",
++ "./ov-ai-choice.js": "./ov-ai-choice.js",
"./ov-wallet": "./ov-wallet.js",
"./sw": "./sw.js"
},
ddiff --git a/test/ai-choice.test.js b/test/ai-choice.test.js
nnew file mode 100644
iindex 0000000..d4b6869
---- /dev/null
++++ b/test/ai-choice.test.js
@@@ -0,0 +1,8 @@
++'use strict';
++const assert=require('assert');
++const {render}=require('../ai-choice');
++const html=render({task:'design.icon',label:'icon',choice:{model:'anthropic/claude-opus-5-5',source:'openvibe'}});
++assert(html.includes('aria-haspopup="dialog"'));assert(html.includes('ov-aim--anthropic'));assert(html.includes('name="choice"'));
++assert(!render({task:'<script>alert(1)</script>',label:'" onload="x'}).includes('<script>'));
++const files=require('../files');assert(files.isBrowserFile('ov-ai-choice.js'));assert(files.isBrowserFile('ov-ai-choice.css'));
++console.log('AI choice renders safe, named controls and uses the shared maker marks');