openvibers/openvibe.design Commits
Create shows every choice the server offers: kinds, sizes, looks, models with prices, takes, a starting page, the cost first (#248)
Changes
@@@ -256,7 +256,8 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef/* (backwards, not both: a transform that stays would make the composer the box its sheets are fixed to.) */.dv-composer:focus-within { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); box-shadow: 0 26px 64px -24px color-mix(in srgb, var(--accent) 75%, transparent); }@keyframes dvIn { from { opacity: 0; transform: translateY(calc(12px * var(--ov-move, 1))) scale(.985); } }--.dv-kinds { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin: 0; padding: 0; border: 0; min-width: 0; }++/* Seven kinds: one row that shares the width on a computer and rides sideways on a phone. */++.dv-kinds { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; margin: 0; padding: 0; border: 0; min-width: 0; }.dv-kinds label, .dv-opt, .dv-look { position: relative; display: block; cursor: pointer; }.dv-kinds input, .dv-opt input, .dv-look input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }.dv-kinds span { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 46px; padding: 0 8px; border-radius: 14px; color: var(--text-secondary); background: color-mix(in srgb, var(--text-primary) 5%, transparent); font-size: .92rem; transition: background-color .18s, color .18s, transform .12s; }@@@ -324,9 +325,36 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef.dv-examples button svg { width: 16px; height: 16px; flex: none; color: var(--accent-light, var(--accent)); }.dv-examples button:hover, .dv-examples button:focus-visible { color: var(--text-primary); border-color: var(--accent); }.dv-examples button:active { transform: scale(.96); }--.dv-create:has(input[name="kind"][value="page"]:checked) .dv-examples button:not([data-kind="page"]),--.dv-create:has(input[name="kind"][value="screen"]:checked) .dv-examples button:not([data-kind="screen"]),--.dv-create:has(input[name="kind"][value="component"]:checked) .dv-examples button:not([data-kind="component"]) { display: none; }++.dv-examples button[hidden] { display: none; }++/* In a choice's sheet: a second group, checked rows, the takes, a starting address; and the cost under the tray. */++.dv-opt[hidden] { display: none; }++.dv-mod[hidden] { display: none; }++.dv-ways.is-off { opacity: .5; }++.dv-group-h { display: grid; gap: 2px; margin: 12px 4px 8px; padding-top: 12px; border-top: 1px solid var(--border); font-size: .95rem; font-weight: 700; }++.dv-group-h small { font-size: .8rem; font-weight: 400; color: var(--text-secondary); }++.dv-opt--check > span { padding-left: 44px; }++.dv-opt--check > span::before { content: ""; position: absolute; left: 14px; top: 50%; width: 20px; height: 20px; margin-top: -10px; box-sizing: border-box; border-radius: 6px; border: 2px solid color-mix(in srgb, var(--text-primary) 30%, transparent); transition: background-color .15s, border-color .15s; }++.dv-opt--check input:checked + span::before { background: var(--accent); border-color: var(--accent); }++.dv-opt--check input:checked + span::after { right: auto; left: 21px; margin-top: -8px; width: 5px; height: 10px; border-color: #fff; }++.dv-opt--check input:disabled + span { opacity: .45; }++.dv-takes { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 8px 0 2px; padding: 8px 8px 8px 12px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary) 5%, transparent); }++.dv-takes > span:first-child { display: grid; gap: 1px; }++.dv-takes b { font-size: .92rem; }++.dv-takes small { font-size: .78rem; color: var(--text-secondary); }++.dv-step { display: inline-flex; align-items: center; gap: 2px; }++.dv-step button { width: 44px; height: 44px; border: 0; border-radius: 12px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--text-primary); font: inherit; font-size: 1.3rem; font-weight: 700; cursor: pointer; transition: background-color .15s, transform .12s; }++.dv-step button:hover { background: color-mix(in srgb, var(--accent) 26%, transparent); }++.dv-step button:active { transform: scale(.92); }++.dv-step input { width: 40px; border: 0; background: none; color: var(--text-primary); font: inherit; font-size: 1.05rem; font-weight: 800; text-align: center; -moz-appearance: textfield; appearance: textfield; }++.dv-step input::-webkit-outer-spin-button, .dv-step input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }++.dv-start { display: block; }++.dv-start input { width: 100%; box-sizing: border-box; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-input, var(--bg-primary)); color: var(--text-primary); font: inherit; font-size: 1rem; }++.dv-start input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }++.dv-start-note { margin: 8px 4px 2px; font-size: .8rem; color: var(--text-secondary); }++.dv-cost { margin: 8px 6px 2px; font-size: .86rem; color: var(--text-secondary); animation: dvIn .3s cubic-bezier(.2, .9, .3, 1.1) backwards; }++.dv-cost[hidden] { display: none; }++.dv-cost.is-error { color: var(--warning-text, #f59e0b); }++.dv-rc-k img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }.dv-recent { margin-top: 44px; }.dv-recent h2 { margin: 0 0 12px; font-size: 1.05rem; }.dv-recent ul { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); gap: 8px; }@@@ -339,12 +367,16 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef.dv-rc-t small { color: var(--text-secondary); font-size: .8rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }@media (max-width: 640px) {.dv-create { margin-top: 22px; }++ .dv-kinds { grid-auto-columns: minmax(84px, 1fr); overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; margin: 0 -8px; padding: 0 8px; scroll-snap-type: x proximity; }++ .dv-kinds::-webkit-scrollbar { display: none; }++ .dv-kinds label { scroll-snap-align: start; }.dv-kinds span { flex-direction: column; gap: 3px; min-height: 58px; font-size: .8rem; }-- .dv-tray { display: grid; grid-template-columns: minmax(0, 1fr); }-- .dv-mod > summary { display: flex; }++ .dv-tray { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }++ .dv-mod > summary { display: flex; gap: 6px; padding: 0 10px 0 8px; }.dv-mod > summary b { flex: 1; }-- .dv-mod-k { min-width: 4.6em; }-- .dv-go { width: 100%; margin: 2px 0 0; min-height: 52px; }++ .dv-mod-k { display: none; }++ .dv-mod:not([hidden]):nth-of-type(odd):last-of-type { grid-column: 1 / -1; }++ .dv-go { grid-column: 1 / -1; width: 100%; margin: 2px 0 0; min-height: 52px; }/* The options rise from the bottom over a dimmed page. */.dv-mod[open]::before { content: ""; position: fixed; inset: 0; z-index: 10005; background: rgba(0, 0, 0, .5); animation: dvFade .2s both; }.dv-mod-sheet { position: fixed; z-index: 10006; left: 0; right: 0; top: auto; bottom: 0; width: auto; max-height: 76vh; padding: 16px 12px calc(16px + env(safe-area-inset-bottom, 0px)); border-radius: 24px 24px 0 0; border-width: 1px 0 0; transform-origin: 50% 100%; animation: dvRise .3s cubic-bezier(.2, .9, .25, 1) both; }@@@ -2,39 +2,121 @@'use strict';const prompt = document.getElementById('dv-prompt');if (prompt) {-- const form = prompt.form, KEY = 'dv-composer';-- const kindOf = () => (form.querySelector('input[name="kind"]:checked') || {}).value || 'page';-- const setKind = (id) => { const k = form.querySelector(`input[name="kind"][value="${CSS.escape(id)}"]`); if (k) { k.checked = true; prompt.placeholder = k.dataset.placeholder || prompt.placeholder; } };++ const form = prompt.form, KEY = 'dv-composer', API = form.dataset.api || '';++ const q = (sel) => form.querySelector(sel), all = (sel) => Array.from(form.querySelectorAll(sel));++ const picked = (name) => (q(`input[name="${name}"]:checked`) || {}).value || '';++ const chosenModels = () => all('input[name="models"]:checked').map((x) => x.value);++ const takesEl = q('input[name="takes"]'), cost = q('[data-dv-cost]'), goLabel = q('[data-go-label]'), go = q('.dv-go');++ const setText = (mod, text) => { const v = q(`.dv-mod[data-mod="${mod}"] [data-mod-v]`); if (v) v.textContent = text; };// What a choice in the tray shows: the name of what is chosen (and, for the look, its colours).const show = (input) => {-- const mod = input.closest('.dv-mod'); if (!mod) return;++ const mod = input.closest('.dv-mod'); if (!mod || input.type !== 'radio') return;++ if (mod.dataset.mod === 'who' && chosenModels().length) return;const v = mod.querySelector('[data-mod-v]'); if (v) v.textContent = input.dataset.label || input.value;const sw = mod.querySelector('[data-mod-sw]'); if (sw && input.dataset.sw) sw.setAttribute('style', input.dataset.sw);};-- const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ kind: kindOf(), who: (form.querySelector('input[name="who"]:checked') || {}).value, themeId: (form.querySelector('input[name="themeId"]:checked') || {}).value })); } catch { /* a private window */ } };++ // The sizes a kind can be made at: the others step out of the list, and the first allowed one is chosen.++ const fitFrames = () => {++ const k = q('input[name="kind"]:checked'); const allowed = k && k.dataset.frames ? k.dataset.frames.split(' ').filter(Boolean) : [];++ const rows = all('.dv-opt[data-frame]'); if (!rows.length) return;++ rows.forEach((r) => { r.hidden = allowed.length > 0 && !allowed.includes(r.dataset.frame); });++ const cur = q('input[name="frame"]:checked');++ if (!cur || cur.closest('.dv-opt').hidden) { const first = rows.find((r) => !r.hidden); if (first) { const i = first.querySelector('input'); i.checked = true; show(i); } }++ const mod = q('.dv-mod[data-mod="frame"]'); if (mod) mod.hidden = rows.filter((r) => !r.hidden).length < 2;++ };++ // The ideas under the composer are those of the kind chosen.++ const fitExamples = () => { const id = picked('kind'); document.querySelectorAll('.dv-examples [data-kind]').forEach((b) => { b.hidden = b.dataset.kind !== id; }); };++ const setKind = (id) => { const k = q(`input[name="kind"][value="${CSS.escape(id)}"]`); if (k) { k.checked = true; prompt.placeholder = k.dataset.placeholder || prompt.placeholder; fitFrames(); fitExamples(); const row = k.closest('.dv-kinds'), lab = k.closest('label'); if (row && lab && row.scrollWidth > row.clientWidth) row.scrollTo({ left: Math.max(0, lab.offsetLeft - row.offsetLeft - (row.clientWidth - lab.offsetWidth) / 2), behavior: 'smooth' }); } };++ // Models and takes: at most the page's number of models, and models × takes within the run's total.++ const takesNow = () => (takesEl ? Math.max(Number(takesEl.min) || 1, Math.min(Number(takesEl.max) || 4, Number(takesEl.value) || 1)) : 3);++ const fitModels = () => {++ const box = q('.dv-models'); const max = box ? Number(box.dataset.max) || 4 : 4; const on = chosenModels();++ all('input[name="models"]').forEach((x) => { x.disabled = !x.checked && on.length >= max; });++ if (takesEl) { const total = Number(takesEl.dataset.total) || 8; const cap = Math.max(1, Math.floor(total / Math.max(1, on.length))); if (takesNow() > cap) takesEl.value = String(cap); else takesEl.value = String(takesNow()); }++ const ways = q('.dv-ways'); if (ways) ways.classList.toggle('is-off', on.length > 0);++ const n = on.length ? on.length * takesNow() : 3;++ if (on.length) setText('who', on.length === 1 ? (q('input[name="models"]:checked').dataset.label || '1 model') : `${on.length} models`);++ else { const w = q('input[name="who"]:checked'); if (w) setText('who', w.dataset.label || w.value); }++ if (goLabel && !go.hasAttribute('aria-busy')) goLabel.textContent = n === 1 ? 'Create one take' : `Create ${['', '', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight'][n] || n} takes`;++ return n;++ };++ const startUrl = () => { const el = q('input[name="start_url"]'); return el ? el.value.trim() : ''; };++ const body = () => {++ const b = { prompt: prompt.value, kind: picked('kind') || 'page', who: picked('who') || 'claude' };++ if (picked('frame')) b.frame = picked('frame');++ if (picked('look')) b.look = picked('look');++ const m = chosenModels(); if (m.length) { b.models = m; b.takes = takesNow(); }++ if (startUrl()) b.start = { url: startUrl() };++ return b;++ };++ const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ kind: picked('kind'), who: picked('who'), look: picked('look'), frame: picked('frame'), models: chosenModels(), takes: takesNow() })); } catch { /* a private window */ } };++ // What the run will cost, asked from the server as the choices settle (the same plan the run uses).++ const usd = (n) => (n >= 0.995 ? `$${n.toFixed(2)}` : n >= 0.01 ? `${Math.round(n * 100)}¢` : n > 0 ? 'under 1¢' : 'nothing');++ let costTimer = 0, costSeq = 0;++ const say = (text, bad) => { if (!cost) return; cost.textContent = text || ''; cost.hidden = !text; cost.classList.toggle('is-error', Boolean(bad)); };++ const askCost = () => {++ if (!API || !cost) return;++ clearTimeout(costTimer);++ costTimer = setTimeout(async () => {++ const seq = ++costSeq;++ try {++ const r = await fetch(`${API}/estimate`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body()) });++ const e = await r.json(); if (seq !== costSeq) return;++ if (!r.ok) return say(chosenModels().length || startUrl() ? (e.detail || '') : '', true);++ const total = Number(e.total_usd);++ if (!isFinite(total)) return say('');++ const about = `About ${usd(total)} for this run`;++ const bal = e.balance ? (e.covers === false ? ` · your Balance (${usd(Number(e.balance.available_usd) || 0)}) does not cover it` : ' · your Balance covers it') : (e.balance_note ? ` · ${e.balance_note}` : '');++ say(about + bal, e.covers === false);++ } catch { if (seq === costSeq) say(''); }++ }, 450);++ };// The last choices come back (they are this person's habits, kept in this browser only).try {const last = JSON.parse(localStorage.getItem(KEY) || '{}');-- if (last.kind) setKind(last.kind);-- ['who', 'themeId'].forEach((name) => { const el = last[name] && form.querySelector(`input[name="${name}"][value="${CSS.escape(last[name])}"]`); if (el) { el.checked = true; show(el); } });-- } catch { /* nothing kept */ }-- const mods = [...form.querySelectorAll('.dv-mod')];++ if (last.kind) setKind(last.kind); else { fitFrames(); fitExamples(); }++ ['who', 'look', 'frame'].forEach((name) => { const el = last[name] && q(`input[name="${name}"][value="${CSS.escape(last[name])}"]`); if (el && !(el.closest('.dv-opt') && el.closest('.dv-opt').hidden)) { el.checked = true; show(el); } });++ (last.models || []).forEach((id) => { const el = q(`input[name="models"][value="${CSS.escape(id)}"]`); if (el) el.checked = true; });++ if (takesEl && last.takes) takesEl.value = String(last.takes);++ } catch { fitFrames(); fitExamples(); }++ fitModels();++ const mods = all('.dv-mod');const closeMods = (except) => mods.forEach((m) => { if (m !== except) m.open = false; });form.addEventListener('change', (event) => {const input = event.target;if (input.name === 'kind') setKind(input.value);++ else if (input.name === 'models' || input.name === 'takes') fitModels();++ else if (input.name === 'start_url') setText('start', startUrl() ? (() => { try { return new URL(startUrl()).hostname; } catch { return 'A page'; } })() : 'Blank');++ else if (input.name === 'who') { all('input[name="models"]').forEach((x) => { x.checked = false; }); fitModels(); show(input); setTimeout(() => closeMods(null), 160); }else if (input.closest('.dv-mod')) { show(input); setTimeout(() => closeMods(null), 160); }-- save();++ save(); askCost();++ });++ form.addEventListener('click', (event) => {++ const step = event.target.closest('[data-step]'); if (!step || !takesEl) return;++ takesEl.value = String(takesNow() + Number(step.dataset.step)); fitModels(); save(); askCost();});++ prompt.addEventListener('blur', askCost);mods.forEach((m) => m.addEventListener('toggle', () => { if (m.open) closeMods(m); }));-- document.addEventListener('click', (event) => { const open = mods.find((m) => m.open); if (open && !open.contains(event.target)) closeMods(null); else if (open && event.target === open) closeMods(null); });++ document.addEventListener('click', (event) => { const open = mods.find((m) => m.open); if (open && (!open.contains(event.target) || event.target === open)) closeMods(null); });document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { const open = mods.find((m) => m.open); if (open) { closeMods(null); open.querySelector('summary').focus(); } } });prompt.addEventListener('keydown', (event) => { if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) form.requestSubmit(); });-- form.addEventListener('submit', () => { const go = form.querySelector('.dv-go'); if (go) { go.setAttribute('aria-busy', 'true'); const t = go.querySelector('span'); if (t) t.textContent = 'Drawing three takes…'; } });++ const busy = (on) => { if (!go) return; if (on) { go.setAttribute('aria-busy', 'true'); if (goLabel) goLabel.textContent = 'Drawing…'; } else { go.removeAttribute('aria-busy'); fitModels(); } };++ form.addEventListener('submit', async (event) => {++ if (!API || typeof fetch !== 'function') return busy(true); // the plain form, as before++ event.preventDefault();++ if (!prompt.value.trim()) return prompt.focus();++ busy(true); closeMods(null);++ try {++ const r = await fetch(API, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body()) });++ const out = await r.json().catch(() => ({}));++ if (!r.ok) { busy(false); return say(out.detail || out.title || 'The designs could not be started. Try again.', true); }++ location.href = out.editUrl || `/d/${encodeURIComponent(out.design.id)}`;++ } catch { busy(false); say('The designs could not be started: this page could not reach the server.', true); }++ });document.querySelectorAll('[data-example]').forEach(button => button.addEventListener('click', () => {prompt.value = button.dataset.example;if (button.dataset.kind) { setKind(button.dataset.kind); save(); }-- prompt.focus();++ prompt.focus(); askCost();}));return;}@@@ -111,13 +111,16 @@ function createPageRoutes(ctx) {const recent = await ctx.designs.recent(subjectOf(req), links);// The Look choice shows the catalogue's themes; without the catalogue the composer still works on the default.const themes = await catalog.list().then((x) => x.themes, () => []);-- return page(res, 200, designView.create({ recent, themes }), req.viewer, { cacheControl: 'private, no-store' });++ // What can be made, in which sizes and looks, by which models at what price (the same answer /api/create/options++ // gives). Without it the composer shows the themes and the four ways, and posts the old form.++ const options = ctx.createOptions ? await ctx.createOptions.options({ token: tokenOf(req) }).then((all) => ctx.createOptions.publicOptions(all), () => null) : null;++ return page(res, 200, designView.create({ recent, themes, options }), req.viewer, { cacheControl: 'private, no-store' });});r.post('/create', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {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',-- themeId: req.body.themeId || undefined, owner: subjectOf(req), ip: req.ip, who: req.body.who });++ themeId: req.body.themeId || (req.body.look && req.body.look !== 'system' ? req.body.look : undefined), 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 */ }@@@ -17,7 +17,22 @@ const MAKES = [{ id: 'component', name: 'Component', glyph: G('<rect x="3.5" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="13" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="3.5" y="13" width="17" height="7.5" rx="2"/>'),placeholder: 'Describe the part: what it shows and the states it has…',examples: ['Pricing section', 'Sign-up form with its errors', 'A stat card with a small chart'] },++ { id: 'slide', name: 'Slide', glyph: G('<rect x="3" y="4.5" width="18" height="12" rx="2.5"/><path d="M12 16.5V20M8.5 20h7M7 9h6M7 12h9"/>'),++ placeholder: 'Describe the slide: the one point it makes and who is in the room…',++ examples: ['Title slide for a launch talk', 'Three numbers from last quarter', 'A before and after comparison'] },++ { id: 'email', name: 'Email', glyph: G('<rect x="3" y="5.5" width="18" height="13" rx="2.5"/><path d="M3.6 7.6l8.4 6 8.4-6"/>'),++ placeholder: 'Describe the email: who gets it and what you want them to do…',++ examples: ['Welcome email for a new member', 'A monthly product update', 'A receipt that reads well'] },++ { id: 'social-card', name: 'Social card', glyph: G('<rect x="4" y="4" width="16" height="16" rx="3.5"/><circle cx="9.3" cy="9.6" r="1.7"/><path d="M4.6 16.4l4.4-4 3.4 3 2.6-2.2 4.4 4"/>'),++ placeholder: 'Describe the card: what it announces and where it will be posted…',++ examples: ['Going live tonight at 8', 'A quote from a customer', 'A new feature in one line'] },++ { id: 'mark', name: 'Mark', glyph: G('<circle cx="12" cy="12" r="8.2"/><path d="M8.4 12.4l2.4 2.4 4.8-5.4"/>'),++ placeholder: 'Describe the mark: the name, what it stands for and where it will be used…',++ examples: ['A mark for a robotics club', 'An app icon for a notes app', 'A monogram, two letters'] },];++const MAKE = Object.fromEntries(MAKES.map((m) => [m.id, m]));++const FRAME_ICON = G('<rect x="3.5" y="5" width="12" height="9" rx="2"/><rect x="14" y="9.5" width="6.5" height="10" rx="2"/>');++const LINK_ICON = G('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.200 1.200M14 10a4 4 0 0 0-5.700 0l-3 3a4 4 0 0 0 5.700 5.700l1.200-1.200"/>');const WHO = [{ id: 'claude', name: 'Claude Opus 5.5', line: 'Three takes from one designer, the strongest' },{ id: 'sonnet', name: 'Claude Sonnet 5.5', line: 'Three takes from one designer, sooner' },@@@ -51,28 +66,64 @@ function gate({ id, signedInAs = null, signInUrl = '/auth/login' }) {return layout({ title: 'A design on OpenVibe.Design', path: '/d', robots: 'noindex, nofollow', body, scripts: [] });}--function create({ recent = [], themes = [] } = {}) {-- const looks = themes.slice(0, 12), look = looks[0] || null;-- const kinds = `<fieldset class="dv-kinds"><legend class="dv-visually-hidden">What you are making</legend>${MAKES.map((m, i) => `<label><input type="radio" name="kind" value="${m.id}" data-placeholder="${esc(m.placeholder)}"${i ? '' : ' checked'}><span>${m.glyph}<b>${m.name}</b></span></label>`).join('')}</fieldset>`;-- const lookMod = look ? `<details class="dv-mod" data-mod="look"><summary><i class="dv-sw" style="${swatch(look)}" data-mod-sw></i><span class="dv-mod-k">Look</span><b data-mod-v>${esc(look.name)}</b></summary>++// A look's four colours (page, text, accent, card) as the small picture's variables; plain colours only.++const lookStyle = (look) => { const w = (look && look.swatch) || []; return `--b:${colour(w[0], '#0b0f19')};--t:${colour(w[1], '#e6edf7')};--a:${colour(w[2], '#3b82f6')};--c:${colour(w[3], '#151b28')}`; };++const money = (n) => `$${Number(n).toFixed(2)}`;++// A maker's name as the maker writes it; "shared" is OpenVibe's own key for that model.++const MAKER = { openai: 'OpenAI', deepseek: 'DeepSeek', anthropic: 'Anthropic', google: 'Google', meta: 'Meta', xai: 'xAI', mistral: 'Mistral', minimax: 'MiniMax', openrouter: 'OpenRouter', shared: 'OpenVibe', local: 'Your machine' };++const maker = (p) => { const k = String(p || '').toLowerCase(); return MAKER[k] || (k ? k.charAt(0).toUpperCase() + k.slice(1) : ''); };++++/**++ * /create. `options` is what GET /api/create/options answers (kinds, frames, looks, models, takes, limits); without it++ * (the catalogue or the model list did not answer) the composer falls back to the themes and the four ways to pick a++ * designer, and the form posts as it always did. With a script, the form is sent to POST /api/create and the cost is++ * asked from POST /api/create/estimate as the choices change.++ */++function create({ recent = [], themes = [], options = null } = {}) {++ const o = options || {};++ const kinds = (o.kinds && o.kinds.length ? o.kinds : MAKES.slice(0, 3).map((m) => ({ id: m.id, name: m.name, frames: [] })))++ .map((k) => ({ ...k, ...(MAKE[k.id] || { glyph: MAKES[0].glyph, placeholder: 'Describe what you need…', examples: [] }), name: k.name || (MAKE[k.id] || {}).name || k.id }));++ const frames = o.frames || [];++ const looksAll = o.looks && o.looks.length ? o.looks++ : themes.map((t) => { const v = t.variables || {}; return { id: t.id, name: t.name, source: 'theme', swatch: [v['--bg-primary'], v['--text-primary'], v['--accent'], v['--bg-card']] }; });++ const looks = looksAll.slice(0, 12), look = looks[0] || null;++ const models = o.models || [];++ const takes = o.takes || { min: 1, max: 4, default: 3 };++ const limits = o.limits || { models: 4, takes_total: 8 };++++ const kindRow = `<fieldset class="dv-kinds" data-ovk-scroll><legend class="dv-visually-hidden">What you are making</legend>${kinds.map((k, i) => `<label><input type="radio" name="kind" value="${esc(k.id)}" data-placeholder="${esc(k.placeholder)}" data-frames="${esc((k.frames || []).join(' '))}"${i ? '' : ' checked'}><span>${k.glyph}<b>${esc(k.name)}</b></span></label>`).join('')}</fieldset>`;++ const first = kinds[0] || { frames: [] };++ const frameMod = frames.length ? `<details class="dv-mod" data-mod="frame"><summary>${FRAME_ICON}<span class="dv-mod-k">Size</span><b data-mod-v>${esc((frames.find((f) => f.id === (first.frames || [])[0]) || frames[0]).name)}</b></summary>++<div class="dv-mod-sheet" role="group" aria-label="Size"><header><b>Size</b><small>The frame the design is drawn and judged in.</small></header>++${frames.map((f) => `<label class="dv-opt" data-frame="${esc(f.id)}"><input type="radio" name="frame" value="${esc(f.id)}" data-label="${esc(f.name)}"${f.id === (first.frames || [])[0] ? ' checked' : ''}><span><b>${esc(f.name)}</b><small>${Number(f.width)} × ${Number(f.height)}</small></span></label>`).join('')}</div></details>` : '';++ const lookMod = look ? `<details class="dv-mod" data-mod="look"><summary><i class="dv-sw" style="${lookStyle(look)}" data-mod-sw></i><span class="dv-mod-k">Look</span><b data-mod-v>${esc(look.name)}</b></summary><div class="dv-mod-sheet" role="group" aria-label="Look"><header><b>Look</b><small>Every take wears this theme. You can change it on the canvas.</small></header>--<div class="dv-looks">${looks.map((t, i) => `<label class="dv-look"><input type="radio" name="themeId" value="${esc(t.id)}" data-label="${esc(t.name)}" data-sw="${swatch(t)}"${i ? '' : ' checked'}><span style="${swatch(t)}"><i aria-hidden="true"></i><b>${esc(t.name)}</b></span></label>`).join('')}</div>--<a class="dv-mod-more" href="/themes">All ${themes.length} themes, or make your own</a></div></details>` : '';++<div class="dv-looks">${looks.map((t, i) => `<label class="dv-look"><input type="radio" name="look" value="${esc(t.id)}" data-label="${esc(t.name)}" data-sw="${lookStyle(t)}"${i ? '' : ' checked'}><span style="${lookStyle(t)}"><i aria-hidden="true"></i><b>${esc(t.name)}</b></span></label>`).join('')}</div>++<a class="dv-mod-more" href="/themes">All ${looksAll.length} looks, or make your own</a></div></details>` : '';++ const modelRows = models.map((m) => `<label class="dv-opt dv-opt--check"><input type="checkbox" name="models" value="${esc(m.id)}" data-label="${esc(m.name)}"><span><b>${esc(m.name)}</b><small>${esc(maker(m.provider))}${m.price_in != null && m.price_out != null ? ` · ${money(m.price_in)} in, ${money(m.price_out)} out, per million tokens` : ''}</small></span></label>`).join('');const whoMod = `<details class="dv-mod" data-mod="who"><summary>${SPARK}<span class="dv-mod-k">Designer</span><b data-mod-v>${WHO[0].name}</b></summary>--<div class="dv-mod-sheet" role="group" aria-label="Designer"><header><b>Designer</b><small>Who draws the three takes.</small></header>--${WHO.map((w, i) => `<label class="dv-opt"><input type="radio" name="who" value="${w.id}" data-label="${esc(w.name)}"${i ? '' : ' checked'}><span><b>${esc(w.name)}</b><small>${esc(w.line)}</small></span></label>`).join('')}++<div class="dv-mod-sheet" role="group" aria-label="Designer"><header><b>Designer</b><small>Who draws the takes.</small></header>++<div class="dv-ways">${WHO.map((w, i) => `<label class="dv-opt"><input type="radio" name="who" value="${w.id}" data-label="${esc(w.name)}"${i ? '' : ' checked'}><span><b>${esc(w.name)}</b><small>${esc(w.line)}</small></span></label>`).join('')}</div>++${models.length ? `<p class="dv-group-h">Or choose the models yourself<small>Up to ${Number(limits.models) || 4}, side by side. Prices are OpenVibe Exchange's.</small></p>++<div class="dv-models" data-max="${Number(limits.models) || 4}">${modelRows}</div>++<div class="dv-takes"><span><b>Takes from each</b><small>${Number(limits.takes_total) || 8} takes at most in one run</small></span><span class="dv-step"><button type="button" data-step="-1" aria-label="One take fewer">−</button><input type="number" name="takes" inputmode="numeric" min="${Number(takes.min) || 1}" max="${Number(takes.max) || 4}" value="${Number(takes.default) || 3}" data-total="${Number(limits.takes_total) || 8}" aria-label="Takes from each model"><button type="button" data-step="1" aria-label="One take more">+</button></span></div>` : ''}<a class="dv-mod-more" href="https://openvibe.exchange/models">Compare these models on OpenVibe Exchange</a></div></details>`;-- const kindName = Object.fromEntries(MAKES.map((m) => [m.id, m]));-- const themeName = Object.fromEntries(themes.map((t) => [t.id, t.name]));++ const startMod = options ? `<details class="dv-mod" data-mod="start"><summary>${LINK_ICON}<span class="dv-mod-k">Start</span><b data-mod-v>Blank</b></summary>++<div class="dv-mod-sheet" role="group" aria-label="Start from"><header><b>Start from a page</b><small>Paste a public address and the takes begin from what that page says and how it is laid out. Leave it empty to start blank.</small></header>++<label class="dv-start"><span class="dv-visually-hidden">The page's address</span><input type="url" name="start_url" inputmode="url" autocomplete="off" placeholder="https://example.com/page" maxlength="2000"></label>++<p class="dv-start-note">An earlier design can be the start too: open it and press Remix.</p></div></details>` : '';++ const kindName = Object.fromEntries(kinds.map((k) => [k.id, k]));++ const lookName = Object.fromEntries(looksAll.map((t) => [t.id, t.name]));const body = `<section class="dv-create"><h1>What will you design?</h1>--<form method="post" action="/create" class="dv-composer" data-dv-composer>--${kinds}++<form method="post" action="/create" class="dv-composer" data-dv-composer${options ? ' data-api="/api/create"' : ''}>++${kindRow}<label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>--<textarea id="dv-prompt" name="prompt" required maxlength="4000" placeholder="${esc(MAKES[0].placeholder)}"></textarea>--<div class="dv-tray">${lookMod}${whoMod}<button type="submit" class="dv-go"><span>Create three takes</span>${ARROW}</button></div></form>--<div class="dv-examples" aria-label="Try an example">${MAKES.map((m) => m.examples.map((x) => `<button type="button" data-example="${esc(x)}" data-kind="${m.id}">${m.glyph}<span>${esc(x)}</span></button>`).join('')).join('')}</div>--${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${recent.map((d) => { const k = kindName[d.kind] || MAKES[0]; const bits = [k.name, themeName[d.themeId], d.status === 'running' ? 'Drawing now' : d.status === 'failed' ? 'Did not finish' : ago(d.createdAt)].filter(Boolean);-- return `<li><a class="dv-rc" href="${esc(d.url)}"><span class="dv-rc-k" aria-hidden="true">${k.glyph}</span><span class="dv-rc-t"><b>${esc(d.prompt)}</b><small>${esc(bits.join(' · '))}</small></span></a></li>`; }).join('')}</ul></section>` : ''}</section>`;++<textarea id="dv-prompt" name="prompt" required maxlength="${Number(limits.prompt) || 4000}" placeholder="${esc((kinds[0] || MAKES[0]).placeholder)}"></textarea>++<div class="dv-tray">${frameMod}${lookMod}${whoMod}${startMod}<button type="submit" class="dv-go"><span data-go-label>Create three takes</span>${ARROW}</button></div>++<p class="dv-cost" data-dv-cost role="status" hidden></p></form>++<div class="dv-examples" aria-label="Try an example">${kinds.map((k) => (k.examples || []).map((x) => `<button type="button" data-example="${esc(x)}" data-kind="${esc(k.id)}">${k.glyph}<span>${esc(x)}</span></button>`).join('')).join('')}</div>++${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${recent.map((d) => { const k = kindName[d.kind] || MAKE[d.kind] || MAKES[0]; const bits = [k.name, lookName[d.look] || lookName[d.themeId], d.model, d.status === 'running' ? 'Drawing now' : d.status === 'failed' ? 'Did not finish' : ago(d.createdAt)].filter(Boolean);++ return `<li><a class="dv-rc" href="${esc(d.url)}"><span class="dv-rc-k" aria-hidden="true">${d.thumbnail ? `<img src="${esc(d.thumbnail)}" alt="" loading="lazy" decoding="async">` : k.glyph}</span><span class="dv-rc-t"><b>${esc(d.prompt)}</b><small>${esc(bits.join(' · '))}</small></span></a></li>`; }).join('')}</ul></section>` : ''}</section>`;return layout({ title: 'Create a design', path: '/create', body, active: '/create', scripts: ['js/design.js'] });}@@@ -82,10 +82,12 @@ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));assert.match(recent.text, /Mobile onboarding/);// The composer of choices (the owner, 11-10: "modular selection … different things and different models"):// what is made, who designs it, and the recent designs as cards that say what each is.-- assert.deepEqual([...recent.text.matchAll(/name="kind" value="([a-z]+)"/g)].map((m) => m[1]), ['page', 'screen', 'component'], 'the three things that can be made');++ const kindsShown = [...recent.text.matchAll(/name="kind" value="([a-z-]+)"/g)].map((m) => m[1]);++ assert.deepEqual(kindsShown.slice(0, 3), ['page', 'screen', 'component'], 'the things that can be made, the first three first');++ assert.ok(kindsShown.length === 3 || kindsShown.length === 7, 'three without the options, seven with them');assert.deepEqual([...recent.text.matchAll(/name="who" value="([a-z]+)"/g)].map((m) => m[1]), ['claude', 'sonnet', 'any', 'compare'], 'the four ways to pick who designs');assert.match(recent.text, /href="https:\/\/openvibe\.exchange\/models">Compare these models on OpenVibe Exchange<\/a>/, 'the models are compared on Exchange');-- assert.match(recent.text, /<a class="dv-rc" href="\/d\/[^"]+"><span class="dv-rc-k" aria-hidden="true"><svg/, 'a recent design is a card with its kind drawn');++ assert.match(recent.text, /<a class="dv-rc" href="\/d\/[^"]+"><span class="dv-rc-k" aria-hidden="true"><(?:svg|img src="\/api\/create\/designs\/)/, 'a recent design is a card with its picture (or its kind drawn where there is none)');assert.doesNotMatch(recent.text, /<section class="dv-recent"><h2>Your recent designs<\/h2><ul><li><a href=/, 'not a bare list of links');});await check('guest and signed-in studio HTML stay private without changing public pages', async () => {
@@ -256,7 +256,8 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -256,7 +256,8 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
/* (backwards, not both: a transform that stays would make the composer the box its sheets are fixed to.) */
/* (backwards, not both: a transform that stays would make the composer the box its sheets are fixed to.) */
.dv-composer:focus-within { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); box-shadow: 0 26px 64px -24px color-mix(in srgb, var(--accent) 75%, transparent); }
.dv-composer:focus-within { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); box-shadow: 0 26px 64px -24px color-mix(in srgb, var(--accent) 75%, transparent); }
@keyframes dvIn { from { opacity: 0; transform: translateY(calc(12px * var(--ov-move, 1))) scale(.985); } }
@keyframes dvIn { from { opacity: 0; transform: translateY(calc(12px * var(--ov-move, 1))) scale(.985); } }
-.dv-kinds { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin: 0; padding: 0; border: 0; min-width: 0; }
+/* Seven kinds: one row that shares the width on a computer and rides sideways on a phone. */
+.dv-kinds { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; margin: 0; padding: 0; border: 0; min-width: 0; }
.dv-kinds label, .dv-opt, .dv-look { position: relative; display: block; cursor: pointer; }
.dv-kinds label, .dv-opt, .dv-look { position: relative; display: block; cursor: pointer; }
.dv-kinds input, .dv-opt input, .dv-look input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
.dv-kinds input, .dv-opt input, .dv-look input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
.dv-kinds span { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 46px; padding: 0 8px; border-radius: 14px; color: var(--text-secondary); background: color-mix(in srgb, var(--text-primary) 5%, transparent); font-size: .92rem; transition: background-color .18s, color .18s, transform .12s; }
.dv-kinds span { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 46px; padding: 0 8px; border-radius: 14px; color: var(--text-secondary); background: color-mix(in srgb, var(--text-primary) 5%, transparent); font-size: .92rem; transition: background-color .18s, color .18s, transform .12s; }
@@ -324,9 +325,36 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -324,9 +325,36 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
.dv-examples button svg { width: 16px; height: 16px; flex: none; color: var(--accent-light, var(--accent)); }
.dv-examples button svg { width: 16px; height: 16px; flex: none; color: var(--accent-light, var(--accent)); }
.dv-examples button:hover, .dv-examples button:focus-visible { color: var(--text-primary); border-color: var(--accent); }
.dv-examples button:hover, .dv-examples button:focus-visible { color: var(--text-primary); border-color: var(--accent); }
.dv-examples button:active { transform: scale(.96); }
.dv-examples button:active { transform: scale(.96); }
-.dv-create:has(input[name="kind"][value="page"]:checked) .dv-examples button:not([data-kind="page"]),
-.dv-create:has(input[name="kind"][value="screen"]:checked) .dv-examples button:not([data-kind="screen"]),
-.dv-create:has(input[name="kind"][value="component"]:checked) .dv-examples button:not([data-kind="component"]) { display: none; }
+.dv-examples button[hidden] { display: none; }
+/* In a choice's sheet: a second group, checked rows, the takes, a starting address; and the cost under the tray. */
+.dv-opt[hidden] { display: none; }
+.dv-mod[hidden] { display: none; }
+.dv-ways.is-off { opacity: .5; }
+.dv-group-h { display: grid; gap: 2px; margin: 12px 4px 8px; padding-top: 12px; border-top: 1px solid var(--border); font-size: .95rem; font-weight: 700; }
+.dv-group-h small { font-size: .8rem; font-weight: 400; color: var(--text-secondary); }
+.dv-opt--check > span { padding-left: 44px; }
+.dv-opt--check > span::before { content: ""; position: absolute; left: 14px; top: 50%; width: 20px; height: 20px; margin-top: -10px; box-sizing: border-box; border-radius: 6px; border: 2px solid color-mix(in srgb, var(--text-primary) 30%, transparent); transition: background-color .15s, border-color .15s; }
+.dv-opt--check input:checked + span::before { background: var(--accent); border-color: var(--accent); }
+.dv-opt--check input:checked + span::after { right: auto; left: 21px; margin-top: -8px; width: 5px; height: 10px; border-color: #fff; }
+.dv-opt--check input:disabled + span { opacity: .45; }
+.dv-takes { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 8px 0 2px; padding: 8px 8px 8px 12px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary) 5%, transparent); }
+.dv-takes > span:first-child { display: grid; gap: 1px; }
+.dv-takes b { font-size: .92rem; }
+.dv-takes small { font-size: .78rem; color: var(--text-secondary); }
+.dv-step { display: inline-flex; align-items: center; gap: 2px; }
+.dv-step button { width: 44px; height: 44px; border: 0; border-radius: 12px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--text-primary); font: inherit; font-size: 1.3rem; font-weight: 700; cursor: pointer; transition: background-color .15s, transform .12s; }
+.dv-step button:hover { background: color-mix(in srgb, var(--accent) 26%, transparent); }
+.dv-step button:active { transform: scale(.92); }
+.dv-step input { width: 40px; border: 0; background: none; color: var(--text-primary); font: inherit; font-size: 1.05rem; font-weight: 800; text-align: center; -moz-appearance: textfield; appearance: textfield; }
+.dv-step input::-webkit-outer-spin-button, .dv-step input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
+.dv-start { display: block; }
+.dv-start input { width: 100%; box-sizing: border-box; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-input, var(--bg-primary)); color: var(--text-primary); font: inherit; font-size: 1rem; }
+.dv-start input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+.dv-start-note { margin: 8px 4px 2px; font-size: .8rem; color: var(--text-secondary); }
+.dv-cost { margin: 8px 6px 2px; font-size: .86rem; color: var(--text-secondary); animation: dvIn .3s cubic-bezier(.2, .9, .3, 1.1) backwards; }
+.dv-cost[hidden] { display: none; }
+.dv-cost.is-error { color: var(--warning-text, #f59e0b); }
+.dv-rc-k img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }
.dv-recent { margin-top: 44px; }
.dv-recent { margin-top: 44px; }
.dv-recent h2 { margin: 0 0 12px; font-size: 1.05rem; }
.dv-recent h2 { margin: 0 0 12px; font-size: 1.05rem; }
.dv-recent ul { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); gap: 8px; }
.dv-recent ul { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); gap: 8px; }
@@ -339,12 +367,16 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -339,12 +367,16 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
.dv-rc-t small { color: var(--text-secondary); font-size: .8rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dv-rc-t small { color: var(--text-secondary); font-size: .8rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@media (max-width: 640px) {
@media (max-width: 640px) {
.dv-create { margin-top: 22px; }
.dv-create { margin-top: 22px; }
+ .dv-kinds { grid-auto-columns: minmax(84px, 1fr); overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; margin: 0 -8px; padding: 0 8px; scroll-snap-type: x proximity; }
+ .dv-kinds::-webkit-scrollbar { display: none; }
+ .dv-kinds label { scroll-snap-align: start; }
.dv-kinds span { flex-direction: column; gap: 3px; min-height: 58px; font-size: .8rem; }
.dv-kinds span { flex-direction: column; gap: 3px; min-height: 58px; font-size: .8rem; }
- .dv-tray { display: grid; grid-template-columns: minmax(0, 1fr); }
- .dv-mod > summary { display: flex; }
+ .dv-tray { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .dv-mod > summary { display: flex; gap: 6px; padding: 0 10px 0 8px; }
.dv-mod > summary b { flex: 1; }
.dv-mod > summary b { flex: 1; }
- .dv-mod-k { min-width: 4.6em; }
- .dv-go { width: 100%; margin: 2px 0 0; min-height: 52px; }
+ .dv-mod-k { display: none; }
+ .dv-mod:not([hidden]):nth-of-type(odd):last-of-type { grid-column: 1 / -1; }
+ .dv-go { grid-column: 1 / -1; width: 100%; margin: 2px 0 0; min-height: 52px; }
/* The options rise from the bottom over a dimmed page. */
/* The options rise from the bottom over a dimmed page. */
.dv-mod[open]::before { content: ""; position: fixed; inset: 0; z-index: 10005; background: rgba(0, 0, 0, .5); animation: dvFade .2s both; }
.dv-mod[open]::before { content: ""; position: fixed; inset: 0; z-index: 10005; background: rgba(0, 0, 0, .5); animation: dvFade .2s both; }
.dv-mod-sheet { position: fixed; z-index: 10006; left: 0; right: 0; top: auto; bottom: 0; width: auto; max-height: 76vh; padding: 16px 12px calc(16px + env(safe-area-inset-bottom, 0px)); border-radius: 24px 24px 0 0; border-width: 1px 0 0; transform-origin: 50% 100%; animation: dvRise .3s cubic-bezier(.2, .9, .25, 1) both; }
.dv-mod-sheet { position: fixed; z-index: 10006; left: 0; right: 0; top: auto; bottom: 0; width: auto; max-height: 76vh; padding: 16px 12px calc(16px + env(safe-area-inset-bottom, 0px)); border-radius: 24px 24px 0 0; border-width: 1px 0 0; transform-origin: 50% 100%; animation: dvRise .3s cubic-bezier(.2, .9, .25, 1) both; }
@@ -2,39 +2,121 @@
@@ -2,39 +2,121 @@
'use strict';
'use strict';
const prompt = document.getElementById('dv-prompt');
const prompt = document.getElementById('dv-prompt');
if (prompt) {
if (prompt) {
- const form = prompt.form, KEY = 'dv-composer';
- const kindOf = () => (form.querySelector('input[name="kind"]:checked') || {}).value || 'page';
- const setKind = (id) => { const k = form.querySelector(`input[name="kind"][value="${CSS.escape(id)}"]`); if (k) { k.checked = true; prompt.placeholder = k.dataset.placeholder || prompt.placeholder; } };
+ const form = prompt.form, KEY = 'dv-composer', API = form.dataset.api || '';
+ const q = (sel) => form.querySelector(sel), all = (sel) => Array.from(form.querySelectorAll(sel));
+ const picked = (name) => (q(`input[name="${name}"]:checked`) || {}).value || '';
+ const chosenModels = () => all('input[name="models"]:checked').map((x) => x.value);
+ const takesEl = q('input[name="takes"]'), cost = q('[data-dv-cost]'), goLabel = q('[data-go-label]'), go = q('.dv-go');
+ const setText = (mod, text) => { const v = q(`.dv-mod[data-mod="${mod}"] [data-mod-v]`); if (v) v.textContent = text; };
// What a choice in the tray shows: the name of what is chosen (and, for the look, its colours).
// What a choice in the tray shows: the name of what is chosen (and, for the look, its colours).
const show = (input) => {
const show = (input) => {
- const mod = input.closest('.dv-mod'); if (!mod) return;
+ const mod = input.closest('.dv-mod'); if (!mod || input.type !== 'radio') return;
+ if (mod.dataset.mod === 'who' && chosenModels().length) return;
const v = mod.querySelector('[data-mod-v]'); if (v) v.textContent = input.dataset.label || input.value;
const v = mod.querySelector('[data-mod-v]'); if (v) v.textContent = input.dataset.label || input.value;
const sw = mod.querySelector('[data-mod-sw]'); if (sw && input.dataset.sw) sw.setAttribute('style', input.dataset.sw);
const sw = mod.querySelector('[data-mod-sw]'); if (sw && input.dataset.sw) sw.setAttribute('style', input.dataset.sw);
};
};
- const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ kind: kindOf(), who: (form.querySelector('input[name="who"]:checked') || {}).value, themeId: (form.querySelector('input[name="themeId"]:checked') || {}).value })); } catch { /* a private window */ } };
+ // The sizes a kind can be made at: the others step out of the list, and the first allowed one is chosen.
+ const fitFrames = () => {
+ const k = q('input[name="kind"]:checked'); const allowed = k && k.dataset.frames ? k.dataset.frames.split(' ').filter(Boolean) : [];
+ const rows = all('.dv-opt[data-frame]'); if (!rows.length) return;
+ rows.forEach((r) => { r.hidden = allowed.length > 0 && !allowed.includes(r.dataset.frame); });
+ const cur = q('input[name="frame"]:checked');
+ if (!cur || cur.closest('.dv-opt').hidden) { const first = rows.find((r) => !r.hidden); if (first) { const i = first.querySelector('input'); i.checked = true; show(i); } }
+ const mod = q('.dv-mod[data-mod="frame"]'); if (mod) mod.hidden = rows.filter((r) => !r.hidden).length < 2;
+ };
+ // The ideas under the composer are those of the kind chosen.
+ const fitExamples = () => { const id = picked('kind'); document.querySelectorAll('.dv-examples [data-kind]').forEach((b) => { b.hidden = b.dataset.kind !== id; }); };
+ const setKind = (id) => { const k = q(`input[name="kind"][value="${CSS.escape(id)}"]`); if (k) { k.checked = true; prompt.placeholder = k.dataset.placeholder || prompt.placeholder; fitFrames(); fitExamples(); const row = k.closest('.dv-kinds'), lab = k.closest('label'); if (row && lab && row.scrollWidth > row.clientWidth) row.scrollTo({ left: Math.max(0, lab.offsetLeft - row.offsetLeft - (row.clientWidth - lab.offsetWidth) / 2), behavior: 'smooth' }); } };
+ // Models and takes: at most the page's number of models, and models × takes within the run's total.
+ const takesNow = () => (takesEl ? Math.max(Number(takesEl.min) || 1, Math.min(Number(takesEl.max) || 4, Number(takesEl.value) || 1)) : 3);
+ const fitModels = () => {
+ const box = q('.dv-models'); const max = box ? Number(box.dataset.max) || 4 : 4; const on = chosenModels();
+ all('input[name="models"]').forEach((x) => { x.disabled = !x.checked && on.length >= max; });
+ if (takesEl) { const total = Number(takesEl.dataset.total) || 8; const cap = Math.max(1, Math.floor(total / Math.max(1, on.length))); if (takesNow() > cap) takesEl.value = String(cap); else takesEl.value = String(takesNow()); }
+ const ways = q('.dv-ways'); if (ways) ways.classList.toggle('is-off', on.length > 0);
+ const n = on.length ? on.length * takesNow() : 3;
+ if (on.length) setText('who', on.length === 1 ? (q('input[name="models"]:checked').dataset.label || '1 model') : `${on.length} models`);
+ else { const w = q('input[name="who"]:checked'); if (w) setText('who', w.dataset.label || w.value); }
+ if (goLabel && !go.hasAttribute('aria-busy')) goLabel.textContent = n === 1 ? 'Create one take' : `Create ${['', '', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight'][n] || n} takes`;
+ return n;
+ };
+ const startUrl = () => { const el = q('input[name="start_url"]'); return el ? el.value.trim() : ''; };
+ const body = () => {
+ const b = { prompt: prompt.value, kind: picked('kind') || 'page', who: picked('who') || 'claude' };
+ if (picked('frame')) b.frame = picked('frame');
+ if (picked('look')) b.look = picked('look');
+ const m = chosenModels(); if (m.length) { b.models = m; b.takes = takesNow(); }
+ if (startUrl()) b.start = { url: startUrl() };
+ return b;
+ };
+ const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ kind: picked('kind'), who: picked('who'), look: picked('look'), frame: picked('frame'), models: chosenModels(), takes: takesNow() })); } catch { /* a private window */ } };
+ // What the run will cost, asked from the server as the choices settle (the same plan the run uses).
+ const usd = (n) => (n >= 0.995 ? `$${n.toFixed(2)}` : n >= 0.01 ? `${Math.round(n * 100)}¢` : n > 0 ? 'under 1¢' : 'nothing');
+ let costTimer = 0, costSeq = 0;
+ const say = (text, bad) => { if (!cost) return; cost.textContent = text || ''; cost.hidden = !text; cost.classList.toggle('is-error', Boolean(bad)); };
+ const askCost = () => {
+ if (!API || !cost) return;
+ clearTimeout(costTimer);
+ costTimer = setTimeout(async () => {
+ const seq = ++costSeq;
+ try {
+ const r = await fetch(`${API}/estimate`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body()) });
+ const e = await r.json(); if (seq !== costSeq) return;
+ if (!r.ok) return say(chosenModels().length || startUrl() ? (e.detail || '') : '', true);
+ const total = Number(e.total_usd);
+ if (!isFinite(total)) return say('');
+ const about = `About ${usd(total)} for this run`;
+ const bal = e.balance ? (e.covers === false ? ` · your Balance (${usd(Number(e.balance.available_usd) || 0)}) does not cover it` : ' · your Balance covers it') : (e.balance_note ? ` · ${e.balance_note}` : '');
+ say(about + bal, e.covers === false);
+ } catch { if (seq === costSeq) say(''); }
+ }, 450);
+ };
// The last choices come back (they are this person's habits, kept in this browser only).
// The last choices come back (they are this person's habits, kept in this browser only).
try {
try {
const last = JSON.parse(localStorage.getItem(KEY) || '{}');
const last = JSON.parse(localStorage.getItem(KEY) || '{}');
- if (last.kind) setKind(last.kind);
- ['who', 'themeId'].forEach((name) => { const el = last[name] && form.querySelector(`input[name="${name}"][value="${CSS.escape(last[name])}"]`); if (el) { el.checked = true; show(el); } });
- } catch { /* nothing kept */ }
- const mods = [...form.querySelectorAll('.dv-mod')];
+ if (last.kind) setKind(last.kind); else { fitFrames(); fitExamples(); }
+ ['who', 'look', 'frame'].forEach((name) => { const el = last[name] && q(`input[name="${name}"][value="${CSS.escape(last[name])}"]`); if (el && !(el.closest('.dv-opt') && el.closest('.dv-opt').hidden)) { el.checked = true; show(el); } });
+ (last.models || []).forEach((id) => { const el = q(`input[name="models"][value="${CSS.escape(id)}"]`); if (el) el.checked = true; });
+ if (takesEl && last.takes) takesEl.value = String(last.takes);
+ } catch { fitFrames(); fitExamples(); }
+ fitModels();
+ const mods = all('.dv-mod');
const closeMods = (except) => mods.forEach((m) => { if (m !== except) m.open = false; });
const closeMods = (except) => mods.forEach((m) => { if (m !== except) m.open = false; });
form.addEventListener('change', (event) => {
form.addEventListener('change', (event) => {
const input = event.target;
const input = event.target;
if (input.name === 'kind') setKind(input.value);
if (input.name === 'kind') setKind(input.value);
+ else if (input.name === 'models' || input.name === 'takes') fitModels();
+ else if (input.name === 'start_url') setText('start', startUrl() ? (() => { try { return new URL(startUrl()).hostname; } catch { return 'A page'; } })() : 'Blank');
+ else if (input.name === 'who') { all('input[name="models"]').forEach((x) => { x.checked = false; }); fitModels(); show(input); setTimeout(() => closeMods(null), 160); }
else if (input.closest('.dv-mod')) { show(input); setTimeout(() => closeMods(null), 160); }
else if (input.closest('.dv-mod')) { show(input); setTimeout(() => closeMods(null), 160); }
- save();
+ save(); askCost();
+ });
+ form.addEventListener('click', (event) => {
+ const step = event.target.closest('[data-step]'); if (!step || !takesEl) return;
+ takesEl.value = String(takesNow() + Number(step.dataset.step)); fitModels(); save(); askCost();
});
});
+ prompt.addEventListener('blur', askCost);
mods.forEach((m) => m.addEventListener('toggle', () => { if (m.open) closeMods(m); }));
mods.forEach((m) => m.addEventListener('toggle', () => { if (m.open) closeMods(m); }));
- document.addEventListener('click', (event) => { const open = mods.find((m) => m.open); if (open && !open.contains(event.target)) closeMods(null); else if (open && event.target === open) closeMods(null); });
+ document.addEventListener('click', (event) => { const open = mods.find((m) => m.open); if (open && (!open.contains(event.target) || event.target === open)) closeMods(null); });
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { const open = mods.find((m) => m.open); if (open) { closeMods(null); open.querySelector('summary').focus(); } } });
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { const open = mods.find((m) => m.open); if (open) { closeMods(null); open.querySelector('summary').focus(); } } });
prompt.addEventListener('keydown', (event) => { if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) form.requestSubmit(); });
prompt.addEventListener('keydown', (event) => { if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) form.requestSubmit(); });
- form.addEventListener('submit', () => { const go = form.querySelector('.dv-go'); if (go) { go.setAttribute('aria-busy', 'true'); const t = go.querySelector('span'); if (t) t.textContent = 'Drawing three takes…'; } });
+ const busy = (on) => { if (!go) return; if (on) { go.setAttribute('aria-busy', 'true'); if (goLabel) goLabel.textContent = 'Drawing…'; } else { go.removeAttribute('aria-busy'); fitModels(); } };
+ form.addEventListener('submit', async (event) => {
+ if (!API || typeof fetch !== 'function') return busy(true); // the plain form, as before
+ event.preventDefault();
+ if (!prompt.value.trim()) return prompt.focus();
+ busy(true); closeMods(null);
+ try {
+ const r = await fetch(API, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body()) });
+ const out = await r.json().catch(() => ({}));
+ if (!r.ok) { busy(false); return say(out.detail || out.title || 'The designs could not be started. Try again.', true); }
+ location.href = out.editUrl || `/d/${encodeURIComponent(out.design.id)}`;
+ } catch { busy(false); say('The designs could not be started: this page could not reach the server.', true); }
+ });
document.querySelectorAll('[data-example]').forEach(button => button.addEventListener('click', () => {
document.querySelectorAll('[data-example]').forEach(button => button.addEventListener('click', () => {
prompt.value = button.dataset.example;
prompt.value = button.dataset.example;
if (button.dataset.kind) { setKind(button.dataset.kind); save(); }
if (button.dataset.kind) { setKind(button.dataset.kind); save(); }
- prompt.focus();
+ prompt.focus(); askCost();
}));
}));
return;
return;
}
}
@@ -111,13 +111,16 @@ function createPageRoutes(ctx) {
@@ -111,13 +111,16 @@ function createPageRoutes(ctx) {
const recent = await ctx.designs.recent(subjectOf(req), links);
const recent = await ctx.designs.recent(subjectOf(req), links);
// The Look choice shows the catalogue's themes; without the catalogue the composer still works on the default.
// The Look choice shows the catalogue's themes; without the catalogue the composer still works on the default.
const themes = await catalog.list().then((x) => x.themes, () => []);
const themes = await catalog.list().then((x) => x.themes, () => []);
- return page(res, 200, designView.create({ recent, themes }), req.viewer, { cacheControl: 'private, no-store' });
+ // What can be made, in which sizes and looks, by which models at what price (the same answer /api/create/options
+ // gives). Without it the composer shows the themes and the four ways, and posts the old form.
+ const options = ctx.createOptions ? await ctx.createOptions.options({ token: tokenOf(req) }).then((all) => ctx.createOptions.publicOptions(all), () => null) : null;
+ return page(res, 200, designView.create({ recent, themes, options }), req.viewer, { cacheControl: 'private, no-store' });
});
});
r.post('/create', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
r.post('/create', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
try {
try {
const out = await ctx.designs.create({ prompt: req.body.prompt, kind: req.body.kind || 'page',
const out = await ctx.designs.create({ prompt: req.body.prompt, kind: req.body.kind || 'page',
- themeId: req.body.themeId || undefined, owner: subjectOf(req), ip: req.ip, who: req.body.who });
+ themeId: req.body.themeId || (req.body.look && req.body.look !== 'system' ? req.body.look : undefined), owner: subjectOf(req), ip: req.ip, who: req.body.who });
if (out.editKey) {
if (out.editKey) {
let links = [];
let links = [];
try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }
try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }
@@ -17,7 +17,22 @@ const MAKES = [
@@ -17,7 +17,22 @@ const MAKES = [
{ id: 'component', name: 'Component', glyph: G('<rect x="3.5" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="13" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="3.5" y="13" width="17" height="7.5" rx="2"/>'),
{ id: 'component', name: 'Component', glyph: G('<rect x="3.5" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="13" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="3.5" y="13" width="17" height="7.5" rx="2"/>'),
placeholder: 'Describe the part: what it shows and the states it has…',
placeholder: 'Describe the part: what it shows and the states it has…',
examples: ['Pricing section', 'Sign-up form with its errors', 'A stat card with a small chart'] },
examples: ['Pricing section', 'Sign-up form with its errors', 'A stat card with a small chart'] },
+ { id: 'slide', name: 'Slide', glyph: G('<rect x="3" y="4.5" width="18" height="12" rx="2.5"/><path d="M12 16.5V20M8.5 20h7M7 9h6M7 12h9"/>'),
+ placeholder: 'Describe the slide: the one point it makes and who is in the room…',
+ examples: ['Title slide for a launch talk', 'Three numbers from last quarter', 'A before and after comparison'] },
+ { id: 'email', name: 'Email', glyph: G('<rect x="3" y="5.5" width="18" height="13" rx="2.5"/><path d="M3.6 7.6l8.4 6 8.4-6"/>'),
+ placeholder: 'Describe the email: who gets it and what you want them to do…',
+ examples: ['Welcome email for a new member', 'A monthly product update', 'A receipt that reads well'] },
+ { id: 'social-card', name: 'Social card', glyph: G('<rect x="4" y="4" width="16" height="16" rx="3.5"/><circle cx="9.3" cy="9.6" r="1.7"/><path d="M4.6 16.4l4.4-4 3.4 3 2.6-2.2 4.4 4"/>'),
+ placeholder: 'Describe the card: what it announces and where it will be posted…',
+ examples: ['Going live tonight at 8', 'A quote from a customer', 'A new feature in one line'] },
+ { id: 'mark', name: 'Mark', glyph: G('<circle cx="12" cy="12" r="8.2"/><path d="M8.4 12.4l2.4 2.4 4.8-5.4"/>'),
+ placeholder: 'Describe the mark: the name, what it stands for and where it will be used…',
+ examples: ['A mark for a robotics club', 'An app icon for a notes app', 'A monogram, two letters'] },
];
];
+const MAKE = Object.fromEntries(MAKES.map((m) => [m.id, m]));
+const FRAME_ICON = G('<rect x="3.5" y="5" width="12" height="9" rx="2"/><rect x="14" y="9.5" width="6.5" height="10" rx="2"/>');
+const LINK_ICON = G('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.200 1.200M14 10a4 4 0 0 0-5.700 0l-3 3a4 4 0 0 0 5.700 5.700l1.200-1.200"/>');
const WHO = [
const WHO = [
{ id: 'claude', name: 'Claude Opus 5.5', line: 'Three takes from one designer, the strongest' },
{ id: 'claude', name: 'Claude Opus 5.5', line: 'Three takes from one designer, the strongest' },
{ id: 'sonnet', name: 'Claude Sonnet 5.5', line: 'Three takes from one designer, sooner' },
{ id: 'sonnet', name: 'Claude Sonnet 5.5', line: 'Three takes from one designer, sooner' },
@@ -51,28 +66,64 @@ function gate({ id, signedInAs = null, signInUrl = '/auth/login' }) {
@@ -51,28 +66,64 @@ function gate({ id, signedInAs = null, signInUrl = '/auth/login' }) {
return layout({ title: 'A design on OpenVibe.Design', path: '/d', robots: 'noindex, nofollow', body, scripts: [] });
return layout({ title: 'A design on OpenVibe.Design', path: '/d', robots: 'noindex, nofollow', body, scripts: [] });
}
}
-function create({ recent = [], themes = [] } = {}) {
- const looks = themes.slice(0, 12), look = looks[0] || null;
- const kinds = `<fieldset class="dv-kinds"><legend class="dv-visually-hidden">What you are making</legend>${MAKES.map((m, i) => `<label><input type="radio" name="kind" value="${m.id}" data-placeholder="${esc(m.placeholder)}"${i ? '' : ' checked'}><span>${m.glyph}<b>${m.name}</b></span></label>`).join('')}</fieldset>`;
- const lookMod = look ? `<details class="dv-mod" data-mod="look"><summary><i class="dv-sw" style="${swatch(look)}" data-mod-sw></i><span class="dv-mod-k">Look</span><b data-mod-v>${esc(look.name)}</b></summary>
+// A look's four colours (page, text, accent, card) as the small picture's variables; plain colours only.
+const lookStyle = (look) => { const w = (look && look.swatch) || []; return `--b:${colour(w[0], '#0b0f19')};--t:${colour(w[1], '#e6edf7')};--a:${colour(w[2], '#3b82f6')};--c:${colour(w[3], '#151b28')}`; };
+const money = (n) => `$${Number(n).toFixed(2)}`;
+// A maker's name as the maker writes it; "shared" is OpenVibe's own key for that model.
+const MAKER = { openai: 'OpenAI', deepseek: 'DeepSeek', anthropic: 'Anthropic', google: 'Google', meta: 'Meta', xai: 'xAI', mistral: 'Mistral', minimax: 'MiniMax', openrouter: 'OpenRouter', shared: 'OpenVibe', local: 'Your machine' };
+const maker = (p) => { const k = String(p || '').toLowerCase(); return MAKER[k] || (k ? k.charAt(0).toUpperCase() + k.slice(1) : ''); };
+
+/**
+ * /create. `options` is what GET /api/create/options answers (kinds, frames, looks, models, takes, limits); without it
+ * (the catalogue or the model list did not answer) the composer falls back to the themes and the four ways to pick a
+ * designer, and the form posts as it always did. With a script, the form is sent to POST /api/create and the cost is
+ * asked from POST /api/create/estimate as the choices change.
+ */
+function create({ recent = [], themes = [], options = null } = {}) {
+ const o = options || {};
+ const kinds = (o.kinds && o.kinds.length ? o.kinds : MAKES.slice(0, 3).map((m) => ({ id: m.id, name: m.name, frames: [] })))
+ .map((k) => ({ ...k, ...(MAKE[k.id] || { glyph: MAKES[0].glyph, placeholder: 'Describe what you need…', examples: [] }), name: k.name || (MAKE[k.id] || {}).name || k.id }));
+ const frames = o.frames || [];
+ const looksAll = o.looks && o.looks.length ? o.looks
+ : themes.map((t) => { const v = t.variables || {}; return { id: t.id, name: t.name, source: 'theme', swatch: [v['--bg-primary'], v['--text-primary'], v['--accent'], v['--bg-card']] }; });
+ const looks = looksAll.slice(0, 12), look = looks[0] || null;
+ const models = o.models || [];
+ const takes = o.takes || { min: 1, max: 4, default: 3 };
+ const limits = o.limits || { models: 4, takes_total: 8 };
+
+ const kindRow = `<fieldset class="dv-kinds" data-ovk-scroll><legend class="dv-visually-hidden">What you are making</legend>${kinds.map((k, i) => `<label><input type="radio" name="kind" value="${esc(k.id)}" data-placeholder="${esc(k.placeholder)}" data-frames="${esc((k.frames || []).join(' '))}"${i ? '' : ' checked'}><span>${k.glyph}<b>${esc(k.name)}</b></span></label>`).join('')}</fieldset>`;
+ const first = kinds[0] || { frames: [] };
+ const frameMod = frames.length ? `<details class="dv-mod" data-mod="frame"><summary>${FRAME_ICON}<span class="dv-mod-k">Size</span><b data-mod-v>${esc((frames.find((f) => f.id === (first.frames || [])[0]) || frames[0]).name)}</b></summary>
+<div class="dv-mod-sheet" role="group" aria-label="Size"><header><b>Size</b><small>The frame the design is drawn and judged in.</small></header>
+${frames.map((f) => `<label class="dv-opt" data-frame="${esc(f.id)}"><input type="radio" name="frame" value="${esc(f.id)}" data-label="${esc(f.name)}"${f.id === (first.frames || [])[0] ? ' checked' : ''}><span><b>${esc(f.name)}</b><small>${Number(f.width)} × ${Number(f.height)}</small></span></label>`).join('')}</div></details>` : '';
+ const lookMod = look ? `<details class="dv-mod" data-mod="look"><summary><i class="dv-sw" style="${lookStyle(look)}" data-mod-sw></i><span class="dv-mod-k">Look</span><b data-mod-v>${esc(look.name)}</b></summary>
<div class="dv-mod-sheet" role="group" aria-label="Look"><header><b>Look</b><small>Every take wears this theme. You can change it on the canvas.</small></header>
<div class="dv-mod-sheet" role="group" aria-label="Look"><header><b>Look</b><small>Every take wears this theme. You can change it on the canvas.</small></header>
-<div class="dv-looks">${looks.map((t, i) => `<label class="dv-look"><input type="radio" name="themeId" value="${esc(t.id)}" data-label="${esc(t.name)}" data-sw="${swatch(t)}"${i ? '' : ' checked'}><span style="${swatch(t)}"><i aria-hidden="true"></i><b>${esc(t.name)}</b></span></label>`).join('')}</div>
-<a class="dv-mod-more" href="/themes">All ${themes.length} themes, or make your own</a></div></details>` : '';
+<div class="dv-looks">${looks.map((t, i) => `<label class="dv-look"><input type="radio" name="look" value="${esc(t.id)}" data-label="${esc(t.name)}" data-sw="${lookStyle(t)}"${i ? '' : ' checked'}><span style="${lookStyle(t)}"><i aria-hidden="true"></i><b>${esc(t.name)}</b></span></label>`).join('')}</div>
+<a class="dv-mod-more" href="/themes">All ${looksAll.length} looks, or make your own</a></div></details>` : '';
+ const modelRows = models.map((m) => `<label class="dv-opt dv-opt--check"><input type="checkbox" name="models" value="${esc(m.id)}" data-label="${esc(m.name)}"><span><b>${esc(m.name)}</b><small>${esc(maker(m.provider))}${m.price_in != null && m.price_out != null ? ` · ${money(m.price_in)} in, ${money(m.price_out)} out, per million tokens` : ''}</small></span></label>`).join('');
const whoMod = `<details class="dv-mod" data-mod="who"><summary>${SPARK}<span class="dv-mod-k">Designer</span><b data-mod-v>${WHO[0].name}</b></summary>
const whoMod = `<details class="dv-mod" data-mod="who"><summary>${SPARK}<span class="dv-mod-k">Designer</span><b data-mod-v>${WHO[0].name}</b></summary>
-<div class="dv-mod-sheet" role="group" aria-label="Designer"><header><b>Designer</b><small>Who draws the three takes.</small></header>
-${WHO.map((w, i) => `<label class="dv-opt"><input type="radio" name="who" value="${w.id}" data-label="${esc(w.name)}"${i ? '' : ' checked'}><span><b>${esc(w.name)}</b><small>${esc(w.line)}</small></span></label>`).join('')}
+<div class="dv-mod-sheet" role="group" aria-label="Designer"><header><b>Designer</b><small>Who draws the takes.</small></header>
+<div class="dv-ways">${WHO.map((w, i) => `<label class="dv-opt"><input type="radio" name="who" value="${w.id}" data-label="${esc(w.name)}"${i ? '' : ' checked'}><span><b>${esc(w.name)}</b><small>${esc(w.line)}</small></span></label>`).join('')}</div>
+${models.length ? `<p class="dv-group-h">Or choose the models yourself<small>Up to ${Number(limits.models) || 4}, side by side. Prices are OpenVibe Exchange's.</small></p>
+<div class="dv-models" data-max="${Number(limits.models) || 4}">${modelRows}</div>
+<div class="dv-takes"><span><b>Takes from each</b><small>${Number(limits.takes_total) || 8} takes at most in one run</small></span><span class="dv-step"><button type="button" data-step="-1" aria-label="One take fewer">−</button><input type="number" name="takes" inputmode="numeric" min="${Number(takes.min) || 1}" max="${Number(takes.max) || 4}" value="${Number(takes.default) || 3}" data-total="${Number(limits.takes_total) || 8}" aria-label="Takes from each model"><button type="button" data-step="1" aria-label="One take more">+</button></span></div>` : ''}
<a class="dv-mod-more" href="https://openvibe.exchange/models">Compare these models on OpenVibe Exchange</a></div></details>`;
<a class="dv-mod-more" href="https://openvibe.exchange/models">Compare these models on OpenVibe Exchange</a></div></details>`;
- const kindName = Object.fromEntries(MAKES.map((m) => [m.id, m]));
- const themeName = Object.fromEntries(themes.map((t) => [t.id, t.name]));
+ const startMod = options ? `<details class="dv-mod" data-mod="start"><summary>${LINK_ICON}<span class="dv-mod-k">Start</span><b data-mod-v>Blank</b></summary>
+<div class="dv-mod-sheet" role="group" aria-label="Start from"><header><b>Start from a page</b><small>Paste a public address and the takes begin from what that page says and how it is laid out. Leave it empty to start blank.</small></header>
+<label class="dv-start"><span class="dv-visually-hidden">The page's address</span><input type="url" name="start_url" inputmode="url" autocomplete="off" placeholder="https://example.com/page" maxlength="2000"></label>
+<p class="dv-start-note">An earlier design can be the start too: open it and press Remix.</p></div></details>` : '';
+ const kindName = Object.fromEntries(kinds.map((k) => [k.id, k]));
+ const lookName = Object.fromEntries(looksAll.map((t) => [t.id, t.name]));
const body = `<section class="dv-create"><h1>What will you design?</h1>
const body = `<section class="dv-create"><h1>What will you design?</h1>
-<form method="post" action="/create" class="dv-composer" data-dv-composer>
-${kinds}
+<form method="post" action="/create" class="dv-composer" data-dv-composer${options ? ' data-api="/api/create"' : ''}>
+${kindRow}
<label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>
<label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>
-<textarea id="dv-prompt" name="prompt" required maxlength="4000" placeholder="${esc(MAKES[0].placeholder)}"></textarea>
-<div class="dv-tray">${lookMod}${whoMod}<button type="submit" class="dv-go"><span>Create three takes</span>${ARROW}</button></div></form>
-<div class="dv-examples" aria-label="Try an example">${MAKES.map((m) => m.examples.map((x) => `<button type="button" data-example="${esc(x)}" data-kind="${m.id}">${m.glyph}<span>${esc(x)}</span></button>`).join('')).join('')}</div>
-${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${recent.map((d) => { const k = kindName[d.kind] || MAKES[0]; const bits = [k.name, themeName[d.themeId], d.status === 'running' ? 'Drawing now' : d.status === 'failed' ? 'Did not finish' : ago(d.createdAt)].filter(Boolean);
- return `<li><a class="dv-rc" href="${esc(d.url)}"><span class="dv-rc-k" aria-hidden="true">${k.glyph}</span><span class="dv-rc-t"><b>${esc(d.prompt)}</b><small>${esc(bits.join(' · '))}</small></span></a></li>`; }).join('')}</ul></section>` : ''}</section>`;
+<textarea id="dv-prompt" name="prompt" required maxlength="${Number(limits.prompt) || 4000}" placeholder="${esc((kinds[0] || MAKES[0]).placeholder)}"></textarea>
+<div class="dv-tray">${frameMod}${lookMod}${whoMod}${startMod}<button type="submit" class="dv-go"><span data-go-label>Create three takes</span>${ARROW}</button></div>
+<p class="dv-cost" data-dv-cost role="status" hidden></p></form>
+<div class="dv-examples" aria-label="Try an example">${kinds.map((k) => (k.examples || []).map((x) => `<button type="button" data-example="${esc(x)}" data-kind="${esc(k.id)}">${k.glyph}<span>${esc(x)}</span></button>`).join('')).join('')}</div>
+${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${recent.map((d) => { const k = kindName[d.kind] || MAKE[d.kind] || MAKES[0]; const bits = [k.name, lookName[d.look] || lookName[d.themeId], d.model, d.status === 'running' ? 'Drawing now' : d.status === 'failed' ? 'Did not finish' : ago(d.createdAt)].filter(Boolean);
+ return `<li><a class="dv-rc" href="${esc(d.url)}"><span class="dv-rc-k" aria-hidden="true">${d.thumbnail ? `<img src="${esc(d.thumbnail)}" alt="" loading="lazy" decoding="async">` : k.glyph}</span><span class="dv-rc-t"><b>${esc(d.prompt)}</b><small>${esc(bits.join(' · '))}</small></span></a></li>`; }).join('')}</ul></section>` : ''}</section>`;
return layout({ title: 'Create a design', path: '/create', body, active: '/create', scripts: ['js/design.js'] });
return layout({ title: 'Create a design', path: '/create', body, active: '/create', scripts: ['js/design.js'] });
}
}
@@ -82,10 +82,12 @@ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
@@ -82,10 +82,12 @@ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
assert.match(recent.text, /Mobile onboarding/);
assert.match(recent.text, /Mobile onboarding/);
// The composer of choices (the owner, 11-10: "modular selection … different things and different models"):
// The composer of choices (the owner, 11-10: "modular selection … different things and different models"):
// what is made, who designs it, and the recent designs as cards that say what each is.
// what is made, who designs it, and the recent designs as cards that say what each is.
- assert.deepEqual([...recent.text.matchAll(/name="kind" value="([a-z]+)"/g)].map((m) => m[1]), ['page', 'screen', 'component'], 'the three things that can be made');
+ const kindsShown = [...recent.text.matchAll(/name="kind" value="([a-z-]+)"/g)].map((m) => m[1]);
+ assert.deepEqual(kindsShown.slice(0, 3), ['page', 'screen', 'component'], 'the things that can be made, the first three first');
+ assert.ok(kindsShown.length === 3 || kindsShown.length === 7, 'three without the options, seven with them');
assert.deepEqual([...recent.text.matchAll(/name="who" value="([a-z]+)"/g)].map((m) => m[1]), ['claude', 'sonnet', 'any', 'compare'], 'the four ways to pick who designs');
assert.deepEqual([...recent.text.matchAll(/name="who" value="([a-z]+)"/g)].map((m) => m[1]), ['claude', 'sonnet', 'any', 'compare'], 'the four ways to pick who designs');
assert.match(recent.text, /href="https:\/\/openvibe\.exchange\/models">Compare these models on OpenVibe Exchange<\/a>/, 'the models are compared on Exchange');
assert.match(recent.text, /href="https:\/\/openvibe\.exchange\/models">Compare these models on OpenVibe Exchange<\/a>/, 'the models are compared on Exchange');
- assert.match(recent.text, /<a class="dv-rc" href="\/d\/[^"]+"><span class="dv-rc-k" aria-hidden="true"><svg/, 'a recent design is a card with its kind drawn');
+ assert.match(recent.text, /<a class="dv-rc" href="\/d\/[^"]+"><span class="dv-rc-k" aria-hidden="true"><(?:svg|img src="\/api\/create\/designs\/)/, 'a recent design is a card with its picture (or its kind drawn where there is none)');
assert.doesNotMatch(recent.text, /<section class="dv-recent"><h2>Your recent designs<\/h2><ul><li><a href=/, 'not a bare list of links');
assert.doesNotMatch(recent.text, /<section class="dv-recent"><h2>Your recent designs<\/h2><ul><li><a href=/, 'not a bare list of links');
});
});
await check('guest and signed-in studio HTML stay private without changing public pages', async () => {
await check('guest and signed-in studio HTML stay private without changing public pages', async () => {