openvibers/openvibe.codes Commits
Model plan editor: presets that say what they do, roles as cards, checks as switches (#418)
Changes
@@@ -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}}@@@ -22,24 +22,44 @@ function createModelPlanSettings(ctx, { page, wrap, header, flash, sameOrigin, vconst 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));});@@@ -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-09cssFiles: 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-09cssBrotliKB: 17.5, // 17.5: measured 16.3 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09externalFiles: 2, // 0};
@@ -367,3 +367,75 @@
@@ -367,3 +367,75 @@
.kn-actions .c4-more:not(.kn-delete)>summary{color:var(--c4-ink);font-weight:600}
.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)}
.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}}
@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}}
@@ -22,24 +22,44 @@ function createModelPlanSettings(ctx, { page, wrap, header, flash, sameOrigin, v
@@ -22,24 +22,44 @@ function createModelPlanSettings(ctx, { page, wrap, header, flash, sameOrigin, v
const chosen = presets[req.query.preset] || saved.effective;
const chosen = presets[req.query.preset] || saved.effective;
const entries = await aiCatalog.choices({ force: true }).catch(() => []);
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 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 roleRow = (role) => {
const current = chosen.roles.find((item) => item.role === role) || { model: 'auto', effort: 'medium' };
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>
<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));
return page(req, res, 'Model plan', wrap(content));
});
});
@@ -21,7 +21,7 @@ const BUDGETS = {
@@ -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)
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
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.
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
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
externalFiles: 2, // 0
};
};