openvibers/openvibe.codes Commits

Model plan editor: presets that say what they do, roles as cards, checks as switches (#418)

OP OpenVibers committed 3 days ago · 426f27b089 · 3 files, +107 −15

* Model plan editor: presets that say what they do, roles as cards, checks as switches

Ask 371's editor (the openvibe.design component "model plan") was raw fieldsets: every budget field open, centred
giant checkboxes, 3,200 px on a phone. Now:
- the four presets as cards with their line ("Pair · Builder, Reviewer · medium reasoning"), the one in use marked
- Applies to: the project, or your account (every project) when empty
- the mode as a segmented switch, with one line on what the chosen mode does
- each role a card: what it does, an on/off switch (the builder always on), model and reasoning side by side, the
  planner's number of plans, and its limits folded with their summary ("None set" / "40,000 tokens · 10 min")
- the escalation ladder labelled for what it is; the checks as three switches with a line each
- Save in a bar that stays in reach, saying which preset the plan starts from
Field names and the POST are unchanged.

* Perf budget: CSS 86 KB (measured 84.5 with the model plan editor's styles)

Changes

ddiff --git a/public/css/c4.css b/public/css/c4.css
iindex c6e89e9..cc26555 100644
---- a/public/css/c4.css
++++ b/public/css/c4.css
@@@ -367,3 +367,75 @@
.kn-actions .c4-more:not(.kn-delete)>summary{color:var(--c4-ink);font-weight:600}
.kn-confirm{margin:0 0 8px;font-size:13px;color:var(--c4-ink)}
@media(max-width:640px){.kn-actions .c4-more>summary{min-height:44px}}
++
++/* ── The model plan editor (Opus, 2026-11-11; openvibe.design component "model plan") ──
++ Presets as cards with what they do; the mode as a segmented switch with its one line; roles as cards (2 across on a
++ wide screen) with an on/off switch, model and reasoning side by side and the limits folded with their summary; the
++ checks as switches with a line each; Save in a bar at the bottom that sticks on a phone. A role switched off dims
++ its card (it stays editable). */
++.mp{display:grid;gap:16px;max-width:980px}
++.mp-owner{margin:0 0 10px;font-size:13px}.mp-owner a{color:var(--c4-accent)}
++.mp-presets{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
++.mp-preset{display:grid;gap:3px;padding:12px 14px;border:1px solid var(--c4-line);border-radius:12px;background:var(--c4-surface);color:var(--c4-ink);text-decoration:none;min-width:0}
++.mp-preset b{font-size:14px}.mp-preset small{font-size:12px;color:var(--c4-muted);line-height:1.35}
++.mp-preset:hover{border-color:var(--c4-accent)}
++.mp-preset.is-on{border-color:var(--c4-accent);box-shadow:inset 0 0 0 1px var(--c4-accent);background:color-mix(in srgb,var(--c4-accent) 8%,var(--c4-surface))}
++.mp-form{display:grid;gap:16px}
++.mp-form label{display:grid;gap:5px;font-size:12px;color:var(--c4-muted);min-width:0}
++.mp-form input:not([type=checkbox]):not([type=radio]),.mp-form select,.mp-form textarea{border:1px solid var(--c4-line);border-radius:8px;padding:9px 10px;color:var(--c4-ink);background:var(--c4-surface);font:inherit;font-size:13px;min-height:42px;min-width:0;width:100%;box-sizing:border-box}
++.mp-form textarea{min-height:76px;resize:vertical}
++.mp-form :is(input,select,textarea):focus-visible{outline:2px solid var(--c4-accent);outline-offset:1px}
++.mp-top{display:grid}
++.mp-scope{max-width:420px}
++.mp-modes,.mp-checks{border:0;margin:0;padding:0;min-width:0}
++.mp-modes legend,.mp-checks legend{font-size:12px;color:var(--c4-muted);margin-bottom:6px;padding:0}
++.mp-seg{display:inline-flex;flex-wrap:wrap;gap:4px;padding:4px;border:1px solid var(--c4-line);border-radius:999px;background:var(--c4-surface)}
++.mp-seg label{position:relative;display:inline-flex}
++.mp-seg input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer}
++.mp-seg span{display:inline-flex;align-items:center;min-height:36px;padding:0 16px;border-radius:999px;font-size:13px;color:var(--c4-muted)}
++.mp-seg input:checked+span{background:var(--c4-accent);color:var(--c4-on-accent);font-weight:650}
++.mp-seg input:focus-visible+span{outline:2px solid var(--c4-accent);outline-offset:2px}
++.mp-mode-lines{margin:8px 2px 0;font-size:13px;color:var(--c4-muted)}
++.mp-mode-lines span{display:none}
++.mp-modes:has(input[value=single]:checked) [data-mode=single],.mp-modes:has(input[value=pair]:checked) [data-mode=pair],.mp-modes:has(input[value=council]:checked) [data-mode=council],.mp-modes:has(input[value=escalate]:checked) [data-mode=escalate]{display:inline}
++.mp-roles{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
++.mp-role{display:grid;gap:10px;padding:14px;border:1px solid var(--c4-line);border-radius:13px;background:var(--c4-surface);transition:opacity .2s}
++.mp-role.is-off,.mp-role:has(.mp-switch input:not(:checked)){opacity:.62}
++.mp-role-h{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
++.mp-role-h h3{margin:0;font-size:15px;color:var(--c4-ink)}
++.mp-role-h p{margin:2px 0 0;font-size:12.5px;color:var(--c4-muted);line-height:1.4}
++.mp-always{flex:none;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--c4-accent);padding:3px 8px;border-radius:999px;background:color-mix(in srgb,var(--c4-accent) 12%,transparent)}
++.mp-fields{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:8px}
++.mp-role[data-role=planner] .mp-fields:first-of-type{grid-template-columns:minmax(0,2fr) minmax(0,1fr) minmax(0,.7fr)}
++.mp-fields--3{grid-template-columns:repeat(3,minmax(0,1fr));padding-top:8px}
++.mp-limits>summary{display:flex;align-items:center;gap:8px;min-height:32px;cursor:pointer;list-style:none;font-size:12.5px;font-weight:600;color:var(--c4-ink)}
++.mp-limits>summary::-webkit-details-marker{display:none}
++.mp-limits>summary small{font-weight:400;color:var(--c4-muted)}
++.mp-limits>summary::after{content:"";margin-left:auto;width:6px;height:6px;border-right:2px solid var(--c4-muted);border-bottom:2px solid var(--c4-muted);transform:rotate(45deg);transition:transform .18s}
++.mp-limits[open]>summary::after{transform:rotate(-135deg)}
++.mp-switch{position:relative;display:inline-flex;flex:none;width:42px;height:24px;cursor:pointer}
++.mp-switch input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer}
++.mp-switch>span[aria-hidden]{position:absolute;inset:0;border-radius:999px;background:var(--c4-line);transition:background .18s}
++.mp-switch>span[aria-hidden]::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 2px #0003;transition:transform .18s}
++.mp-switch input:checked+span{background:var(--c4-accent)}
++.mp-switch input:checked+span::after{transform:translateX(18px)}
++.mp-switch input:focus-visible+span{outline:2px solid var(--c4-accent);outline-offset:2px}
++.mp-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
++.mp-ladder small{color:var(--c4-muted);font-size:11.5px}
++.mp-checks{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
++.mp-checks legend{grid-column:1/-1}
++.mp-form .mp-check{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--c4-line);border-radius:12px;background:var(--c4-surface);cursor:pointer}
++.mp-check b{display:block;font-size:13px;color:var(--c4-ink)}.mp-check small{font-size:12px;color:var(--c4-muted)}
++.mp-save{position:sticky;bottom:0;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;margin:0 -2px;border:1px solid var(--c4-line);border-radius:13px;background:color-mix(in srgb,var(--c4-surface) 92%,transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
++.mp-save span{font-size:13px;color:var(--c4-muted)}.mp-save b{color:var(--c4-ink)}
++.mp-save .ovk-btn{min-height:44px}
++@media (max-width:760px){
++.mp-presets{grid-template-columns:repeat(2,minmax(0,1fr))}
++.mp-roles,.mp-checks{grid-template-columns:minmax(0,1fr)}
++.mp-role[data-role=planner] .mp-fields:first-of-type{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
++.mp-role[data-role=planner] .mp-model{grid-column:1/-1}
++.mp-fields{grid-template-columns:minmax(0,1fr) minmax(0,.8fr)}
++.mp-fields--3{grid-template-columns:repeat(3,minmax(0,1fr))}
++.mp-seg{display:flex}.mp-seg label{flex:1}.mp-seg span{flex:1;justify-content:center;padding:0 8px}
++}
++@media (prefers-reduced-motion:reduce){.mp-switch>span[aria-hidden],.mp-switch>span[aria-hidden]::after,.mp-role,.mp-limits>summary::after{transition:none}}
ddiff --git a/server/http/model-plan-settings.js b/server/http/model-plan-settings.js
iindex c4d27ff..779683c 100644
---- a/server/http/model-plan-settings.js
++++ b/server/http/model-plan-settings.js
@@@ -22,24 +22,44 @@ function createModelPlanSettings(ctx, { page, wrap, header, flash, sameOrigin, v
const chosen = presets[req.query.preset] || saved.effective;
const entries = await aiCatalog.choices({ force: true }).catch(() => []);
const options = [...new Map([['auto', { id: 'auto', name: 'Auto (AI routes)' }], ...entries.map((item) => [item.id, item])]).values()];
-- const modelInput = (role, current) => `<input name="role_${role}_model" list="model-plan-models" value="${esc(current.model || 'auto')}" maxlength="200" required>`;
-- const effortInput = (role, current) => `<select name="role_${role}_effort">${EFFORTS.map((value) => `<option value="${value}"${current.effort === value ? ' selected' : ''}>${value}</option>`).join('')}</select>`;
++ // The editor (Opus, 2026-11-11; openvibe.design component "model plan"): presets as cards that say what they do,
++ // the mode as a switch with one line on what it means, each role a card (on/off, model, reasoning, limits folded
++ // with their summary), the checks as switches, and Save in a bar that stays in reach on a phone. Field names are
++ // the form's contract with POST below and the API; only the look changed.
++ const ROLE_LINE = { planner: 'Reads the task and the code, and writes the plan.', builder: 'Writes the change. Always on.', reviewer: 'Reads the change and asks for fixes.', verifier: 'Runs the result and checks it against the task.' };
++ const MODE_LINE = { single: 'One model does the whole task.', pair: 'A builder, and a reviewer who checks the work.', council: 'Several plans are written; the best one is built.', escalate: 'Starts small and moves up the ladder when a check fails.' };
++ const cap = (x) => String(x).charAt(0).toUpperCase() + String(x).slice(1);
++ const presetLine = (plan) => {
++ const roles = (plan.roles || []).map((r) => r.role);
++ const top = (plan.roles || []).map((r) => EFFORTS.indexOf(r.effort)).reduce((a, b) => Math.max(a, b), 0);
++ return `${cap(plan.mode || 'single')} · ${roles.length ? roles.map(cap).join(', ') : 'Builder'} · ${EFFORTS[top] || 'medium'} reasoning`;
++ };
++ const same = (a, b) => JSON.stringify({ m: a.mode, r: a.roles, c: a.checks }) === JSON.stringify({ m: b.mode, r: b.roles, c: b.checks });
++ const active = req.query.preset && presets[req.query.preset] ? req.query.preset : Object.keys(presets).find((name) => same(presets[name], chosen)) || '';
++ const modelInput = (role, current) => `<input name="role_${role}_model" list="model-plan-models" value="${esc(current.model || 'auto')}" maxlength="200" required aria-label="${esc(cap(role))} model">`;
++ const effortInput = (role, current) => `<select name="role_${role}_effort" aria-label="${esc(cap(role))} reasoning">${EFFORTS.map((value) => `<option value="${value}"${current.effort === value ? ' selected' : ''}>${value}</option>`).join('')}</select>`;
++ const limitsLine = (b = {}) => [b.tokens ? `${Number(b.tokens).toLocaleString('en-US')} tokens` : '', b.minutes ? `${b.minutes} min` : '', b.dollars ? `$${b.dollars}` : ''].filter(Boolean).join(' · ') || 'None set';
const roleRow = (role) => {
const current = chosen.roles.find((item) => item.role === role) || { model: 'auto', effort: 'medium' };
-- const enabled = chosen.roles.some((item) => item.role === role);
-- return `<fieldset class="c4-card"><legend>${esc(role)}</legend>${role === 'builder' ? '' : `<label><input type="checkbox" name="role_${role}_enabled" value="1"${enabled ? ' checked' : ''}> Use ${esc(role)}</label>`}
--<div class="c4-form-grid"><label>Model ${modelInput(role, current)}</label><label>Effort ${effortInput(role, current)}</label>${role === 'planner' ? `<label>Plan samples <input type="number" name="role_planner_samples" min="1" max="5" value="${current.samples || 1}"></label>` : ''}</div>
--<div class="c4-form-grid"><label>Token budget <input type="number" name="role_${role}_tokens" min="1" value="${esc(current.budget?.tokens || '')}"></label><label>Minute budget <input type="number" name="role_${role}_minutes" min="0.01" step="any" value="${esc(current.budget?.minutes || '')}"></label><label>Dollar budget <input type="number" name="role_${role}_dollars" min="0.01" step="any" value="${esc(current.budget?.dollars || '')}"></label></div></fieldset>`;
++ const enabled = role === 'builder' || chosen.roles.some((item) => item.role === role);
++ const on = role === 'builder' ? '<span class="mp-always">Always on</span>' : `<label class="mp-switch"><input type="checkbox" name="role_${role}_enabled" value="1"${enabled ? ' checked' : ''}><span aria-hidden="true"></span><span class="mp-sr">Use ${esc(role)}</span></label>`;
++ return `<section class="mp-role${enabled ? '' : ' is-off'}" data-role="${esc(role)}"><header class="mp-role-h"><div><h3>${esc(cap(role))}</h3><p>${esc(ROLE_LINE[role] || '')}</p></div>${on}</header>
++<div class="mp-fields"><label class="mp-model">Model ${modelInput(role, current)}</label><label>Reasoning ${effortInput(role, current)}</label>${role === 'planner' ? `<label>Plans <input type="number" name="role_planner_samples" min="1" max="5" value="${current.samples || 1}"></label>` : ''}</div>
++<details class="mp-limits"${current.budget ? ' open' : ''}><summary>Limits <small>${esc(limitsLine(current.budget))}</small></summary><div class="mp-fields mp-fields--3"><label>Tokens <input type="number" name="role_${role}_tokens" min="1" value="${esc(current.budget?.tokens || '')}" placeholder="No limit"></label><label>Minutes <input type="number" name="role_${role}_minutes" min="0.01" step="any" value="${esc(current.budget?.minutes || '')}" placeholder="No limit"></label><label>Dollars <input type="number" name="role_${role}_dollars" min="0.01" step="any" value="${esc(current.budget?.dollars || '')}" placeholder="No limit"></label></div></details></section>`;
};
-- const content = `${header('Model plan', 'Choose how each coding task is planned, built and checked.')}${flash(req)}${req.viewer.role === 'owner' ? '<p><a href="/admin/model-plans">Weekly outcomes and preset tuning</a></p>' : ''}
--<section class="c4-card"><p>Start from a preset and edit any field:</p><p>${Object.keys(presets).map((name) => `<a class="ovk-btn ovk-btn--quiet" href="/settings/model-plan?${new URLSearchParams({ ...(project ? { project } : {}), preset: name })}">${esc(name)}</a>`).join(' ')}</p>
--<form class="c4-form" method="post" action="/settings/model-plan"><label>Project id (empty saves your account default) <input name="project" value="${esc(project)}" maxlength="100"></label>
--<label>Mode <select name="mode">${['single', 'pair', 'council', 'escalate'].map((mode) => `<option value="${mode}"${chosen.mode === mode ? ' selected' : ''}>${mode}</option>`).join('')}</select></label>
++ const check = (name, label, line, on) => `<label class="mp-check"><span class="mp-switch"><input type="checkbox" name="${name}" value="1"${on ? ' checked' : ''}><span aria-hidden="true"></span></span><span><b>${label}</b><small>${line}</small></span></label>`;
++ const content = `${header('Model plan', 'Choose how each coding task is planned, built and checked.')}${flash(req)}${req.viewer.role === 'owner' ? '<p class="mp-owner"><a href="/admin/model-plans">Weekly outcomes and preset tuning</a></p>' : ''}
++<section class="mp" aria-label="Model plan">
++<nav class="mp-presets" aria-label="Presets">${Object.keys(presets).map((name) => `<a class="mp-preset${name === active ? ' is-on' : ''}" href="/settings/model-plan?${new URLSearchParams({ ...(project ? { project } : {}), preset: name })}"${name === active ? ' aria-current="true"' : ''}><b>${esc(name)}</b><small>${esc(presetLine(presets[name]))}</small></a>`).join('')}</nav>
++<form class="mp-form" method="post" action="/settings/model-plan">
++<div class="mp-top"><label class="mp-scope">Applies to <input name="project" value="${esc(project)}" maxlength="100" placeholder="Your account (every project)"></label></div>
++<fieldset class="mp-modes"><legend>Mode</legend><div class="mp-seg">${['single', 'pair', 'council', 'escalate'].map((mode) => `<label><input type="radio" name="mode" value="${mode}"${chosen.mode === mode ? ' checked' : ''}><span>${cap(mode)}</span></label>`).join('')}</div>
++<p class="mp-mode-lines">${['single', 'pair', 'council', 'escalate'].map((mode) => `<span data-mode="${mode}">${esc(MODE_LINE[mode])}</span>`).join('')}</p></fieldset>
<datalist id="model-plan-models">${options.map((item) => `<option value="${esc(item.id)}" label="${esc(item.name || item.display_name || item.id)}${item.desc ? ` · ${esc(item.desc)}` : ''}"></option>`).join('')}</datalist>
--${ROLE_ORDER.map(roleRow).join('')}
--<label>Escalation or fallback ladder (one model per line) <textarea name="ladder" rows="3">${esc((chosen.ladder || []).join('\n'))}</textarea></label>
--<fieldset><legend>Checks</legend><label><input type="checkbox" name="checks_tests" value="1"${chosen.checks.tests ? ' checked' : ''}> Tests</label> <label><input type="checkbox" name="checks_lint" value="1"${chosen.checks.lint ? ' checked' : ''}> Lint</label> <label><input type="checkbox" name="checks_reviewer" value="1"${chosen.checks.reviewer ? ' checked' : ''}> Reviewer approval</label></fieldset>
--<button class="ovk-btn ovk-btn--primary" type="submit">Save model plan</button></form></section>`;
++<div class="mp-roles">${ROLE_ORDER.map(roleRow).join('')}</div>
++<label class="mp-ladder">When a check fails, try next <small>One model per line, for Escalate</small><textarea name="ladder" rows="3" placeholder="e.g. a stronger model, then the strongest">${esc((chosen.ladder || []).join('\n'))}</textarea></label>
++<fieldset class="mp-checks"><legend>Before it is done</legend>${check('checks_tests', 'Tests', 'The project’s tests pass.', chosen.checks.tests)}${check('checks_lint', 'Lint', 'The linter has nothing to say.', chosen.checks.lint)}${check('checks_reviewer', 'Reviewer approval', 'The reviewer signs off.', chosen.checks.reviewer)}</fieldset>
++<div class="mp-save"><span>${active ? `Starting from <b>${esc(active)}</b>` : 'Your own plan'}</span><button class="ovk-btn ovk-btn--primary" type="submit">Save model plan</button></div></form></section>`;
return page(req, res, 'Model plan', wrap(content));
});
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex e6cbfb8..a549a44 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -21,7 +21,7 @@ const BUDGETS = {
jsRawKB: 411.5, // 2026-11-10: openvibe-shared 4.197.0 (the menu's row of places, the You tab's row, the Sign in mark, the tooltip)
jsBrotliKB: 98.5, // 2026-11-11: openvibe-shared 4.190.0 (the site name's dropdown, the footer's online strip, the balances pill); 94.2: measured 94.1 KB brotli with openvibe-shared 4.186.0 (2026-11-10: the mark's Working and Thinking states, the Dock and the menu as one sheet, bare controls styled by the loader); 94: measured 92 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.
-- cssRawKB: 84.4, // 84.4: measured 79.4 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09
++ cssRawKB: 86, // 86: measured 84.5 with the model plan editor's styles in c4.css (2026-11-11); 84.4: measured 79.4 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09
cssBrotliKB: 17.5, // 17.5: measured 16.3 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09
externalFiles: 2, // 0
};