openvibers/openvibe.space Commits

New space: a description handed over from Codes fills the box (?prompt=) (#152)

OP OpenVibers committed 12 days ago · 4306270783 · 4 files, +44 −6

Changes

ddiff --git a/public/css/spaces.css b/public/css/spaces.css
iindex 1cce6cc..1f733fe 100644
---- a/public/css/spaces.css
++++ b/public/css/spaces.css
@@@ -18,6 +18,9 @@
.sp-actions form { margin: 0; }
.sp-flash { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #2c3d5c); background: var(--bg-secondary, #101828); }
.sp-flash.is-bad { border-color: #ef4444; color: #fecaca; background: color-mix(in srgb, #ef4444 12%, transparent); }
++/* A description handed over from openvibe.codes: one quiet line in the accent, above the form it filled. */
++.sp-handover { display: flex; align-items: center; gap: 10px; margin: 0 0 4px; padding: 10px 14px; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--accent, #4f8cff) 40%, transparent); background: color-mix(in srgb, var(--accent, #4f8cff) 10%, transparent); color: var(--text-primary, #e8edf3); font-size: 14px; }
++.sp-handover i { color: var(--accent, #4f8cff); }
/* The hidden attribute wins over every display rule here (the AI overlay, Unpublish). */
.sp-editor [hidden], .sp-wrap [hidden], .sp-section [hidden] { display: none !important; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
ddiff --git a/server/spaces/pages.js b/server/spaces/pages.js
iindex 8a11f58..f3ed94a 100644
---- a/server/spaces/pages.js
++++ b/server/spaces/pages.js
@@@ -185,11 +185,15 @@ function createSpacesPages(ctx) {
html(res, 200, render.galleryPage({ viewer: signedIn(req) ? req.viewer : null, spaces: await spaces.gallery({ q, sort }), templates: templates.list(), q, sort, csrf: csrfOf(req) }));
}));
++ // ?prompt=<text>: a description written elsewhere on the network (openvibe.codes "Something new") fills the box,
++ // one press from a build; nothing is built until the person presses Build. ?from=codes says where it came from.
++ const promptOf = (req) => (typeof req.query.prompt === 'string' ? req.query.prompt.replace(/\r\n?/g, '\n').trim().slice(0, 4000) : '');
++ const fromOf = (req) => (req.query.from === 'codes' ? 'codes' : null);
router.get('/spaces/new', view(async (req, res) => {
if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');
// ?template=<id>: someone picked a template on openvibe.website; it leads the page, one press from a new space.
const pick = req.query.template ? templates.get(String(req.query.template)) : null;
-- html(res, 200, render.newPage({ viewer: req.viewer, templates: templates.list(), csrf: csrfOf(req), aiOn: ai.enabled(), buildsLeft: await buildsLeft(req), pick }));
++ html(res, 200, render.newPage({ viewer: req.viewer, templates: templates.list(), csrf: csrfOf(req), aiOn: ai.enabled(), buildsLeft: await buildsLeft(req), pick, prompt: pick ? '' : promptOf(req), from: fromOf(req) }));
}));
router.post('/spaces/new', ...action(async (req, res) => {
const body = req.body || {};
@@@ -220,7 +224,7 @@ function createSpacesPages(ctx) {
// ?template=<id>: a website template picked on openvibe.website leads the page, one press from a draft.
const picked = req.query.template ? templates.get(String(req.query.template)) : null;
const pick = picked && picked.kind === 'site' ? picked : null;
-- html(res, 200, render.guestPage({ templates: templates.sites(), error: null, prompt: '', aiOn: ai.enabled(), pick }));
++ html(res, 200, render.guestPage({ templates: templates.sites(), error: null, prompt: pick ? '' : promptOf(req), aiOn: ai.enabled(), pick, from: fromOf(req) }));
}));
router.post('/spaces/guest', ...guestAction(async (req, res) => {
const body = req.body || {};
ddiff --git a/server/spaces/render.js b/server/spaces/render.js
iindex 22f0158..eae2f40 100644
---- a/server/spaces/render.js
++++ b/server/spaces/render.js
@@@ -93,7 +93,12 @@ function galleryPage({ viewer, spaces, templates, q, sort, csrf }) {
return page({ title: 'Spaces', description: 'Make a space: a small app on your OpenVibe account, built by OpenVibe AI or from a template, with sign-in, shared lists, private storage and AI.', path: '/spaces', showcase: true, body });
}
--function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft, pick = null }) {
++// A description handed over from another OpenVibe site: say so, and put the Build button one press away.
++const handedOver = (from, prompt) => (prompt && from === 'codes'
++ ? '<p class="sp-handover" role="status"><i class="fa-solid fa-code" aria-hidden="true"></i> From OpenVibe Codes: check what you wrote, then press Build it.</p>'
++ : '');
++
++function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft, pick = null, from = null }) {
const picked = pick ? `<section class="sp-pick" style="--tpl:${esc(pick.accent)}" aria-labelledby="sp-pick-h">
<span class="sp-template-icon"><i class="fa-solid ${esc(pick.icon)}" aria-hidden="true"></i></span>
<div class="sp-pick-text"><p class="eyebrow">The template you picked</p><h2 id="sp-pick-h">${esc(pick.name)}</h2><p>${esc(pick.description)}</p></div>
@@@ -104,11 +109,12 @@ function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft, pic
<h1 class="sp-title">${pick ? 'Make it yours' : 'What do you want to make?'}</h1>
${error ? `<p class="sp-flash is-bad" role="alert">${esc(error)}</p>` : ''}
${picked}
++ ${handedOver(from, prompt)}
<form class="sp-ai-form" method="post" action="/spaces/new">
<input type="hidden" name="csrf" value="${esc(csrf)}">
<label for="sp-prompt">Describe it and OpenVibe AI builds it</label>
<textarea id="sp-prompt" name="prompt" rows="4" maxlength="4000" placeholder="A trivia quiz about 90s cartoons with a leaderboard…" ${aiOn ? '' : 'disabled'}>${esc(prompt || '')}</textarea>
-- <div class="sp-row"><button class="sp-btn" ${aiOn ? '' : 'disabled'}><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Build it</button>
++ <div class="sp-row"><button class="sp-btn" ${aiOn ? '' : 'disabled'}${prompt && aiOn ? ' autofocus' : ''}><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Build it</button>
<span class="sp-muted">${aiOn ? `${Number(buildsLeft)} AI builds left today. It takes about a minute; you can change everything after.` : 'OpenVibe AI is not available right now; start from a template instead.'}</span></div>
<div class="sp-ideas" aria-label="Ideas">${['A daily mood check-in with a shared chart', 'A pixel art board everyone can paint on', 'A recipe idea generator that asks what is in my fridge', 'A study flashcard deck I can quiz myself with'].map((i) => `<button type="button" class="sp-chip" data-idea="${esc(i)}">${esc(i)}</button>`).join('')}</div>
</form>
@@@ -233,7 +239,7 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
* The start page of a guest draft (openvibe.website, plan 5.9): no account, a template or a sentence, and a
* private link to edit it. Plain forms in the spaces markup; the design is the orchestrator's.
*/
--function guestPage({ templates, error, prompt, aiOn, pick = null }) {
++function guestPage({ templates, error, prompt, aiOn, pick = null, from = null }) {
const picked = pick ? `<section class="sp-pick" style="--tpl:${esc(pick.accent)}" aria-labelledby="sp-pick-h">
<span class="sp-template-icon"><i class="fa-solid ${esc(pick.icon)}" aria-hidden="true"></i></span>
<div class="sp-pick-text"><p class="eyebrow">The template you picked</p><h2 id="sp-pick-h">${esc(pick.name)}</h2><p>${esc(pick.description)}</p></div>
@@@ -250,10 +256,11 @@ function guestPage({ templates, error, prompt, aiOn, pick = null }) {
<p class="sp-lede">No account needed. Start from a template or describe what you want and OpenVibe AI writes it. You get a private link to edit it; sign in later to keep it.</p>
${error ? `<p class="sp-flash is-bad" role="alert">${esc(error)}</p>` : ''}
${picked}
++ ${handedOver(from, prompt)}
<form class="sp-ai-form" method="post" action="/spaces/guest">
<label for="sp-prompt">Describe it and OpenVibe AI builds it</label>
<textarea id="sp-prompt" name="prompt" rows="4" maxlength="4000" placeholder="A bakery site with today&rsquo;s hours, where to find us and how to order…" ${aiOn ? '' : 'disabled'}>${esc(prompt || '')}</textarea>
-- <div class="sp-row"><button class="sp-btn" ${aiOn ? '' : 'disabled'}><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Build it</button>
++ <div class="sp-row"><button class="sp-btn" ${aiOn ? '' : 'disabled'}${prompt && aiOn ? ' autofocus' : ''}><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Build it</button>
<span class="sp-muted">${aiOn ? 'It takes about a minute; you can change everything after.' : 'OpenVibe AI is not available right now; start from a template instead.'}</span></div>
</form>
<h2 class="sp-subtitle">Or start from a template</h2>
ddiff --git a/test/website-home.test.js b/test/website-home.test.js
iindex d136543..6b934dc 100644
---- a/test/website-home.test.js
++++ b/test/website-home.test.js
@@@ -96,6 +96,30 @@ assert.match(hostingSrc, /target\.kind === 'dashboard' \|\| target\.kind === 'we
const plain = await t.request({ method: 'GET', host: 'openvibe.space', path: '/spaces/new?template=nope', session: alice });
assert.ok(!plain.text.includes('class="sp-pick"'), 'an unknown template picks nothing');
});
++
++ await check('a description handed over from Codes fills the box, one press from a build', async () => {
++ const said = 'A snake game <b>with</b> a leaderboard & sound';
++ const r = await t.request({ method: 'GET', host: 'openvibe.space', path: `/spaces/new?prompt=${encodeURIComponent(said)}&from=codes`, session: alice });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.ok(r.text.includes('A snake game &lt;b&gt;with&lt;/b&gt; a leaderboard &amp; sound</textarea>'), 'the description fills the box, escaped');
++ assert.ok(!r.text.includes('<b>with</b>'), 'nothing in it is markup');
++ assert.match(r.text, /class="sp-handover"[^>]*>[\s\S]*?From OpenVibe Codes/);
++ assert.match(r.text, /<button class="sp-btn"[^>]*autofocus[^>]*>[\s\S]*?Build it/, 'Build it takes the focus');
++ const plain = await t.request({ method: 'GET', host: 'openvibe.space', path: `/spaces/new?prompt=${encodeURIComponent(said)}`, session: alice });
++ assert.ok(plain.text.includes('a leaderboard &amp; sound</textarea>') && !plain.text.includes('class="sp-handover"'), 'a prompt from anywhere else fills the box without the Codes line');
++ const picked = await t.request({ method: 'GET', host: 'openvibe.space', path: `/spaces/new?template=site-portfolio&prompt=${encodeURIComponent(said)}`, session: alice });
++ assert.ok(!picked.text.includes('a leaderboard &amp; sound'), 'a picked template leads, and the box stays empty');
++ });
++
++ await check('the guest start page takes a handed-over description too, and a gated page never echoes it', async () => {
++ const r = await t.request({ method: 'GET', host: 'openvibe.space', path: `/spaces/guest?prompt=${encodeURIComponent('A bakery site with our hours')}&from=codes` });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.ok(r.text.includes('A bakery site with our hours</textarea>'));
++ assert.match(r.text, /class="sp-handover"/);
++ const gated = await t.request({ method: 'GET', host: 'openvibe.space', path: `/spaces/new?prompt=${encodeURIComponent('secret plan for a site')}&from=codes` });
++ assert.strictEqual(gated.status, 401);
++ assert.ok(!gated.text.includes('secret plan'), 'an anonymous answer carries nothing from the query');
++ });
} finally { await t.close(); }
done();
})().catch((e) => { console.error(e); process.exit(1); });