openvibers/openvibe.design Commits

Create is a composer of choices: what you are making, the look, who designs it (#244)

OP OpenVibers committed 3 days ago · 4a7b5d7bd9 · 6 files, +197 −35

* Create is a composer of choices: what you are making, the look, who designs it

The kind (page, app screen, component), the theme and the designer were all taken by the server and mostly not offered.
Each is a choice in the composer now; recent designs are cards that say what each is.

* The pressed button fills and its arrow steps forward: nothing spins

Changes

ddiff --git a/public/css/design.css b/public/css/design.css
iindex 5dd0cd0..9416f19 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -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; }
ddiff --git a/public/js/design.js b/public/js/design.js
iindex d354e82..82eec93 100644
---- a/public/js/design.js
++++ b/public/js/design.js
@@@ -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;
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex b359d99..073053a 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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 */ }
ddiff --git a/server/views/design.js b/server/views/design.js
iindex 659388b..4bfc8be 100644
---- a/server/views/design.js
++++ b/server/views/design.js
@@@ -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'] });
}
ddiff --git a/test/contrast.test.js b/test/contrast.test.js
iindex a1f9df1..a42af70 100644
---- a/test/contrast.test.js
++++ b/test/contrast.test.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.
ddiff --git a/test/design.test.js b/test/design.test.js
iindex 74e53df..61dada4 100644
---- a/test/design.test.js
++++ b/test/design.test.js
@@@ -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');