openvibers/openvibe.codes Commits
Codes start page: one switch, the composer first, setup as a card (#307)
Changes
@@@ -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)}@@@ -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();@@@ -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 opensif (mode === 'openvibe') {@@@ -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 };@@@ -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');});@@@ -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');});
@@ -62,10 +62,11 @@ details[open]>summary .ch-caret{rotate:180deg}
@@ -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-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{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: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 select:focus{outline:none}
.ch-pick:focus-within{border-color:color-mix(in srgb,var(--ch-accent) 55%,var(--ch-line))}
.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 .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{width:min(240px,48vw);border:0;background:transparent;color:inherit;font:inherit;font-size:13px}
.ch-pick-repo input:focus{outline:none}
.ch-pick-repo input:focus{outline:none}
@@ -76,23 +77,34 @@ details[open]>summary .ch-caret{rotate:180deg}
@@ -76,23 +77,34 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch .ovt-composer.is-sending textarea{opacity:.6}
.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). */
/* 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 .ovt-title{opacity:0}
.ch .ovt.is-start .ovt-head{border-bottom:0;background:none;backdrop-filter:none;-webkit-backdrop-filter:none}
.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 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}
.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. */
/* 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="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),
.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
@@ -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{gap:8px}
.ch-hello h1{font-size:23px}
.ch-hello h1{font-size:23px}
.ch-hello p{font-size:14px}
.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. */
/* 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}
.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
@@ -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{margin:8px 0 0;color:var(--ch-muted);font-size:12px;line-height:1.45}
.ch .ch-usage-foot a{color:var(--ch-accent)}
.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. */
/* 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: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)}
.ch .ch-setup-nudge-go{flex:none;width:16px;height:16px;transform:rotate(-90deg)}
@@ -24,12 +24,13 @@
@@ -24,12 +24,13 @@
const grow = () => text.dispatchEvent(new Event('input', { bubbles: true }));
const grow = () => text.dispatchEvent(new Event('input', { bubbles: true }));
const rail = $('[data-ch-ideas]', app);
const rail = $('[data-ch-ideas]', app);
const track = rail && $('[data-ch-ideas-track]', rail);
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]'));
const modes = Array.from(app.querySelectorAll('[data-ch-mode]'));
let sets = {};
let sets = {};
try { sets = rail ? JSON.parse(rail.dataset.chIdeasets || '{}') : {}; } catch { 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 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.
// 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`); };
const pace = () => { if (track) track.style.setProperty('--ch-drift', `${Math.max(30, Math.round(track.scrollWidth / 2 / 32))}s`); };
function card(i, copy) {
function card(i, copy) {
@@ -104,7 +105,7 @@
@@ -104,7 +105,7 @@
const b = e.target.closest('[data-ch-idea]');
const b = e.target.closest('[data-ch-idea]');
if (!b) return;
if (!b) return;
choose(b.dataset.chIdeaMode);
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;
text.value = b.dataset.chIdea;
grow();
grow();
text.focus();
text.focus();
@@ -483,7 +483,8 @@ function createChatRoutes(ctx) {
@@ -483,7 +483,8 @@ function createChatRoutes(ctx) {
machinesFor(req),
machinesFor(req),
]);
]);
const asked = String(req.query.repo || '');
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 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 kind = start.KINDS[req.query.kind] ? req.query.kind : 'website';
const chosenProject = start.project(req.query.project) ? String(req.query.project) : 'auto';
const chosenProject = start.project(req.query.project) ? String(req.query.project) : 'auto';
@@ -491,15 +492,20 @@ function createChatRoutes(ctx) {
@@ -491,15 +492,20 @@ function createChatRoutes(ctx) {
const name = String(req.viewer.displayName || req.viewer.username || '').split(' ')[0];
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).
// 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 = [
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).
// 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 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 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>`;
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) {
@@ -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 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 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 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 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.
// 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) {
@@ -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).
// 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 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, {
return frame(req, res, {
title: 'New chat', heading: 'New chat', start: true, empty: hello,
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) {
@@ -546,13 +556,13 @@ function createChatRoutes(ctx) {
const mode = start.MODES.includes(b.mode) ? b.mode : (b.project && !b.repo ? 'openvibe' : 'code');
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).
// A typed address that carries a password never goes back into a URL (logs keep URLs).
const typed = String(b.repo || '');
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 back = (msg) => res.redirect(303, `/?error=${encodeURIComponent(msg)}${keep}`);
const text = String(b.text || '').trim().slice(0, 16000);
const text = String(b.text || '').trim().slice(0, 16000);
if (!text) return back('Write what you want made or changed first.');
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
// 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.
// 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 repoUrl = '';
let targets = null; // several OpenVibe projects for one request: one task each, the first one opens
let targets = null; // several OpenVibe projects for one request: one task each, the first one opens
if (mode === 'openvibe') {
if (mode === 'openvibe') {
@@ -21,10 +21,14 @@ const MODES = ['new', 'openvibe', 'code'];
@@ -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.
// What "Something new" can be, and the words Space's builder is handed so it knows which.
const KINDS = {
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
// 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.
// named game or product.
@@ -53,6 +57,12 @@ const IDEAS = {
@@ -53,6 +57,12 @@ const IDEAS = {
'A countdown to my next stream',
'A countdown to my next stream',
'Flashcards for learning Japanese',
'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: [
code: [
'Fix the failing tests',
'Fix the failing tests',
'Explain how this project is laid out',
'Explain how this project is laid out',
@@ -77,7 +87,7 @@ const IDEAS = {
@@ -77,7 +87,7 @@ const IDEAS = {
function ideasFor(mode) {
function ideasFor(mode) {
if (mode === 'new') {
if (mode === 'new') {
const out = [];
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 out;
}
}
return (IDEAS[mode] || IDEAS.code).map((text) => ({ text, mode, kind: null }));
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
@@ -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,
* 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.
* 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';
const k = KINDS[kind] ? kind : 'website';
let words = String(text || '').trim();
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`)}`;
const build = (w) => `${origin}/auth/login?next=${encodeURIComponent(`/spaces/new?prompt=${encodeURIComponent(`${lead}${w}`.slice(0, 4000))}&from=codes`)}`;
let href = build(words);
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); }
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;
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 };
@@ -82,14 +82,15 @@ const { stepKind } = require('../server/http/chat');
@@ -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.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, /<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, /<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, /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, /<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, /<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');
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 });
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, /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');
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');
});
});
@@ -47,7 +47,7 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';
@@ -47,7 +47,7 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';
assert.match(css, /\.c4-setup-cmd code\{[^}]*white-space:pre-wrap;overflow-wrap:anywhere/);
assert.match(css, /\.c4-setup-cmd code\{[^}]*white-space:pre-wrap;overflow-wrap:anywhere/);
const home = await t.get('/', { as: ada });
const home = await t.get('/', { as: ada });
assert.match(home.text, /class="ch-setup-nudge" href="\/setup"/);
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 () => {
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';
@@ -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="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"/);
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 });
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, /<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');
assert.match(home.text, /data-ch-who data-ov-select="bare"/, 'the composer\'s chips use the bare select');
});
});