openvibers/openvibe.design Commits
Create is a composer of choices: what you are making, the look, who designs it (#244)
Changes
@@@ -248,31 +248,112 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef.ar-form textarea::placeholder { color: var(--text-muted, #7386a3); }/* Design Studio A: a quiet prompt and canvas. */.dv-visually-hidden { position: absolute; clip-path: inset(50%); width: 1px; height: 1px; overflow: hidden; }--.dv-create { max-width: 800px; margin: clamp(48px, 13vh, 150px) auto 100px; padding: 0 20px; }++.dv-create { max-width: 820px; margin: clamp(28px, 9vh, 120px) auto 80px; padding: 0 16px; }.ds .dv-create { min-height: calc(100svh - 110px); }--.dv-create h1 { margin: 0 0 24px; font-size: clamp(2.2rem, 5vw, 3.6rem); letter-spacing: -.05em; line-height: 1.06; }--.dv-create form { border: 1px solid var(--border); background: var(--bg-card); border-radius: 16px; padding: 12px; box-shadow: 0 16px 48px #0001; }--.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 h1 { margin: 0 0 20px; font-size: clamp(2.1rem, 5vw, 3.4rem); letter-spacing: -.05em; line-height: 1.06; }++/* The composer: what is made on top, the words, then a tray of choices and the button. */++.dv-composer { position: relative; border: 1px solid var(--border); background: var(--bg-card); border-radius: 22px; padding: 8px; box-shadow: 0 22px 56px -26px color-mix(in srgb, var(--accent) 55%, transparent); transition: border-color .2s, box-shadow .3s; animation: dvIn .5s cubic-bezier(.2, .9, .3, 1.1) backwards; }++/* (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; }++.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; }++.dv-kinds b { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.dv-kinds svg { flex: none; transition: transform .35s cubic-bezier(.2, 1.4, .3, 1); }++.dv-kinds label:active span { transform: scale(.97); }++.dv-kinds input:checked + span { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent-light, var(--accent)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 34%, transparent); }++.dv-kinds input:checked + span svg { transform: scale(1.12) rotate(calc(-4deg * var(--ov-move, 1))); }++.dv-kinds input:focus-visible + span, .dv-opt input:focus-visible + span, .dv-look input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }++.dv-create textarea { display: block; width: 100%; box-sizing: border-box; min-height: 132px; padding: 16px 12px; resize: vertical; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 1.12rem; line-height: 1.45; }.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-strong, var(--accent)); color: var(--on-accent-strong, #fff); 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-tray { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-top: 8px; border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); }++/* A choice in the tray: its name, what is chosen, and a sheet of the options (under it on a computer, from the bottom on a phone). */++.dv-mod { position: relative; min-width: 0; }++.dv-mod > summary { list-style: none; display: inline-flex; align-items: center; gap: 8px; max-width: 100%; box-sizing: border-box; min-height: 44px; padding: 0 12px 0 10px; border-radius: 999px; cursor: pointer; user-select: none; background: color-mix(in srgb, var(--text-primary) 5%, transparent); color: var(--text-secondary); font-size: .88rem; transition: background-color .18s, transform .12s; }++.dv-mod > summary::-webkit-details-marker { display: none; }++.dv-mod > summary::after { content: ""; flex: none; width: 7px; height: 7px; margin: 0 2px 3px 2px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); opacity: .6; transition: transform .2s; }++.dv-mod[open] > summary::after { transform: translateY(3px) rotate(225deg); }++.dv-mod > summary:hover, .dv-mod[open] > summary { background: color-mix(in srgb, var(--accent) 14%, transparent); }++.dv-mod > summary:active { transform: scale(.97); }++.dv-mod > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++.dv-mod > summary svg { flex: none; width: 18px; height: 18px; color: var(--accent-light, var(--accent)); }++.dv-mod-k { color: var(--text-muted); font-weight: 600; }++.dv-mod > summary b { color: var(--text-primary); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.dv-sw { flex: none; width: 20px; height: 20px; border-radius: 50%; background: linear-gradient(135deg, var(--b) 50%, var(--a) 50%); box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 22%, transparent); }++.dv-mod-sheet { position: absolute; z-index: 30; left: 0; top: calc(100% + 8px); width: min(380px, calc(100vw - 32px)); max-height: min(62vh, 460px); overflow: auto; overscroll-behavior: contain; box-sizing: border-box; padding: 12px; border-radius: 18px; background: var(--bg-elevated, var(--bg-secondary)); border: 1px solid var(--border); box-shadow: 0 24px 60px rgba(0, 0, 0, .42); transform-origin: 18% 0; animation: dvPop .24s cubic-bezier(.2, .9, .3, 1.15) both; }++@keyframes dvPop { from { opacity: 0; transform: translateY(calc(-8px * var(--ov-move, 1))) scale(.96); } }++.dv-mod-sheet header { display: grid; gap: 2px; padding: 2px 4px 10px; }++.dv-mod-sheet header b { font-size: 1rem; }++.dv-mod-sheet header small, .dv-opt small { color: var(--text-secondary); font-size: .8rem; line-height: 1.35; }++.dv-opt > span { display: grid; gap: 2px; min-height: 56px; box-sizing: border-box; align-content: center; padding: 8px 40px 8px 12px; border-radius: 14px; transition: background-color .15s; }++.dv-opt:hover > span { background: color-mix(in srgb, var(--text-primary) 6%, transparent); }++.dv-opt b { font-size: .95rem; }++.dv-opt input:checked + span { background: color-mix(in srgb, var(--accent) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 36%, transparent); }++.dv-opt input:checked + span::after { content: ""; position: absolute; right: 16px; top: 50%; width: 7px; height: 13px; margin-top: -9px; border-right: 2.5px solid var(--accent-light, var(--accent)); border-bottom: 2.5px solid var(--accent-light, var(--accent)); transform: rotate(45deg); animation: dvTick .25s cubic-bezier(.2, 1.6, .4, 1) both; }++@keyframes dvTick { from { transform: rotate(45deg) scale(0); } }++.dv-looks { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }++.dv-look > span { display: grid; gap: 6px; padding: 5px 5px 7px; border-radius: 14px; transition: background-color .15s, transform .12s; }++/* A theme in small: its page, a card on it, a line of its text and its accent. */++.dv-look i { position: relative; display: block; height: 52px; border-radius: 10px; background: var(--b); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--t) 16%, transparent); overflow: hidden; }++.dv-look i::before { content: ""; position: absolute; left: 8px; right: 8px; top: 9px; bottom: -6px; border-radius: 7px 7px 0 0; background: var(--c); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--t) 12%, transparent); }++.dv-look i::after { content: ""; position: absolute; left: 15px; top: 17px; width: 46%; height: 5px; border-radius: 3px; background: var(--t); box-shadow: 0 12px 0 0 var(--a); opacity: .92; }++.dv-look b { font-size: .78rem; font-weight: 650; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-secondary); }++.dv-look:hover > span { background: color-mix(in srgb, var(--text-primary) 6%, transparent); }++.dv-look:active > span { transform: scale(.96); }++.dv-look input:checked + span { background: color-mix(in srgb, var(--accent) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent); }++.dv-look input:checked + span b { color: var(--text-primary); }++.dv-mod-more { display: flex; align-items: center; min-height: 44px; margin-top: 6px; padding: 0 12px; border-radius: 12px; color: var(--accent-light, var(--accent)); font-size: .88rem; font-weight: 650; text-decoration: none; }++.dv-mod-more::after { content: "→"; margin-left: auto; }++.dv-mod-more:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }++.dv-go { display: inline-flex; align-items: center; justify-content: center; gap: 8px; margin-left: auto; min-height: 48px; padding: 0 16px 0 20px; border: 0; border-radius: 999px; background: var(--accent-strong, var(--accent)); color: var(--on-accent-strong, #fff); font: inherit; font-weight: 800; cursor: pointer; transition: transform .12s, filter .2s, box-shadow .3s; box-shadow: 0 10px 24px -12px var(--accent); }++.dv-go:hover { filter: brightness(1.08); }++.dv-go:hover svg { transform: translateX(calc(3px * var(--ov-move, 1))); }++.dv-go svg { transition: transform .25s cubic-bezier(.2, 1.4, .3, 1); }++.dv-go:active { transform: scale(.96); }++/* Pressed: the button fills from the left and its arrow keeps stepping forward (nothing here spins). */++.dv-go[aria-busy="true"] { pointer-events: none; position: relative; overflow: hidden; }++.dv-go[aria-busy="true"]::before { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, color-mix(in srgb, #fff 26%, transparent), transparent); transform: translateX(-100%); animation: dvSweep 1.3s ease-in-out infinite; }++.dv-go[aria-busy="true"] svg { animation: dvStep .7s ease-in-out infinite; }++@keyframes dvSweep { to { transform: translateX(100%); } }++@keyframes dvStep { 50% { transform: translateX(calc(5px * var(--ov-move, 1))); opacity: .6; } }++/* Ideas to start from: those of the kind chosen above, each with its glyph. */++.dv-examples { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }++.dv-examples button { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; border: 1px solid var(--border); border-radius: 99px; padding: 0 14px 0 11px; background: var(--bg-card); color: var(--text-secondary); font: inherit; font-size: .86rem; cursor: pointer; transition: color .15s, border-color .15s, transform .12s; animation: dvIn .4s cubic-bezier(.2, .9, .3, 1.1) both; }++.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-recent { margin-top: 48px; }--.dv-recent h2 { font-size: 1rem; }--.dv-recent ul { padding-left: 20px; }++.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-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; }++.dv-rc { display: flex; align-items: center; gap: 12px; min-height: 64px; box-sizing: border-box; padding: 10px 14px 10px 10px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card); color: inherit; text-decoration: none; transition: border-color .15s, transform .12s; }++.dv-rc:hover { border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); }++.dv-rc:active { transform: scale(.98); }++.dv-rc-k { flex: none; display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 13%, transparent); }++.dv-rc-t { display: grid; gap: 2px; min-width: 0; }++.dv-rc-t b { font-size: .95rem; font-weight: 650; 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) {++ .dv-create { margin-top: 22px; }++ .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-mod > summary b { flex: 1; }++ .dv-mod-k { min-width: 4.6em; }++ .dv-go { 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; }++ .dv-examples { flex-wrap: nowrap; overflow-x: auto; margin-left: -16px; margin-right: -16px; padding: 0 16px 4px; scrollbar-width: none; }++ .dv-examples::-webkit-scrollbar { display: none; }++ .dv-examples button { flex: none; }++}++@keyframes dvFade { from { opacity: 0; } }++@keyframes dvRise { from { transform: translateY(calc(100% * var(--ov-move, 1))); opacity: calc(1 - var(--ov-move, 1) * .2); } }.dv-recent li { margin: 10px 0; overflow-wrap: anywhere; }.dv-canvas { min-height: 80vh; padding: 24px max(16px, 3vw) 120px; background: var(--bg-primary); color: var(--text-primary); }.dv-heading { max-width: 1320px; margin: 0 auto 24px; }@@@ -2,8 +2,38 @@'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; } };++ // 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 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 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')];++ 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.closest('.dv-mod')) { show(input); setTimeout(() => closeMods(null), 160); }++ save();++ });++ 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('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…'; } });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();}));return;@@@ -109,13 +109,15 @@ function createPageRoutes(ctx) {let links = [];try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }const recent = await ctx.designs.recent(subjectOf(req), links);-- return page(res, 200, designView.create({ recent }), req.viewer, { cacheControl: 'private, no-store' });++ // 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' });});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',-- owner: subjectOf(req), ip: req.ip, who: req.body.who });++ themeId: req.body.themeId || 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 */ }@@@ -5,7 +5,37 @@ const ovServe = require('openvibe-shared/serve');const { attrEscape } = require('../arena/sanitize');const { themeCss } = require('../designs');--const examples = ['Landing page for a coffee roaster', 'Dashboard for a fitness app', 'Pricing section', 'Mobile onboarding'];++// The composer's choices. The server takes kind, themeId and who (designs.create); each is a choice a person can see.++const G = (d) => `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;++const MAKES = [++ { id: 'page', name: 'Page', glyph: G('<rect x="3" y="4.5" width="18" height="15" rx="3"/><path d="M3 9h18M7 13h6M7 16h10"/>'),++ placeholder: 'Describe the page: who it is for and what it should get them to do…',++ examples: ['Landing page for a coffee roaster', 'Pricing page with three plans', 'Portfolio for a photographer'] },++ { id: 'screen', name: 'App screen', glyph: G('<rect x="7" y="2.5" width="10" height="19" rx="3"/><path d="M10.5 5.5h3M10 17.5h4"/>'),++ placeholder: 'Describe the screen: what the app does and what this screen is for…',++ examples: ['Mobile onboarding', 'Dashboard for a fitness app', 'A music player, now playing'] },++ { 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'] },++];++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' },++ { id: 'any', name: 'Top three', line: 'One take each from the three best models you can use, your own keys first' },++ { id: 'compare', name: 'Three makers', line: 'One take each from three different companies, side by side' },++];++const SPARK = G('<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9L12 17.5l-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>');++const ARROW = '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>';++// A theme's colours, only when they are plain colours (they go into a style attribute).++const colour = (v, fallback) => (/^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i.test(String(v || '')) ? String(v) : fallback);++const swatch = (t) => { const v = (t && t.variables) || {}; return `--b:${colour(v['--bg-primary'], '#0b0f19')};--c:${colour(v['--bg-card'], '#151b28')};--a:${colour(v['--accent'], '#3b82f6')};--t:${colour(v['--text-primary'], '#e6edf7')}`; };++function ago(iso, now = Date.now()) {++ const t = Date.parse(iso); if (!isFinite(t)) return '';++ const m = Math.max(0, Math.round((now - t) / 60000));++ if (m < 2) return 'just now'; if (m < 60) return `${m} min ago`;++ const h = Math.round(m / 60); if (h < 24) return `${h} h ago`;++ const d = Math.round(h / 24); return d < 30 ? `${d} ${d === 1 ? 'day' : 'days'} ago` : new Date(t).toISOString().slice(0, 10);++}/*** The page somebody who is not on a Specific-people design's list sees. It never shows the prompt,@@@ -21,16 +51,28 @@ 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 = [] } = {}) {++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>++<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>` : '';++ 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('')}++<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 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>--<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>`;++<form method="post" action="/create" class="dv-composer" data-dv-composer>++${kinds}++<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>`;return layout({ title: 'Create a design', path: '/create', body, active: '/create', scripts: ['js/design.js'] });}@@@ -19,7 +19,7 @@ assert.ok();// The /create submit button was white on the plain accent (3.67:1). The accent strong pair is text-safe.assert.ok(-- css.includes('.dv-create form button { display: block; margin: 0 0 2px auto; border: 0; border-radius: 9px; background: var(--accent-strong, var(--accent)); color: var(--on-accent-strong, #fff);'),++ /\.dv-go \{[^}]*background: var\(--accent-strong, var\(--accent\)\); color: var\(--on-accent-strong, #fff\);/.test(css),'the create submit button uses the accent strong pair');// The arena's small lines were muted text on a tint (4.1-4.4:1); the secondary text is the readable pair.@@@ -80,6 +80,13 @@ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));assert.equal(recent.headers.get('cache-control'), 'private, no-store');assert.match(recent.text, /A coffee roaster landing page/);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');++ 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.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 () => {const guest = await t.get('/create');
@@ -248,31 +248,112 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -248,31 +248,112 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
.ar-form textarea::placeholder { color: var(--text-muted, #7386a3); }
.ar-form textarea::placeholder { color: var(--text-muted, #7386a3); }
/* Design Studio A: a quiet prompt and canvas. */
/* Design Studio A: a quiet prompt and canvas. */
.dv-visually-hidden { position: absolute; clip-path: inset(50%); width: 1px; height: 1px; overflow: hidden; }
.dv-visually-hidden { position: absolute; clip-path: inset(50%); width: 1px; height: 1px; overflow: hidden; }
-.dv-create { max-width: 800px; margin: clamp(48px, 13vh, 150px) auto 100px; padding: 0 20px; }
+.dv-create { max-width: 820px; margin: clamp(28px, 9vh, 120px) auto 80px; padding: 0 16px; }
.ds .dv-create { min-height: calc(100svh - 110px); }
.ds .dv-create { min-height: calc(100svh - 110px); }
-.dv-create h1 { margin: 0 0 24px; font-size: clamp(2.2rem, 5vw, 3.6rem); letter-spacing: -.05em; line-height: 1.06; }
-.dv-create form { border: 1px solid var(--border); background: var(--bg-card); border-radius: 16px; padding: 12px; box-shadow: 0 16px 48px #0001; }
-.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 h1 { margin: 0 0 20px; font-size: clamp(2.1rem, 5vw, 3.4rem); letter-spacing: -.05em; line-height: 1.06; }
+/* The composer: what is made on top, the words, then a tray of choices and the button. */
+.dv-composer { position: relative; border: 1px solid var(--border); background: var(--bg-card); border-radius: 22px; padding: 8px; box-shadow: 0 22px 56px -26px color-mix(in srgb, var(--accent) 55%, transparent); transition: border-color .2s, box-shadow .3s; animation: dvIn .5s cubic-bezier(.2, .9, .3, 1.1) backwards; }
+/* (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; }
+.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; }
+.dv-kinds b { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.dv-kinds svg { flex: none; transition: transform .35s cubic-bezier(.2, 1.4, .3, 1); }
+.dv-kinds label:active span { transform: scale(.97); }
+.dv-kinds input:checked + span { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent-light, var(--accent)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 34%, transparent); }
+.dv-kinds input:checked + span svg { transform: scale(1.12) rotate(calc(-4deg * var(--ov-move, 1))); }
+.dv-kinds input:focus-visible + span, .dv-opt input:focus-visible + span, .dv-look input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
+.dv-create textarea { display: block; width: 100%; box-sizing: border-box; min-height: 132px; padding: 16px 12px; resize: vertical; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 1.12rem; line-height: 1.45; }
.dv-create textarea::placeholder { color: var(--text-muted); }
.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-strong, var(--accent)); color: var(--on-accent-strong, #fff); 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-tray { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-top: 8px; border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); }
+/* A choice in the tray: its name, what is chosen, and a sheet of the options (under it on a computer, from the bottom on a phone). */
+.dv-mod { position: relative; min-width: 0; }
+.dv-mod > summary { list-style: none; display: inline-flex; align-items: center; gap: 8px; max-width: 100%; box-sizing: border-box; min-height: 44px; padding: 0 12px 0 10px; border-radius: 999px; cursor: pointer; user-select: none; background: color-mix(in srgb, var(--text-primary) 5%, transparent); color: var(--text-secondary); font-size: .88rem; transition: background-color .18s, transform .12s; }
+.dv-mod > summary::-webkit-details-marker { display: none; }
+.dv-mod > summary::after { content: ""; flex: none; width: 7px; height: 7px; margin: 0 2px 3px 2px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); opacity: .6; transition: transform .2s; }
+.dv-mod[open] > summary::after { transform: translateY(3px) rotate(225deg); }
+.dv-mod > summary:hover, .dv-mod[open] > summary { background: color-mix(in srgb, var(--accent) 14%, transparent); }
+.dv-mod > summary:active { transform: scale(.97); }
+.dv-mod > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.dv-mod > summary svg { flex: none; width: 18px; height: 18px; color: var(--accent-light, var(--accent)); }
+.dv-mod-k { color: var(--text-muted); font-weight: 600; }
+.dv-mod > summary b { color: var(--text-primary); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.dv-sw { flex: none; width: 20px; height: 20px; border-radius: 50%; background: linear-gradient(135deg, var(--b) 50%, var(--a) 50%); box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 22%, transparent); }
+.dv-mod-sheet { position: absolute; z-index: 30; left: 0; top: calc(100% + 8px); width: min(380px, calc(100vw - 32px)); max-height: min(62vh, 460px); overflow: auto; overscroll-behavior: contain; box-sizing: border-box; padding: 12px; border-radius: 18px; background: var(--bg-elevated, var(--bg-secondary)); border: 1px solid var(--border); box-shadow: 0 24px 60px rgba(0, 0, 0, .42); transform-origin: 18% 0; animation: dvPop .24s cubic-bezier(.2, .9, .3, 1.15) both; }
+@keyframes dvPop { from { opacity: 0; transform: translateY(calc(-8px * var(--ov-move, 1))) scale(.96); } }
+.dv-mod-sheet header { display: grid; gap: 2px; padding: 2px 4px 10px; }
+.dv-mod-sheet header b { font-size: 1rem; }
+.dv-mod-sheet header small, .dv-opt small { color: var(--text-secondary); font-size: .8rem; line-height: 1.35; }
+.dv-opt > span { display: grid; gap: 2px; min-height: 56px; box-sizing: border-box; align-content: center; padding: 8px 40px 8px 12px; border-radius: 14px; transition: background-color .15s; }
+.dv-opt:hover > span { background: color-mix(in srgb, var(--text-primary) 6%, transparent); }
+.dv-opt b { font-size: .95rem; }
+.dv-opt input:checked + span { background: color-mix(in srgb, var(--accent) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 36%, transparent); }
+.dv-opt input:checked + span::after { content: ""; position: absolute; right: 16px; top: 50%; width: 7px; height: 13px; margin-top: -9px; border-right: 2.5px solid var(--accent-light, var(--accent)); border-bottom: 2.5px solid var(--accent-light, var(--accent)); transform: rotate(45deg); animation: dvTick .25s cubic-bezier(.2, 1.6, .4, 1) both; }
+@keyframes dvTick { from { transform: rotate(45deg) scale(0); } }
+.dv-looks { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
+.dv-look > span { display: grid; gap: 6px; padding: 5px 5px 7px; border-radius: 14px; transition: background-color .15s, transform .12s; }
+/* A theme in small: its page, a card on it, a line of its text and its accent. */
+.dv-look i { position: relative; display: block; height: 52px; border-radius: 10px; background: var(--b); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--t) 16%, transparent); overflow: hidden; }
+.dv-look i::before { content: ""; position: absolute; left: 8px; right: 8px; top: 9px; bottom: -6px; border-radius: 7px 7px 0 0; background: var(--c); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--t) 12%, transparent); }
+.dv-look i::after { content: ""; position: absolute; left: 15px; top: 17px; width: 46%; height: 5px; border-radius: 3px; background: var(--t); box-shadow: 0 12px 0 0 var(--a); opacity: .92; }
+.dv-look b { font-size: .78rem; font-weight: 650; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-secondary); }
+.dv-look:hover > span { background: color-mix(in srgb, var(--text-primary) 6%, transparent); }
+.dv-look:active > span { transform: scale(.96); }
+.dv-look input:checked + span { background: color-mix(in srgb, var(--accent) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent); }
+.dv-look input:checked + span b { color: var(--text-primary); }
+.dv-mod-more { display: flex; align-items: center; min-height: 44px; margin-top: 6px; padding: 0 12px; border-radius: 12px; color: var(--accent-light, var(--accent)); font-size: .88rem; font-weight: 650; text-decoration: none; }
+.dv-mod-more::after { content: "→"; margin-left: auto; }
+.dv-mod-more:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
+.dv-go { display: inline-flex; align-items: center; justify-content: center; gap: 8px; margin-left: auto; min-height: 48px; padding: 0 16px 0 20px; border: 0; border-radius: 999px; background: var(--accent-strong, var(--accent)); color: var(--on-accent-strong, #fff); font: inherit; font-weight: 800; cursor: pointer; transition: transform .12s, filter .2s, box-shadow .3s; box-shadow: 0 10px 24px -12px var(--accent); }
+.dv-go:hover { filter: brightness(1.08); }
+.dv-go:hover svg { transform: translateX(calc(3px * var(--ov-move, 1))); }
+.dv-go svg { transition: transform .25s cubic-bezier(.2, 1.4, .3, 1); }
+.dv-go:active { transform: scale(.96); }
+/* Pressed: the button fills from the left and its arrow keeps stepping forward (nothing here spins). */
+.dv-go[aria-busy="true"] { pointer-events: none; position: relative; overflow: hidden; }
+.dv-go[aria-busy="true"]::before { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, color-mix(in srgb, #fff 26%, transparent), transparent); transform: translateX(-100%); animation: dvSweep 1.3s ease-in-out infinite; }
+.dv-go[aria-busy="true"] svg { animation: dvStep .7s ease-in-out infinite; }
+@keyframes dvSweep { to { transform: translateX(100%); } }
+@keyframes dvStep { 50% { transform: translateX(calc(5px * var(--ov-move, 1))); opacity: .6; } }
+/* Ideas to start from: those of the kind chosen above, each with its glyph. */
+.dv-examples { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
+.dv-examples button { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; border: 1px solid var(--border); border-radius: 99px; padding: 0 14px 0 11px; background: var(--bg-card); color: var(--text-secondary); font: inherit; font-size: .86rem; cursor: pointer; transition: color .15s, border-color .15s, transform .12s; animation: dvIn .4s cubic-bezier(.2, .9, .3, 1.1) both; }
+.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-recent { margin-top: 48px; }
-.dv-recent h2 { font-size: 1rem; }
-.dv-recent ul { padding-left: 20px; }
+.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-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; }
+.dv-rc { display: flex; align-items: center; gap: 12px; min-height: 64px; box-sizing: border-box; padding: 10px 14px 10px 10px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card); color: inherit; text-decoration: none; transition: border-color .15s, transform .12s; }
+.dv-rc:hover { border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); }
+.dv-rc:active { transform: scale(.98); }
+.dv-rc-k { flex: none; display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 13%, transparent); }
+.dv-rc-t { display: grid; gap: 2px; min-width: 0; }
+.dv-rc-t b { font-size: .95rem; font-weight: 650; 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) {
+ .dv-create { margin-top: 22px; }
+ .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-mod > summary b { flex: 1; }
+ .dv-mod-k { min-width: 4.6em; }
+ .dv-go { 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; }
+ .dv-examples { flex-wrap: nowrap; overflow-x: auto; margin-left: -16px; margin-right: -16px; padding: 0 16px 4px; scrollbar-width: none; }
+ .dv-examples::-webkit-scrollbar { display: none; }
+ .dv-examples button { flex: none; }
+}
+@keyframes dvFade { from { opacity: 0; } }
+@keyframes dvRise { from { transform: translateY(calc(100% * var(--ov-move, 1))); opacity: calc(1 - var(--ov-move, 1) * .2); } }
.dv-recent li { margin: 10px 0; overflow-wrap: anywhere; }
.dv-recent li { margin: 10px 0; overflow-wrap: anywhere; }
.dv-canvas { min-height: 80vh; padding: 24px max(16px, 3vw) 120px; background: var(--bg-primary); color: var(--text-primary); }
.dv-canvas { min-height: 80vh; padding: 24px max(16px, 3vw) 120px; background: var(--bg-primary); color: var(--text-primary); }
.dv-heading { max-width: 1320px; margin: 0 auto 24px; }
.dv-heading { max-width: 1320px; margin: 0 auto 24px; }
@@ -2,8 +2,38 @@
@@ -2,8 +2,38 @@
'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; } };
+ // 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 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 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')];
+ 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.closest('.dv-mod')) { show(input); setTimeout(() => closeMods(null), 160); }
+ save();
+ });
+ 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('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…'; } });
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(); }
prompt.focus();
prompt.focus();
}));
}));
return;
return;
@@ -109,13 +109,15 @@ function createPageRoutes(ctx) {
@@ -109,13 +109,15 @@ function createPageRoutes(ctx) {
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 */ }
const recent = await ctx.designs.recent(subjectOf(req), links);
const recent = await ctx.designs.recent(subjectOf(req), links);
- return page(res, 200, designView.create({ recent }), req.viewer, { cacheControl: 'private, no-store' });
+ // 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' });
});
});
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',
- owner: subjectOf(req), ip: req.ip, who: req.body.who });
+ themeId: req.body.themeId || 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 */ }
@@ -5,7 +5,37 @@ const ovServe = require('openvibe-shared/serve');
@@ -5,7 +5,37 @@ const ovServe = require('openvibe-shared/serve');
const { attrEscape } = require('../arena/sanitize');
const { attrEscape } = require('../arena/sanitize');
const { themeCss } = require('../designs');
const { themeCss } = require('../designs');
-const examples = ['Landing page for a coffee roaster', 'Dashboard for a fitness app', 'Pricing section', 'Mobile onboarding'];
+// The composer's choices. The server takes kind, themeId and who (designs.create); each is a choice a person can see.
+const G = (d) => `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
+const MAKES = [
+ { id: 'page', name: 'Page', glyph: G('<rect x="3" y="4.5" width="18" height="15" rx="3"/><path d="M3 9h18M7 13h6M7 16h10"/>'),
+ placeholder: 'Describe the page: who it is for and what it should get them to do…',
+ examples: ['Landing page for a coffee roaster', 'Pricing page with three plans', 'Portfolio for a photographer'] },
+ { id: 'screen', name: 'App screen', glyph: G('<rect x="7" y="2.5" width="10" height="19" rx="3"/><path d="M10.5 5.5h3M10 17.5h4"/>'),
+ placeholder: 'Describe the screen: what the app does and what this screen is for…',
+ examples: ['Mobile onboarding', 'Dashboard for a fitness app', 'A music player, now playing'] },
+ { 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'] },
+];
+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' },
+ { id: 'any', name: 'Top three', line: 'One take each from the three best models you can use, your own keys first' },
+ { id: 'compare', name: 'Three makers', line: 'One take each from three different companies, side by side' },
+];
+const SPARK = G('<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9L12 17.5l-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>');
+const ARROW = '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>';
+// A theme's colours, only when they are plain colours (they go into a style attribute).
+const colour = (v, fallback) => (/^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i.test(String(v || '')) ? String(v) : fallback);
+const swatch = (t) => { const v = (t && t.variables) || {}; return `--b:${colour(v['--bg-primary'], '#0b0f19')};--c:${colour(v['--bg-card'], '#151b28')};--a:${colour(v['--accent'], '#3b82f6')};--t:${colour(v['--text-primary'], '#e6edf7')}`; };
+function ago(iso, now = Date.now()) {
+ const t = Date.parse(iso); if (!isFinite(t)) return '';
+ const m = Math.max(0, Math.round((now - t) / 60000));
+ if (m < 2) return 'just now'; if (m < 60) return `${m} min ago`;
+ const h = Math.round(m / 60); if (h < 24) return `${h} h ago`;
+ const d = Math.round(h / 24); return d < 30 ? `${d} ${d === 1 ? 'day' : 'days'} ago` : new Date(t).toISOString().slice(0, 10);
+}
/**
/**
* The page somebody who is not on a Specific-people design's list sees. It never shows the prompt,
* The page somebody who is not on a Specific-people design's list sees. It never shows the prompt,
@@ -21,16 +51,28 @@ function gate({ id, signedInAs = null, signInUrl = '/auth/login' }) {
@@ -21,16 +51,28 @@ 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 = [] } = {}) {
+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>
+<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>` : '';
+ 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('')}
+<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 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"><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>
-<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>`;
+<form method="post" action="/create" class="dv-composer" data-dv-composer>
+${kinds}
+<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>`;
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'] });
}
}
@@ -19,7 +19,7 @@ assert.ok(
@@ -19,7 +19,7 @@ assert.ok(
);
);
// The /create submit button was white on the plain accent (3.67:1). The accent strong pair is text-safe.
// The /create submit button was white on the plain accent (3.67:1). The accent strong pair is text-safe.
assert.ok(
assert.ok(
- css.includes('.dv-create form button { display: block; margin: 0 0 2px auto; border: 0; border-radius: 9px; background: var(--accent-strong, var(--accent)); color: var(--on-accent-strong, #fff);'),
+ /\.dv-go \{[^}]*background: var\(--accent-strong, var\(--accent\)\); color: var\(--on-accent-strong, #fff\);/.test(css),
'the create submit button uses the accent strong pair'
'the create submit button uses the accent strong pair'
);
);
// The arena's small lines were muted text on a tint (4.1-4.4:1); the secondary text is the readable pair.
// The arena's small lines were muted text on a tint (4.1-4.4:1); the secondary text is the readable pair.
@@ -80,6 +80,13 @@ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
@@ -80,6 +80,13 @@ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
assert.equal(recent.headers.get('cache-control'), 'private, no-store');
assert.equal(recent.headers.get('cache-control'), 'private, no-store');
assert.match(recent.text, /A coffee roaster landing page/);
assert.match(recent.text, /A coffee roaster landing page/);
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"):
+ // 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');
+ 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.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 () => {
const guest = await t.get('/create');
const guest = await t.get('/create');