openvibers/openvibe.design Commits

openvibe.design is Claude-first: Opus 5.5 by default, or compare models (#45)

OP OpenVibers committed 13 days ago · 5a7f3205d9 · 9 files, +130 −9

A design's three takes are Claude Opus 5.5, Claude Sonnet 5.5 and a second
Opus take when OpenVibe.AI offers them (pinned routes); a missing one is
filled by the Arena's next pick, and claude: false says when none is offered.
The create form offers 'Claude Opus 5.5' (checked) or 'Compare models' (the
Arena's three providers) as plain radios; the API takes who: 'compare'.
Icon drafts prefer Opus 5.5 too. The Arena stays many-model.

Changes

ddiff --git a/public/css/design.css b/public/css/design.css
iindex 383e696..40d30e3 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -258,6 +258,18 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
.dv-create textarea { display: block; width: 100%; min-height: 150px; padding: 16px; resize: vertical; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 1.15rem; }
.dv-create textarea::placeholder { color: var(--text-muted); }
.dv-create form button { display: block; margin: 0 0 2px auto; border: 0; border-radius: 9px; background: var(--accent); color: var(--on-accent); padding: 12px 18px; font: inherit; font-weight: 700; cursor: pointer; }
++/* Who designs it (L37): Claude first, or three providers compared. Plain radios, so it works without JavaScript. */
++.dv-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; justify-content: space-between; }
++.dv-foot button { margin: 0 !important; }
++.dv-who { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; border: 0; min-width: 0; }
++.dv-who label { position: relative; cursor: pointer; }
++.dv-who input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
++.dv-who span { display: grid; align-content: start; gap: 1px; min-height: 44px; box-sizing: border-box; padding: 6px 12px; border: 1px solid var(--border); border-radius: 11px; color: var(--text-secondary); }
++.dv-who b { font-size: .9rem; color: var(--text-primary); }
++.dv-who small { font-size: .74rem; color: var(--text-muted); }
++.dv-who input:checked + span { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
++.dv-who input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
++@media (max-width: 520px) { .dv-who { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } .dv-who span { height: 100%; } .dv-foot button { width: 100%; } }
.dv-examples { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
.dv-examples button { min-height: 38px; border: 1px solid var(--border); border-radius: 99px; padding: 8px 13px; background: var(--bg-card); color: var(--text-muted); font: inherit; font-size: .84rem; cursor: pointer; }
.dv-examples button:hover, .dv-examples button:focus-visible { color: var(--text-primary); border-color: var(--accent); }
ddiff --git a/server/arena/ai.js b/server/arena/ai.js
iindex b85b0ba..c427234 100644
---- a/server/arena/ai.js
++++ b/server/arena/ai.js
@@@ -183,6 +183,31 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
async function slots() { return (await slotPlan()).slots; }
++ /**
++ * Who draws a design on openvibe.design (plan L37, the owner: "default to Opus 5.5… most of the site
++ * Anthropic-powered, but configurable"). `claude`, the default, asks for Claude Opus 5.5, Claude Sonnet 5.5 and
++ * a second take from Opus, from the offers AI makes this service; a model AI does not offer is replaced by the
++ * Arena's next pick, and `claude: false` in the answer says no Claude model was offered at all. `compare` is the
++ * Arena's three, one per provider.
++ */
++ const CLAUDE = [/^claude-opus-5-5\b/, /^claude-sonnet-5-5\b/, /^claude-opus-5-5\b/];
++ async function designPlan(who = 'claude') {
++ if (who === 'compare') return { ...(await slotPlan()), who: 'compare', claude: null };
++ let list = configuredModels();
++ if (!list.length) list = await offeredModels();
++ const offers = rankOffers(list);
++ const slots = [];
++ for (const re of CLAUDE) {
++ const o = offers.find((x) => x.model && re.test(x.model));
++ if (o) slots.push(o);
++ }
++ const claude = slots.length > 0;
++ for (const o of offers) if (slots.length < 3 && !slots.some((x) => x.provider === o.provider && x.model === o.model)) slots.push(o);
++ const poolFallback = slots.length < 3;
++ while (slots.length < 3) slots.push({ provider: 'ai', model: null });
++ return { slots, pool_fallback: poolFallback, offered: offers.length, who: 'claude', claude };
++ }
++
function systemPrompt(category) {
return [
'You are a senior product designer and front-end engineer competing in the OpenVibe Design Arena.',
@@@ -299,17 +324,25 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
* Plain text from OpenVibe.AI's routing (the glyph drafts, L32): the same token and run as the Arena, one answer.
* → { text, model, provider, run_id }
*/
-- async function text({ prompt, system, maxTokens = 2000, target = null }) {
++ async function text({ prompt, system, maxTokens = 2000, target = null, prefer = null }) {
const startedAt = Date.now();
++ // `prefer` (a model pattern) pins the first offer that matches it (L37: Claude first); no match lets AI's
++ // routing choose, as before.
++ let route = null;
++ if (prefer) {
++ const list = configuredModels().length ? configuredModels() : await offeredModels().catch(() => []);
++ const o = list.find((x) => x.model && prefer.test(x.model));
++ if (o) route = { provider: o.provider, model: o.model };
++ }
const created = await call('POST', `/api/v1/generate?wait=${Math.min(ai.waitMs, 60_000)}`, {
-- prompt, system, max_tokens: maxTokens, options: { cache: false }, ...(target ? { target } : {}),
++ prompt, system, max_tokens: maxTokens, options: { cache: false }, ...(target ? { target } : {}), ...(route ? { route } : {}),
});
const { run } = await waitFor(created && created.run ? created.run : created, startedAt);
const prov = run.provenance || {};
return { text: textOf(run.output), model: String(prov.model || run.model || 'unknown').slice(0, 120), provider: String(prov.provider || run.provider || 'ai').slice(0, 80), run_id: run.id || null };
}
-- return { enabled, slots, slotPlan, offeredModels, rankOffers, generate, systemPrompt, text, CATEGORY_BRIEF };
++ return { enabled, slots, slotPlan, designPlan, offeredModels, rankOffers, generate, systemPrompt, text, CATEGORY_BRIEF };
}
module.exports = { createArenaAi, ArenaAiError, CATEGORY_BRIEF };
ddiff --git a/server/designs.js b/server/designs.js
iindex 6703389..10cee6d 100644
---- a/server/designs.js
++++ b/server/designs.js
@@@ -99,7 +99,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
if (!selected) throw fail(422, 'design.invalid_theme', 'Choose a theme from the catalog.');
return selected;
}
-- async function create({ prompt, kind = 'page', themeId, owner = null, ip = '' }) {
++ async function create({ prompt, kind = 'page', themeId, owner = null, ip = '', who = 'claude' }) {
prompt = String(prompt || '').trim();
if (!prompt || prompt.length > 4000 || !KINDS.includes(kind))
throw fail(422, 'design.invalid_input', 'Provide a prompt up to 4000 characters and a valid kind.');
@@@ -107,7 +107,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const count = Number(await db.value(`SELECT COUNT(*) FROM designs WHERE ${owner ? 'owner' : 'guest_ip'}=$1 AND created_at >= $2::timestamptz`, [owner || ip, cutoff]));
if (count >= (owner ? 10 : 2)) throw fail(429, 'design.daily_limit', 'Daily design limit reached.');
const selected = await theme(themeId);
-- const plan = await ai.slotPlan();
++ // Claude first (L37); `compare` asks the Arena's three. An AI client without designPlan (tests, older) uses slots.
++ const plan = ai.designPlan ? await ai.designPlan(who === 'compare' ? 'compare' : 'claude') : await ai.slotPlan();
const slots = plan.slots.slice(0, 3);
const variants = slots.map((slot, index) => ({ id: s.newId('dva'), slot: index, provider: slot.provider,
model: slot.model, status: 'pending', html: '', ms: 0, costUsd: 0, error: null }));
ddiff --git a/server/glyphs/drafts.js b/server/glyphs/drafts.js
iindex 9da7410..d84e152 100644
---- a/server/glyphs/drafts.js
++++ b/server/glyphs/drafts.js
@@@ -39,6 +39,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
prompt: `The icon: "${n}".${what ? ` What it stands for: ${what}` : ''}`,
maxTokens: 1800,
target: { service: 'design', type: 'glyph_draft', id: n },
++ prefer: /^claude-opus-5-5\b/, // Claude first on openvibe.design (L37)
});
let list = [];
try {
ddiff --git a/server/http/design-api.js b/server/http/design-api.js
iindex c8b0204..e189e98 100644
---- a/server/http/design-api.js
++++ b/server/http/design-api.js
@@@ -39,7 +39,7 @@ function createDesignApi(ctx) {
routes.post('/', sameOrigin, safe(async (req, res) => {
const body = req.body || {};
const result = await ctx.designs.create({ prompt: body.prompt, kind: body.kind || 'page',
-- themeId: body.themeId, owner: owner(req), ip: req.ip });
++ themeId: body.themeId, owner: owner(req), ip: req.ip, who: body.who });
if (result.editKey) {
let links = [];
try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 56a2162..298284a 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -90,7 +90,7 @@ function createPageRoutes(ctx) {
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
try {
const out = await ctx.designs.create({ prompt: req.body.prompt, kind: req.body.kind || 'page',
-- owner: subjectOf(req), ip: req.ip });
++ owner: subjectOf(req), ip: req.ip, who: req.body.who });
if (out.editKey) {
let links = [];
try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }
ddiff --git a/server/views/design.js b/server/views/design.js
iindex 3d39011..1f9c5ab 100644
---- a/server/views/design.js
++++ b/server/views/design.js
@@@ -10,7 +10,10 @@ function create({ recent = [] } = {}) {
const body = `<section class="dv-create"><h1>What will you design?</h1>
<form method="post" action="/create"><label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>
<textarea id="dv-prompt" name="prompt" required maxlength="4000" placeholder="Describe a page, an app screen or a component…"></textarea>
--<button type="submit">Create designs</button></form>
++<div class="dv-foot"><fieldset class="dv-who"><legend class="dv-visually-hidden">Who designs it</legend>
++<label><input type="radio" name="who" value="claude" checked><span><b>Claude Opus 5.5</b><small>three takes, Claude first</small></span></label>
++<label><input type="radio" name="who" value="compare"><span><b>Compare models</b><small>three providers side by side</small></span></label>
++</fieldset><button type="submit">Create designs</button></div></form>
<div class="dv-examples" aria-label="Try an example">${examples.map(x => `<button type="button" data-example="${esc(x)}">${esc(x)}</button>`).join('')}</div>
${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${recent.map(d => `<li><a href="${esc(d.url)}">${esc(d.prompt)}</a></li>`).join('')}</ul></section>` : ''}</section>`;
return layout({ title: 'Create a design', path: '/create', body, active: '/create', scripts: ['js/design.js'] });
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 6771197..51df954 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -219,7 +219,7 @@ ${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
function developers() {
const rows = [
-- ['POST /api/v1/designs', 'Create three model variants with {prompt, kind, themeId?}; guests receive a secret edit link.'],
++ ['POST /api/v1/designs', 'Create three variants with {prompt, kind, themeId?, who?}: Claude Opus 5.5 first by default, or who: "compare" for three providers; guests receive a secret edit link.'],
['GET /api/v1/designs/:id', 'Poll generation status, read each frame, the pick and the version list; guests send X-Design-Key.'],
['POST /api/v1/designs/:id/theme', 'Change the token theme with {themeId}.'],
['POST /api/v1/designs/:id/pick', 'Choose the working frame with {variantIndex}; the pick is recorded as one Arena vote.'],
ddiff --git a/test/claude-first.test.js b/test/claude-first.test.js
nnew file mode 100644
iindex 0000000..e508d6a
---- /dev/null
++++ b/test/claude-first.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++// openvibe.design is Claude-first (plan L37, 2026-10-31): a design's three takes are Claude Opus 5.5, Claude Sonnet 5.5
++// and Opus again when OpenVibe.AI offers them; a missing one is filled by the Arena's next pick, and no Claude at all
++// is said in the answer. "Compare models" is the Arena's three providers. The create form offers both, Claude checked.
++const assert = require('node:assert/strict');
++const { createArenaAi } = require('../server/arena/ai');
++const { boot, check, done } = require('./helpers/boot');
++
++const clientOf = (models) => createArenaAi({
++ config: { ai: { url: 'https://ai.test', models, waitMs: 1000 }, oauth: { clientId: 'design', clientSecret: 'x' }, networkUrl: 'https://network.test' },
++ fetchImpl: async () => { throw new Error('no network in this test'); },
++ log: { warn() {}, info() {}, log() {}, error() {} },
++});
++
++(async () => {
++ await check('Claude first: Opus 5.5, Sonnet 5.5, then Opus again', async () => {
++ const plan = await clientOf(['openai:gpt-5.5', 'anthropic:claude-sonnet-5-5', 'deepseek:deepseek-chat', 'anthropic:claude-opus-5-5']).designPlan();
++ assert.deepEqual(plan.slots, [
++ { provider: 'anthropic', model: 'claude-opus-5-5' },
++ { provider: 'anthropic', model: 'claude-sonnet-5-5' },
++ { provider: 'anthropic', model: 'claude-opus-5-5' },
++ ]);
++ assert.equal(plan.who, 'claude');
++ assert.equal(plan.claude, true);
++ });
++
++ await check('a Claude model AI does not offer is filled by the Arena\'s next pick', async () => {
++ const plan = await clientOf(['openai:gpt-5.5', 'anthropic:claude-opus-5-5', 'deepseek:deepseek-chat']).designPlan('claude');
++ assert.deepEqual(plan.slots.map((s) => s.model), ['claude-opus-5-5', 'claude-opus-5-5', 'gpt-5.5']);
++ });
++
++ await check('no Claude offered: the Arena\'s picks, and the answer says so', async () => {
++ const plan = await clientOf(['openai:gpt-5.5', 'deepseek:deepseek-chat']).designPlan();
++ assert.equal(plan.claude, false);
++ assert.deepEqual(plan.slots, [{ provider: 'openai', model: 'gpt-5.5' }, { provider: 'deepseek', model: 'deepseek-chat' }, { provider: 'ai', model: null }]);
++ assert.equal(plan.pool_fallback, true);
++ });
++
++ await check('compare: one per provider, as the Arena asks', async () => {
++ const plan = await clientOf(['anthropic:claude-opus-5-5', 'anthropic:claude-sonnet-5-5', 'openai:gpt-5.5', 'deepseek:deepseek-chat']).designPlan('compare');
++ assert.equal(plan.who, 'compare');
++ assert.deepEqual(plan.slots.map((s) => s.provider), ['anthropic', 'openai', 'deepseek']);
++ });
++
++ const asked = [];
++ const ai = {
++ designPlan: async (who) => { asked.push(who); return { slots: [{ provider: 'anthropic', model: 'claude-opus-5-5' }, { provider: 'anthropic', model: 'claude-sonnet-5-5' }, { provider: 'anthropic', model: 'claude-opus-5-5' }] }; },
++ generate: () => new Promise(() => {}),
++ };
++ const t = await boot({ ai });
++ try {
++ await check('the create form offers Claude Opus 5.5 (checked) or a comparison, and passes the choice on', async () => {
++ const page = await t.get('/create');
++ assert.ok(page.text.includes('<input type="radio" name="who" value="claude" checked>') && page.text.includes('value="compare"'));
++ const ana = t.network.addUser('ana');
++ const form = (who) => ({ method: 'POST', as: ana, body: `prompt=A+coffee+roaster&who=${who}`, headers: { 'content-type': 'application/x-www-form-urlencoded' }, origin: 'https://openvibe.design' });
++ await t.get('/create', form('compare'));
++ await t.get('/create', form('claude'));
++ await t.get('/create', form('anything'));
++ assert.deepEqual(asked, ['compare', 'claude', 'claude'], 'anything else is Claude');
++ const api = await t.get('/api/v1/designs', { as: ana, json: { prompt: 'Pricing section', kind: 'page' }, origin: 'https://openvibe.design' });
++ assert.equal(api.status, 201);
++ assert.equal(asked[asked.length - 1], 'claude', 'the API defaults to Claude too');
++ const read = await t.get(`/api/v1/designs/${api.json().design.id}`, { as: ana });
++ assert.deepEqual(read.json().variants.map((v) => v.model), ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-opus-5-5']);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });