openvibers/openvibe.codes Commits

Codes start page: one switch, the composer first, setup as a card (#307)

OP OpenVibers committed 6 days ago · b702521081 · 6 files, +84 −50

* Codes start page: one switch, the composer first, setup as a card

The owner's marked-up screenshot (11-07): the three tall cards become one segmented switch (New project, OpenVibe
site, My code), the greeting is one short line so the composer stays on a phone's first screen, the setup bar that
wrapped mid-phrase becomes a card under the composer (a ring with the count, the next step on its own line), the
repository shows by name instead of a cut-off URL, and pickers leave room for their arrow.

* Codes: what you are making is one tap (Website, Game, App, Stream overlay), a game is 2D or 3D

The owner (11-07): switching from website to game should be easy, a game 2D or 3D, more icons. The kind select becomes
icon chips with the design system's ring icons; Game shows a 2D / 3D switch, and the words Space's builder gets say
which (3D names three.js). A stream overlay kind builds a transparent 1920x1080 page for OBS, with its own ideas.

Changes

ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex 58615d2..6a77569 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -62,10 +62,11 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch-chip-static .ovt-avatar{width:22px;height:22px;margin:0;border-radius:7px;font-size:10px}
.ch-pick{position:relative;display:inline-flex;align-items:center;gap:6px;max-width:100%;height:32px;padding:0 10px;border-radius:999px;border:1px solid var(--ch-line);color:var(--ch-soft);font-size:13px}
.ch-pick:hover{background:var(--ch-hover);color:var(--ch-ink)}
--.ch-pick select{appearance:none;-webkit-appearance:none;border:0;background:transparent;color:inherit;font:inherit;font-weight:600;padding:0 16px 0 0;max-width:200px;cursor:pointer}
++.ch-pick select{appearance:none;-webkit-appearance:none;border:0;background:transparent;color:inherit;font:inherit;font-weight:600;padding:0 22px 0 0;max-width:min(200px,42vw);text-overflow:ellipsis;cursor:pointer}
.ch-pick select:focus{outline:none}
.ch-pick:focus-within{border-color:color-mix(in srgb,var(--ch-accent) 55%,var(--ch-line))}
--.ch-pick .ch-caret{position:absolute;right:8px;pointer-events:none}
++.ch-pick .ch-caret{position:absolute;right:10px;width:14px;height:14px;pointer-events:none}
++.ch-pick:has(.ch-caret){padding-right:30px}
.ch-pick .ch-i:not(.ch-caret){width:15px;height:15px}
.ch-pick-repo input{width:min(240px,48vw);border:0;background:transparent;color:inherit;font:inherit;font-size:13px}
.ch-pick-repo input:focus{outline:none}
@@@ -76,23 +77,34 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch .ovt-composer.is-sending textarea{opacity:.6}
/* A new chat: the greeting, then the composer (the kit's start page puts them together in the middle). */
--.ch-hello{display:grid;justify-items:center;gap:10px;text-align:center}
--.ch-hello h1{margin:6px 0 0;font-size:clamp(24px,3.2vw,32px);letter-spacing:-.03em;line-height:1.15;text-wrap:balance}
--.ch-hello p{margin:0;max-width:52ch;color:var(--ch-soft);line-height:1.55}
++.ch-hello{display:grid;justify-items:center;gap:6px;text-align:center}
++.ch-hello h1{margin:4px 0 0;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.03em;line-height:1.15;text-wrap:balance}
++.ch-hello p{margin:0;max-width:52ch;color:var(--ch-soft);line-height:1.5}
.ch .ovt.is-start .ovt-head .ovt-title{opacity:0}
.ch .ovt.is-start .ovt-head{border-bottom:0;background:none;backdrop-filter:none;-webkit-backdrop-filter:none}
--/* What you are making: three cards that belong to the composer's form. Each says where its work runs. */
--.ch-modes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;width:100%;margin:14px 0 0;padding:0;border:0;min-width:0}
--.ch-mode{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr);align-content:start;gap:2px 11px;padding:12px 14px;border:1px solid var(--ch-line);border-radius:16px;background:var(--ch-card);text-align:left;cursor:pointer;transition:border-color .15s,background-color .15s}
--.ch-mode:hover{border-color:color-mix(in srgb,var(--ch-accent) 35%,var(--ch-line))}
++/* What you are making: one segmented switch that belongs to the composer's form (11-07: was three tall cards). */
++.ch-modes{display:inline-flex;gap:3px;max-width:100%;margin:12px auto 0;padding:4px;border:1px solid var(--ch-line);border-radius:999px;background:color-mix(in srgb,var(--ch-ink) 4%,transparent);min-width:0}
++.ch-mode{position:relative;display:inline-flex;align-items:center;gap:7px;height:40px;padding:0 14px;border-radius:999px;color:var(--ch-soft);font-size:14px;white-space:nowrap;cursor:pointer;transition:background-color .2s,color .2s,box-shadow .2s}
++.ch-mode:hover{color:var(--ch-ink)}
.ch-mode input{position:absolute;inset:0;margin:0;opacity:0;cursor:pointer}
--.ch-mode-i{grid-row:span 2;display:grid;place-items:center;width:34px;height:34px;border-radius:11px;color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 14%,transparent)}
--.ch-mode-i .ch-i{width:18px;height:18px}
--.ch-mode b{font-size:14.5px;letter-spacing:-.01em;color:var(--ch-ink)}
--.ch-mode small{font-size:12.5px;line-height:1.4;color:var(--ch-muted)}
--.ch-mode:has(input:checked){border-color:color-mix(in srgb,var(--ch-accent) 70%,var(--ch-line));background:color-mix(in srgb,var(--ch-accent) 9%,var(--ch-card))}
--.ch-mode:has(input:checked) .ch-mode-i{color:var(--ch-on-accent);background:var(--ch-accent)}
++.ch-mode-i{display:grid;place-items:center;color:var(--ch-muted)}
++.ch-mode-i .ch-i{width:16px;height:16px}
++.ch-mode b{font-weight:650;letter-spacing:-.01em}
++.ch-mode:has(input:checked){color:var(--ch-ink);background:var(--ch-card);box-shadow:0 1px 2px rgba(0,0,0,.18),inset 0 0 0 1px color-mix(in srgb,var(--ch-accent) 45%,var(--ch-line))}
++.ch-mode:has(input:checked) .ch-mode-i{color:var(--ch-accent)}
.ch-mode:has(input:focus-visible){outline:2px solid var(--ch-accent);outline-offset:2px}
++@media (max-width:520px){.ch-mode{padding:0 11px;font-size:13.5px}.ch-mode:not(:has(input:checked)) .ch-mode-i{display:none}}
++/* What it is (11-07): icon chips, one tap each; a game shows 2D / 3D beside it. */
++.ch-kinds{display:flex;flex-wrap:wrap;align-items:center;gap:6px;min-width:0}
++.ch-kind,.ch-dim{position:relative;display:inline-flex;align-items:center;gap:7px;height:36px;padding:0 12px 0 7px;border:1px solid var(--ch-line);border-radius:999px;color:var(--ch-soft);font-size:13px;font-weight:600;white-space:nowrap;cursor:pointer;transition:border-color .15s,background-color .15s,color .15s}
++.ch-kind input,.ch-dim input{position:absolute;inset:0;margin:0;opacity:0;cursor:pointer}
++.ch-kind:hover,.ch-dim:hover{color:var(--ch-ink)}
++.ch-kind:has(input:checked),.ch-dim:has(input:checked){color:var(--ch-ink);border-color:color-mix(in srgb,var(--ch-accent) 65%,var(--ch-line));background:color-mix(in srgb,var(--ch-accent) 12%,transparent)}
++.ch-kind:has(input:focus-visible),.ch-dim:has(input:focus-visible){outline:2px solid var(--ch-accent);outline-offset:2px}
++.ch-dims{display:none;gap:4px;padding-left:4px;border-left:1px solid var(--ch-line);margin-left:2px}
++.ch-dim{height:32px;padding:0 11px;font-size:12.5px;font-weight:750;letter-spacing:.03em}
++.ch-kinds:has([data-ch-kind][value="game"]:checked) .ch-dims{display:inline-flex}
++@media (pointer:coarse){.ch-kind{height:40px}.ch-dim{height:40px}}
/* Each picker and line shows for the way it belongs to. */
.ch:has([data-ch-mode][value="new"]:checked) :is(.ch-for-openvibe,.ch-for-code,.ch-for-agents),
.ch:has([data-ch-mode][value="openvibe"]:checked) :is(.ch-for-new,.ch-for-code),
@@@ -148,11 +160,6 @@ html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=red
.ch-hello{gap:8px}
.ch-hello h1{font-size:23px}
.ch-hello p{font-size:14px}
-- .ch-modes{grid-template-columns:minmax(0,1fr);gap:6px;margin-top:8px}
-- .ch-mode{gap:1px 11px;padding:9px 12px;border-radius:14px}
-- .ch-mode-i{width:32px;height:32px;border-radius:10px;align-self:center}
-- .ch-mode b{font-size:14px}
-- .ch-mode small{font-size:12px;line-height:1.35}
}
/* The conductor's usage in the side panel (the failover, 2026-11-03): a meter per agent, the one that takes the next turn. */
.ch .ovt-panel-rows .ch-usage-meter{display:block;height:5px;margin-top:6px;border-radius:3px;background:var(--ch-hover);overflow:hidden}
@@@ -164,9 +171,13 @@ html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=red
.ch .ch-usage-foot{margin:8px 0 0;color:var(--ch-muted);font-size:12px;line-height:1.45}
.ch .ch-usage-foot a{color:var(--ch-accent)}
/* The home's line to the guided setup (2026-11-03): how far along, and what is next. */
--.ch .ch-setup-nudge{display:inline-flex;align-items:center;gap:10px;margin-top:10px;padding:9px 12px 9px 14px;min-height:44px;border:1px solid var(--ch-line);border-radius:999px;background:var(--ch-card);color:var(--ch-soft);font-size:13px;text-decoration:none;max-width:100%}
--.ch .ch-setup-nudge b{color:var(--ch-ink)}
++/* Setup, under the composer: a ring with the count, two left-aligned lines, never a centred line that wraps mid-phrase. */
++.ch .ch-setup-nudge{display:flex;align-items:center;gap:12px;width:100%;max-width:780px;margin:12px auto 0;padding:10px 12px 10px 10px;min-height:56px;box-sizing:border-box;border:1px solid var(--ch-line);border-radius:16px;background:var(--ch-card);color:var(--ch-soft);text-decoration:none;transition:border-color .15s,transform .15s}
.ch .ch-setup-nudge:hover{border-color:color-mix(in srgb,var(--ch-accent) 50%,var(--ch-line))}
--.ch .ch-setup-nudge-bar{flex:none;width:42px;height:6px;border-radius:3px;background:var(--ch-hover);overflow:hidden}
--.ch .ch-setup-nudge-bar>span{display:block;height:100%;border-radius:3px;background:var(--ch-accent)}
++.ch .ch-setup-ring{flex:none;position:relative;display:grid;place-items:center;width:38px;height:38px;border-radius:50%;background:conic-gradient(var(--ch-accent) calc(var(--p) * 1%),color-mix(in srgb,var(--ch-ink) 10%,transparent) 0)}
++.ch .ch-setup-ring::before{content:"";position:absolute;inset:4px;border-radius:50%;background:var(--ch-card)}
++.ch .ch-setup-ring b{position:relative;font-size:11px;font-weight:800;color:var(--ch-ink);font-variant-numeric:tabular-nums}
++.ch .ch-setup-text{display:grid;gap:1px;flex:1;min-width:0;text-align:left}
++.ch .ch-setup-text b{color:var(--ch-ink);font-size:14px;font-weight:650}
++.ch .ch-setup-text small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--ch-muted)}
.ch .ch-setup-nudge-go{flex:none;width:16px;height:16px;transform:rotate(-90deg)}
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 9cb6cee..9e2df17 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -24,12 +24,13 @@
const grow = () => text.dispatchEvent(new Event('input', { bubbles: true }));
const rail = $('[data-ch-ideas]', app);
const track = rail && $('[data-ch-ideas-track]', rail);
-- const kind = $('[data-ch-kind]', form);
++ // The kind is a group of radios (11-07); an idea picks its own.
++ const setKind = (v) => { const r = app.querySelector(`[data-ch-kind][value="${v}"]`); if (r) r.checked = true; };
const modes = Array.from(app.querySelectorAll('[data-ch-mode]'));
let sets = {};
try { sets = rail ? JSON.parse(rail.dataset.chIdeasets || '{}') : {}; } catch { sets = {}; }
const PLACEHOLDER = { new: 'Describe your website, game or app', openvibe: 'Describe the change, question or fix', code: 'Describe the change, question or fix' };
-- const ICON = (i) => (i.kind === 'game' ? 'game' : i.kind === 'app' ? 'app' : i.kind === 'website' ? 'web' : i.mode === 'openvibe' ? 'ov' : 'spark');
++ const ICON = (i) => (i.kind === 'game' ? 'game' : i.kind === 'app' ? 'app' : i.kind === 'website' ? 'web' : i.kind === 'overlay' ? 'spark' : i.mode === 'openvibe' ? 'ov' : 'spark');
// The rail's speed follows its length: about 32 px a second, whatever the ideas.
const pace = () => { if (track) track.style.setProperty('--ch-drift', `${Math.max(30, Math.round(track.scrollWidth / 2 / 32))}s`); };
function card(i, copy) {
@@@ -104,7 +105,7 @@
const b = e.target.closest('[data-ch-idea]');
if (!b) return;
choose(b.dataset.chIdeaMode);
-- if (kind && b.dataset.chIdeaKind) kind.value = b.dataset.chIdeaKind;
++ if (b.dataset.chIdeaKind) setKind(b.dataset.chIdeaKind);
text.value = b.dataset.chIdea;
grow();
text.focus();
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex eb9b592..cfea585 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -483,7 +483,8 @@ function createChatRoutes(ctx) {
machinesFor(req),
]);
const asked = String(req.query.repo || '');
-- const repo = asked || (repos[0] && repos[0].url) || '';
++ // A repository shows by its name (owner/name), not a cut-off URL; the server reads either.
++ const repo = asked || (repos[0] && (repos[0].full_name || repos[0].url)) || '';
const mode = start.MODES.includes(req.query.mode) ? req.query.mode : asked ? 'code' : repos.length ? 'code' : 'new';
const kind = start.KINDS[req.query.kind] ? req.query.kind : 'website';
const chosenProject = start.project(req.query.project) ? String(req.query.project) : 'auto';
@@@ -491,15 +492,20 @@ function createChatRoutes(ctx) {
const name = String(req.viewer.displayName || req.viewer.username || '').split(' ')[0];
// The three ways to start: radio cards above the composer (they belong to its form by id).
++ // One switch (the owner, 11-07: the three tall cards "rework these"): a short name each; what it means is the line
++ // under the composer, which changes with the choice.
const MODE_CARDS = [
-- ['new', 'spark', 'Something new', 'A website, game or app. OpenVibe AI builds it online: nothing to install.'],
-- ['openvibe', 'ov', 'An OpenVibe site', `Improve Live, Chat, Games or any of ${start.PROJECTS.length} projects.`],
-- ['code', 'repo', 'Your code', 'Any Git repository, private ones too. Agents work on your computer.'],
++ ['new', 'spark', 'New project', 'A website, game or app, built online: nothing to install'],
++ ['openvibe', 'ov', 'OpenVibe site', `Improve Live, Chat, Games or any of ${start.PROJECTS.length} OpenVibe projects`],
++ ['code', 'repo', 'My code', 'Any Git repository, private ones too; agents work on your computer'],
];
-- const modes = `<fieldset class="ch-modes"><legend class="ch-sr">What are you making?</legend>${MODE_CARDS.map(([v, icon, title, line]) => `<label class="ch-mode"><input type="radio" name="mode" value="${v}" form="ch-start"${mode === v ? ' checked' : ''} data-ch-mode><span class="ch-mode-i">${ico(icon)}</span><b>${esc(title)}</b><small>${esc(line)}</small></label>`).join('')}</fieldset>`;
++ const modes = `<div class="ch-modes" role="radiogroup" aria-label="What are you making?">${MODE_CARDS.map(([v, icon, title, line]) => `<label class="ch-mode" title="${esc(line)}"><input type="radio" name="mode" value="${v}" form="ch-start"${mode === v ? ' checked' : ''} data-ch-mode><span class="ch-mode-i">${ico(icon)}</span><b>${esc(title)}</b></label>`).join('')}</div>`;
// The pickers: each shows for the way it belongs to (CSS reads the checked card).
-- const kindPick = `<label class="ch-pick ch-for-new" title="What it is"><span class="ch-sr">What it is</span>${ico('web')}<select name="kind" data-ch-kind data-ov-select="bare" data-ov-title="What it is">${Object.entries(start.KINDS).map(([v, k]) => `<option value="${v}"${kind === v ? ' selected' : ''}>${esc(k.label)}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
++ // What it is: icon chips from the design system's icons (the owner, 11-07: "switching from website to game should
++ // be easy … 2D or 3D … more icons"); a game asks 2D or 3D right beside it.
++ const dim = start.DIMS[req.query.dim] ? String(req.query.dim) : '2d';
++ const kindPick = `<div class="ch-kinds ch-for-new" role="radiogroup" aria-label="What it is">${Object.entries(start.KINDS).map(([v, k]) => `<label class="ch-kind"><input type="radio" name="kind" value="${v}" form="ch-start"${kind === v ? ' checked' : ''} data-ch-kind><span class="ov-icon" data-icon="${esc(k.icon || 'ov')}" data-size="22" aria-hidden="true"></span><span>${esc(k.label)}</span></label>`).join('')}<span class="ch-dims" role="radiogroup" aria-label="2D or 3D">${Object.keys(start.DIMS).map((d) => `<label class="ch-dim"><input type="radio" name="dim" value="${d}" form="ch-start"${dim === d ? ' checked' : ''}><span>${d.toUpperCase()}</span></label>`).join('')}</span></div>`;
const groups = [...new Set(start.PROJECTS.map((p) => p.group))];
const projectPick = `<label class="ch-pick ch-for-openvibe" title="Which OpenVibe project"><span class="ch-sr">Which OpenVibe project</span>${ico('ov')}<select name="project" data-ch-project data-ov-select="bare" data-ov-title="Which OpenVibe project"><option value="auto"${chosenProject === 'auto' ? ' selected' : ''}>Codes picks for you</option>${groups.map((g) => `<optgroup label="${esc(g)}">${start.PROJECTS.filter((p) => p.group === g).map((p) => `<option value="${esc(p.repository)}"${chosenProject === p.repository ? ' selected' : ''}${p.line ? ` title="${esc(p.line)}"` : ''}>${esc(p.name)}${p.domain ? ` · ${esc(p.domain)}` : ''}</option>`).join('')}</optgroup>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" list="ch-repos" autocomplete="off" data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>`;
@@@ -510,7 +516,7 @@ function createChatRoutes(ctx) {
const newRepo = `<a class="ch-chip ch-chip-new" href="/git">${ico('new')}New on OpenVibe Git</a>`;
const recent = `<div class="ch-recent ch-for-code" role="group" aria-label="Your repositories">${newRepo}${repos.length > 1 ? repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('') : ''}</div>`;
const picked = `<p class="ch-picked ch-for-openvibe" data-ch-picked data-ch-match-url="/chat/match" aria-live="polite">${ico('ov')}<span data-ch-picked-text>Say what you want changed and which site, in your own words: Codes works out which projects to change.</span></p>`;
-- const where = `${recent}${picked}<p class="ch-where"><span class="ch-for-new">${ico('web')}<span>Built on OpenVibe’s servers at <b>openvibe.space</b> <span class="ch-where-tail">and you get a live link; you check it before anything is built</span></span></span><span class="ch-for-agents">${whereAgents(mine)}</span></p>`;
++ const where = `${recent}${picked}<p class="ch-where"><span class="ch-for-new">${ico('web')}<span>Built online at <b>openvibe.space</b><span class="ch-where-tail">: you see it before it is built</span></span></span><span class="ch-for-agents">${whereAgents(mine)}</span></p>`;
// The ideas rail: it drifts sideways, stops under a pointer or the keyboard, and has its own pause. A press fills
// the box (and picks the way it belongs to), ready to be changed. The second copy is only there for the loop.
@@@ -522,11 +528,15 @@ function createChatRoutes(ctx) {
// Not set up yet: one line to the guided setup, saying how far along the person is (database reads only).
const progress = ctx.setupProgress ? await ctx.setupProgress(req).catch(() => null) : null;
-- const nudge = progress ? `<a class="ch-setup-nudge" href="/setup"><span class="ch-setup-nudge-bar" aria-hidden="true"><span style="width:${Math.round((progress.done / progress.of) * 100)}%"></span></span><span><b>Finish setting up</b> · ${progress.done} of ${progress.of} done · next: ${esc(progress.next)}</span>${ico('chevron', 'ch-setup-nudge-go')}</a>` : '';
-- const hello = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a friend. Pick what it is, and Codes sends it to the right place.</p>${nudge}${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;
++ // Setup, under the composer (the owner, 11-07: the centred bar that wrapped mid-phrase was "ugly"): a ring with the
++ // count, what it is, and the next step on its own line.
++ const pct = progress ? Math.round((progress.done / Math.max(1, progress.of)) * 100) : 0;
++ const nudge = progress ? `<a class="ch-setup-nudge" href="/setup"><span class="ch-setup-ring" style="--p:${pct}" aria-hidden="true"><b>${progress.done}/${progress.of}</b></span><span class="ch-setup-text"><b>Finish setting up Codes</b><small>Next: ${esc(progress.next)}</small></span>${ico('chevron', 'ch-setup-nudge-go')}</a>` : '';
++ // The greeting is one short line: the composer, the page's one action, stays on the first screen of a phone.
++ const hello = `<div class="ch-hello"><span class="ov-mark" data-size="40" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a friend.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;
return frame(req, res, {
title: 'New chat', heading: 'New chat', start: true, empty: hello,
-- composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'Describe the change, question or fix', pickers, where }) + ideas,
++ composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'What should change?', pickers, where }) + nudge + ideas,
});
});
@@@ -546,13 +556,13 @@ function createChatRoutes(ctx) {
const mode = start.MODES.includes(b.mode) ? b.mode : (b.project && !b.repo ? 'openvibe' : 'code');
// A typed address that carries a password never goes back into a URL (logs keep URLs).
const typed = String(b.repo || '');
-- const keep = [['mode', mode], ['repo', /:\/\/[^/]*@|^[^/]*:[^/]*@/.test(typed) ? '' : typed], ['project', b.project], ['kind', b.kind]].filter(([, v]) => v).map(([k, v]) => `&${k}=${encodeURIComponent(String(v).slice(0, 300))}`).join('');
++ const keep = [['mode', mode], ['repo', /:\/\/[^/]*@|^[^/]*:[^/]*@/.test(typed) ? '' : typed], ['project', b.project], ['kind', b.kind], ['dim', b.dim]].filter(([, v]) => v).map(([k, v]) => `&${k}=${encodeURIComponent(String(v).slice(0, 300))}`).join('');
const back = (msg) => res.redirect(303, `/?error=${encodeURIComponent(msg)}${keep}`);
const text = String(b.text || '').trim().slice(0, 16000);
if (!text) return back('Write what you want made or changed first.');
// Something new: OpenVibe AI builds it on openvibe.space. The words go there in the box; nothing is built
// until the person presses Build, so a handed-over description is never spent behind their back.
-- if (mode === 'new') return res.redirect(303, start.spaceHref({ kind: b.kind, text }));
++ if (mode === 'new') return res.redirect(303, start.spaceHref({ kind: b.kind, dim: b.dim, text }));
let repoUrl = '';
let targets = null; // several OpenVibe projects for one request: one task each, the first one opens
if (mode === 'openvibe') {
ddiff --git a/server/http/start.js b/server/http/start.js
iindex aacc1b3..5e3c68b 100644
---- a/server/http/start.js
++++ b/server/http/start.js
@@@ -21,10 +21,14 @@ const MODES = ['new', 'openvibe', 'code'];
// What "Something new" can be, and the words Space's builder is handed so it knows which.
const KINDS = {
-- website: { label: 'Website', lead: 'A website: ', words: /\b(site|website|page|portfolio|landing|blog)\b/i },
-- game: { label: 'Game', lead: 'A browser game: ', words: /\b(game|play|puzzle|quiz|arcade|platformer)\b/i },
-- app: { label: 'App or tool', lead: '', words: null },
++ website: { label: 'Website', icon: 'website', lead: 'A website: ', words: /\b(site|website|page|portfolio|landing|blog)\b/i },
++ game: { label: 'Game', icon: 'games', lead: 'A browser game: ', words: /\b(game|play|puzzle|quiz|arcade|platformer)\b/i },
++ app: { label: 'App', icon: 'tools', lead: '', words: null },
++ // A page for OBS: a transparent background, sized for a stream (alerts, a chat box, a goal bar, a countdown).
++ overlay: { label: 'Stream overlay', icon: 'live', lead: 'A stream overlay for OBS (a page with a transparent background, 1920×1080): ', words: /\b(overlay|obs|alert|browser source)\b/i },
};
++// A game is 2D or 3D (the owner, 11-07): the words say which, so Space's builder picks the right engine.
++const DIMS = { '2d': 'A 2D browser game: ', '3d': 'A 3D browser game (three.js): ' };
// The ideas on the rail: each one fills the box, ready to be changed. Plain, specific, and nothing that copies a
// named game or product.
@@@ -53,6 +57,12 @@ const IDEAS = {
'A countdown to my next stream',
'Flashcards for learning Japanese',
],
++ overlay: [
++ 'A follower alert with my logo that slides in',
++ 'A goal bar for my sub goal',
++ 'A chat box that fades old messages',
++ 'A starting-soon screen with a countdown',
++ ],
code: [
'Fix the failing tests',
'Explain how this project is laid out',
@@@ -77,7 +87,7 @@ const IDEAS = {
function ideasFor(mode) {
if (mode === 'new') {
const out = [];
-- for (let i = 0; i < 6; i++) for (const kind of ['website', 'game', 'app']) if (IDEAS[kind][i]) out.push({ text: IDEAS[kind][i], mode: 'new', kind });
++ for (let i = 0; i < 6; i++) for (const kind of ['website', 'game', 'app', 'overlay']) if (IDEAS[kind][i]) out.push({ text: IDEAS[kind][i], mode: 'new', kind });
return out;
}
return (IDEAS[mode] || IDEAS.code).map((text) => ({ text, mode, kind: null }));
@@@ -206,14 +216,15 @@ const MAX_HANDOVER_URL = 6000; // a request line nginx and every browser take
* the person back with the query; Space's own sign-in page would drop it). Long words are cut until the address fits,
* and the person sees exactly what arrived before anything is built.
*/
--function spaceHref({ kind, text, origin = SPACE }) {
++function spaceHref({ kind, text, dim, origin = SPACE }) {
const k = KINDS[kind] ? kind : 'website';
let words = String(text || '').trim();
-- const lead = KINDS[k].lead && !(KINDS[k].words && KINDS[k].words.test(words)) ? KINDS[k].lead : '';
++ const said = KINDS[k].words && KINDS[k].words.test(words);
++ const lead = k === 'game' && DIMS[dim] ? DIMS[dim] : KINDS[k].lead && !said ? KINDS[k].lead : '';
const build = (w) => `${origin}/auth/login?next=${encodeURIComponent(`/spaces/new?prompt=${encodeURIComponent(`${lead}${w}`.slice(0, 4000))}&from=codes`)}`;
let href = build(words);
while (href.length > MAX_HANDOVER_URL && words.length > 40) { words = words.slice(0, Math.floor(words.length * 0.85)).trimEnd(); href = build(words); }
return href;
}
--module.exports = { MODES, KINDS, IDEAS, ideasFor, PROJECTS, project, matchProjects, normalizeRepo, spaceHref, SPACE };
++module.exports = { MODES, KINDS, DIMS, IDEAS, ideasFor, PROJECTS, project, matchProjects, normalizeRepo, spaceHref, SPACE };
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex 2b60e44..688bbe1 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -82,14 +82,15 @@ const { stepKind } = require('../server/http/chat');
assert.ok(!/name="repo"[^>]*required/.test(h), 'the repository is not required: something new needs none');
assert.match(h, /<select name="project"[^>]*>[\s\S]*?<option value="OpenVibers\/OpenVibe\.Live"[^>]*>Live · openvibe\.live<\/option>/, 'OpenVibe projects are a list');
assert.match(h, /<option value="OpenVibers\/OpenVibe\.Shared"/, 'the shared libraries too');
-- assert.match(h, /<select name="kind"[^>]*>[\s\S]*?>Website<\/option>[\s\S]*?>Game<\/option>/, 'something new is a website, a game or an app');
++ assert.match(h, /<input type="radio" name="kind" value="website"[^>]*>[\s\S]*?Website[\s\S]*?name="kind" value="game"[\s\S]*?Game/, 'something new is a website, a game, an app or a stream overlay: one tap each (11-07)');
++ assert.match(h, /name="dim" value="2d"[^>]*checked[\s\S]*?name="dim" value="3d"/, 'a game is 2D or 3D');
assert.match(h, /class="ch-where"[\s\S]*?openvibe\.space[\s\S]*?Runs on/, 'each way says where it runs');
assert.match(h, /<section class="ch-spark" aria-label="Ideas to start from"[^>]*data-ch-ideasets="/, 'the ideas rail, with every set for the script');
assert.match(h, /<ul class="ch-spark-list" aria-hidden="true">[\s\S]*?tabindex="-1"/, 'the loop copy is hidden from the keyboard and assistive tech');
assert.match(h, /data-ch-ideas-pause aria-pressed="false" aria-label="Pause the ideas"/, 'the moving rail has a pause');
const fresh = await t.get('/?mode=new&kind=game', { as: ada });
assert.match(fresh.text, /value="new" form="ch-start" checked/, 'the chosen way is kept');
-- assert.match(fresh.text, /<option value="game" selected>Game<\/option>/);
++ assert.match(fresh.text, /<input type="radio" name="kind" value="game" form="ch-start" checked data-ch-kind>/);
assert.match(fresh.text, /data-ch-idea-mode="new" data-ch-idea-kind="website"[\s\S]*?data-ch-idea-kind="game"[\s\S]*?data-ch-idea-kind="app"/, 'something new mixes the three kinds');
});
ddiff --git a/test/setup.test.js b/test/setup.test.js
iindex 9e83056..baa5f8b 100644
---- a/test/setup.test.js
++++ b/test/setup.test.js
@@@ -47,7 +47,7 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';
assert.match(css, /\.c4-setup-cmd code\{[^}]*white-space:pre-wrap;overflow-wrap:anywhere/);
const home = await t.get('/', { as: ada });
assert.match(home.text, /class="ch-setup-nudge" href="\/setup"/);
-- assert.match(home.text, /0 of 3 done · next: Your machine/);
++ assert.match(home.text, /<b>0\/3<\/b>[\s\S]*Finish setting up Codes[\s\S]*Next: Your machine/, 'a ring with the count, the name and the next step on its own line (11-07)');
});
await check('a paired machine with Claude Code signed in: two steps done, the pickers know', async () => {
@@@ -64,7 +64,7 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';
assert.match(res.text, /<option value="claude-code"[^>]*data-state="ok" data-badge="41% used"/, 'the default agent picker says what is ready');
assert.match(res.text, /<option value="opencode"[^>]*data-state="bad" data-badge="Sign in"[^>]*data-href="\/setup#agent-opencode"/);
const home = await t.get('/', { as: ada });
-- assert.match(home.text, /2 of 3 done · next: Your choices/);
++ assert.match(home.text, /<b>2\/3<\/b>[\s\S]*Next: Your choices/);
assert.match(home.text, /<option value="claude-code\|[^"]*"[^>]*data-state="ok"/, 'the composer\'s agent picker knows too');
assert.match(home.text, /data-ch-who data-ov-select="bare"/, 'the composer\'s chips use the bare select');
});