openvibers/openvibe.codes Commits
Codes C4c: one navigation per page, and nothing hides under the phone tabs (#180)
Changes
@@@ -1,10 +1,8 @@.c4{--c4-bg:#f7f8fa;--c4-surface:#fff;--c4-ink:#18212b;--c4-muted:#687583;--c4-line:#e1e6eb;--c4-accent:#176b5b;--c4-dot:#98a5aa;--c4-dot-ok:#69a24f;--c4-dot-bad:#ce7441;--c4-dot-skip:#b3bcc1;--c4-dot-open:#179579;--c4-ok-bg:#edf6e9;--c4-ok-ink:#47713d;--c4-bad-bg:#fff0e9;--c4-bad-ink:#995128;--c4-bad-line:#e7c4b2;--c4-skip-bg:#f0f2f3;--c4-skip-ink:#5b6870;--c4-open-bg:#e9f4f1;--c4-open-ink:#176b5b;--c4-quiet-bg:#eef2f3;--c4-quiet-ink:#4f5d67;--c4-quiet:#f3f5f6;--c4-ghost:#f7f8f9;color:var(--c4-ink);max-width:1540px;margin:0 auto;padding:0 32px 44px}--.c4-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:36px;padding-top:26px}--.c4-side nav{position:sticky;top:24px;display:grid;gap:5px}--.c4-side a{display:flex;align-items:center;gap:11px;min-height:44px;padding:9px 12px;border-radius:9px;color:var(--c4-muted);text-decoration:none;font-size:14px}--.c4-side svg{width:19px;height:19px;flex:none}--.c4-side a[aria-current],.c4-mobile a[aria-current]{color:var(--c4-accent);background:#e9f4f1;font-weight:650}--.c4-main{min-width:0;max-width:1200px;width:100%}++/* One navigation per page: the shared Frame navbar carries the workspace's sections, so the page has no sidebar++ of its own (openvibe.design/patterns/single-navbar). */++.c4-main{min-width:0;max-width:1200px;width:100%;padding-top:26px}++.c4-mobile a[aria-current]{color:var(--c4-accent);background:#e9f4f1;font-weight:650}.c4-title{display:flex;justify-content:space-between;align-items:end;margin:0 0 22px}.c4-title h1{font-size:clamp(26px,3vw,36px);letter-spacing:-.045em;margin:0;line-height:1.12}.c4-title p{margin:8px 0 0;color:var(--c4-muted);font-size:14px;line-height:1.5}@@@ -12,6 +10,7 @@.c4-card h2{font-size:16px;letter-spacing:-.02em;margin:0}.c4-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.c4-card-head a,.c4-card a:not(.c4-button){color:var(--c4-accent);font-size:13px}++.c4-sub{margin:6px 0 12px;color:var(--c4-muted);font-size:13px;line-height:1.45}.c4-composer{border-color:#d5e4df;background:linear-gradient(140deg,#fff,#f8fcfa)}.c4-composer form>label{display:block;font-size:15px;font-weight:650;margin-bottom:12px}.c4-composer textarea{width:100%;min-height:105px;resize:vertical;border:1px solid var(--c4-line);border-radius:9px;padding:13px;font:inherit;color:var(--c4-ink);background:#fff}@@@ -38,11 +37,11 @@.c4-progress-toggle{width:100%;display:flex;align-items:center;gap:9px;text-align:left;border:0;border-radius:9px;background:#edf5f2;color:var(--c4-ink);padding:12px 14px;min-height:46px;font:inherit;font-size:13px;cursor:pointer}.c4-progress-meta{color:var(--c4-muted);font-size:12px;margin-left:auto}.c4-tabs{display:flex;gap:5px;border-bottom:1px solid var(--c4-line);margin:16px 0 0;overflow-x:auto}.c4-tabs a{padding:11px 12px;color:var(--c4-muted);text-decoration:none;white-space:nowrap;border-bottom:2px solid transparent;font-size:13px;min-height:44px}.c4-tabs a[aria-selected=true]{color:var(--c4-accent);border-color:var(--c4-accent);font-weight:650}.c4-panel{padding:14px 0 4px;min-height:58px}.c4-panel[hidden]{display:none}.c4-panel p{font-size:13px;color:var(--c4-muted)}.c4-log{white-space:pre-wrap;overflow-wrap:anywhere;max-height:360px;overflow:auto;background:#f6f8f8;border-radius:8px;padding:12px;font-size:12px;color:#314047}.c4-code{white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f7f7;padding:12px;border-radius:8px;font-size:12px}.c4-settings{display:grid;gap:12px;font-size:13px}.c4-safety{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.c4-safety-card{display:flex;align-items:flex-start;gap:10px;border:1px solid var(--c4-line);border-radius:10px;padding:14px;cursor:pointer;min-height:90px}.c4-safety-card:has(input:checked){border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent)}.c4-safety-card input{margin-top:3px;accent-color:var(--c4-accent)}.c4-safety-card span{display:grid;gap:6px}.c4-safety-card small{color:var(--c4-muted);font-size:12px;line-height:1.4}.c4 a:focus-visible,.c4 button:focus-visible,.c4 input:focus-visible,.c4 textarea:focus-visible,.c4 select:focus-visible{outline:2px solid #188c75;outline-offset:2px}--@media(max-width:900px){.c4{padding-left:22px;padding-right:22px}.c4-layout{grid-template-columns:180px minmax(0,1fr);gap:22px}}--@media(max-width:760px){.c4{padding:0 15px calc(78px + env(safe-area-inset-bottom))}.c4-layout{display:block;padding-top:18px}.c4-side{display:none}.c4-title{margin-bottom:16px}.c4-card{padding:16px;border-radius:11px}.c4-grid{grid-template-columns:1fr;gap:0}.c4-controls{display:grid;grid-template-columns:1fr 1fr}.c4-controls label{min-width:0}.c4-controls select,.c4-controls input{width:100%}.c4-controls .c4-button{grid-column:1/-1}.c4-task-detail{padding:17px}.c4-task-heading{gap:8px}.c4-task-heading>.c4-status{flex:none}.c4-chips{gap:6px}.c4-chip{font-size:11px;padding:5px 8px}.c4-outcome{grid-template-columns:30px minmax(0,1fr);gap:8px}.c4-outcome-actions{grid-column:1/-1;justify-content:flex-start;padding-left:38px}.c4-opill{min-height:44px;padding:0 11px;font-size:12px}.c4-mobile{display:grid;position:fixed;z-index:20;bottom:0;left:0;right:0;grid-template-columns:repeat(5,1fr);padding:4px max(6px,env(safe-area-inset-left)) calc(4px + env(safe-area-inset-bottom));background:var(--c4-surface);border-top:1px solid var(--c4-line)}.c4-mobile a{display:flex;min-height:48px;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;font-size:10px;padding:4px 2px;border-radius:8px;color:var(--c4-muted);text-decoration:none}.c4-mobile svg{width:19px;height:19px}.c4-search{align-items:stretch;flex-wrap:wrap}.c4-search label{width:100%}.c4-safety{grid-template-columns:1fr}.c4-safety-card{min-height:68px}.c4-progress-toggle{flex-wrap:wrap}.c4-progress-meta{margin-left:0}.c4-task-meta{font-size:10px}}++@media(max-width:900px){.c4{padding-left:22px;padding-right:22px}}++@media(max-width:760px){.c4{padding:0 15px 24px}.c4-main{padding-top:18px}.c4-title{margin-bottom:16px}.c4-card{padding:16px;border-radius:11px}.c4-grid{grid-template-columns:1fr;gap:0}.c4-controls{display:grid;grid-template-columns:1fr 1fr}.c4-controls label{min-width:0}.c4-controls select,.c4-controls input{width:100%}.c4-controls .c4-button{grid-column:1/-1}.c4-task-detail{padding:17px}.c4-task-heading{gap:8px}.c4-task-heading>.c4-status{flex:none}.c4-chips{gap:6px}.c4-chip{font-size:11px;padding:5px 8px}.c4-outcome{grid-template-columns:30px minmax(0,1fr);gap:8px}.c4-outcome-actions{grid-column:1/-1;justify-content:flex-start;padding-left:38px}.c4-opill{min-height:44px;padding:0 11px;font-size:12px}.c4-mobile{display:grid;position:fixed;z-index:20;bottom:0;left:0;right:0;grid-template-columns:repeat(6,1fr);height:var(--c4-tabs-h);box-sizing:border-box;padding:0 max(6px,env(safe-area-inset-left)) env(safe-area-inset-bottom,0px);background:var(--c4-surface);border-top:1px solid var(--c4-line)}.c4-mobile a{display:flex;min-height:44px;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;font-size:10px;padding:4px 2px;border-radius:8px;color:var(--c4-muted);text-decoration:none}.c4-mobile svg{width:19px;height:19px}.c4-search{align-items:stretch;flex-wrap:wrap}.c4-search label{width:100%}.c4-safety{grid-template-columns:1fr}.c4-safety-card{min-height:68px}.c4-progress-toggle{flex-wrap:wrap}.c4-progress-meta{margin-left:0}.c4-task-meta{font-size:10px}}:root[data-theme-mode="dark"] .c4{--c4-bg:#101518;--c4-surface:#171f23;--c4-ink:#eaf0f1;--c4-muted:#a3b0b4;--c4-line:#2d393d;--c4-accent:#75d0ba;--c4-dot:#5b6870;--c4-dot-ok:#afd39c;--c4-dot-bad:#f0b18c;--c4-dot-skip:#5b6870;--c4-dot-open:#75d0ba;--c4-ok-bg:#243423;--c4-ok-ink:#afd39c;--c4-bad-bg:#39291f;--c4-bad-ink:#f0b18c;--c4-bad-line:#5a4034;--c4-skip-bg:#263034;--c4-skip-ink:#cad4d6;--c4-open-bg:#1d3831;--c4-open-ink:#75d0ba;--c4-quiet-bg:#20292d;--c4-quiet-ink:#cad4d6;--c4-quiet:#20292d;--c4-ghost:#11181b}:root[data-theme-mode="dark"] .c4-composer{background:linear-gradient(140deg,#171f23,#182722);border-color:#315148}:root[data-theme-mode="dark"] .c4 textarea,:root[data-theme-mode="dark"] .c4 select,:root[data-theme-mode="dark"] .c4 input{background:#11181b;color:var(--c4-ink)}--:root[data-theme-mode="dark"] .c4-side a[aria-current],:root[data-theme-mode="dark"] .c4-mobile a[aria-current]{background:#1d3831}:root[data-theme-mode="dark"] .c4-log,:root[data-theme-mode="dark"] .c4-code{background:#11181b;color:#d3dfdf}:root[data-theme-mode="dark"] .c4-button-light{background:#20292d;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-obadge{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-progress-toggle{background:#1d3831;color:var(--c4-ink)}++:root[data-theme-mode="dark"] .c4-mobile a[aria-current]{background:#1d3831}:root[data-theme-mode="dark"] .c4-log,:root[data-theme-mode="dark"] .c4-code{background:#11181b;color:#d3dfdf}:root[data-theme-mode="dark"] .c4-button-light{background:#20292d;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-obadge{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-progress-toggle{background:#1d3831;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-status{background:#263034;color:#cad4d6}:root[data-theme-mode="dark"] .c4-running,:root[data-theme-mode="dark"] .c4-queued{background:#1d3831;color:#75d0ba}:root[data-theme-mode="dark"] .c4-failed{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-done,:root[data-theme-mode="dark"] .c4-succeeded{background:#243423;color:#afd39c}.c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4-progress-content[data-live="false"]{grid-template-rows:1fr;opacity:1}.c4-progress-inner{min-height:0;overflow:hidden}.c4-usage-row{grid-template-columns:minmax(0,1fr) auto}.c4-usage-row .c4-task-title,.c4-usage-row .c4-task-meta{grid-column:1}.c4-usage-row>b{grid-column:2;grid-row:1/3;align-self:center}.page:has(.c4){width:100%;max-width:none;padding:0;min-height:0}.c4-attempts{display:grid;gap:6px;margin:10px 0;padding:0;list-style:none}.c4-attempt:before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 1px;border-radius:50%;background:var(--c4-accent)}.c4-attempt{color:var(--c4-muted);font-size:12px}.c4-log[hidden]{display:none}@@@ -153,3 +152,9 @@.c4-chip[href]{min-height:44px;align-items:center;padding:8px 12px}.c4-card-head a{display:inline-flex;align-items:center;min-height:44px}}++/* C4c: the phone tab bar's own height, and the page's bottom inset for it (openvibe.design/patterns/bottom-tabs).++ The workspace's tab bar is fixed at the bottom, so the scrollable content reserves its height and the safe area:++ the last line of the page and the footer clear it when the page is scrolled to the end. */++@media(max-width:760px){++ body.ov-workspace{--c4-tabs-h:calc(58px + env(safe-area-inset-bottom,0px));padding-bottom:var(--c4-tabs-h)}++}@@@ -67,7 +67,12 @@ function createC4Routes(ctx) {return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra });};const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;-- const wrap = (current, content, signedIn = false) => `<div class="c4"><div class="c4-layout"><aside class="c4-side"><nav aria-label="Workspace">${LINKS.map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></aside><main class="c4-main">${content}</main></div><nav class="c4-mobile" aria-label="Workspace">${LINKS.slice(0, 5).map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></div>`;++ /**++ * The page's frame. There is no sidebar: the shared Frame navbar carries the workspace's sections, so a page has++ * one navigation (openvibe.design/patterns/single-navbar). On a phone the same sections are the tab bar at the++ * bottom, and the page reserves its height (openvibe.design/patterns/bottom-tabs).++ */++ const wrap = (current, content, signedIn = false) => `<div class="c4"><main class="c4-main">${content}</main><nav class="c4-mobile" aria-label="Workspace">${LINKS.map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></div>`;const signIn = (req) => `<section class="c4-card c4-empty"><h2>Sign in to see your work</h2><p>Your tasks, machines and usage belong to your OpenVibe account.</p><a class="c4-button" href="/auth/login?next=${encodeURIComponent(req.originalUrl || '/')}">Sign in</a></section>`;const sameOrigin = (req) => {const origin = req.get('origin');@@@ -242,7 +247,7 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="const saved = repos.map((repo) => `<a class="c4-chip" href="/?repo=${encodeURIComponent(repo.url)}#c4-prompt" data-c4-saved-repo="${esc(repo.url)}">${icon('<path d="M4 4h16v16l-8-4-8 4z"/>')}${esc(repo.full_name)}</a>`).join('');const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" list="c4-repo-options" data-c4-repo-search data-c4-repo-url="/api/v1/me/repos"><datalist id="c4-repo-options">${repos.map((repo) => `<option value="${esc(repo.url)}">${esc(repo.full_name)}</option>`).join('')}</datalist></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><label>Machine <select name="machine"><option value="">Auto</option>${machineOptions}</select></label><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>--<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2><span class="c4-muted">Approvals and questions</span></div><div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div></section></div>++<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2></div><p class="c4-sub">Approvals and questions</p><div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div></section></div><section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>${signedIn && !mine.ok && mine.problem ? note(`Your machines could not be read, so the machine picker has only Auto. ${esc(mine.problem.detail || '')}`, 'bad') : ''}${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent', 'Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'], ['Your computer or ours', 'Pair your computer with one command. A hosted sandbox is coming soon.'], ['Proof you can inspect', 'See the changes, tests, screenshots and review before you approve.'], ['A cost you can follow', 'Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a, b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;@@@ -332,7 +337,8 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</hconst now = Date.now();const rows = (list.keys || []).map((k) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span><span class="c4-row-label">${esc(PROVIDER_NAMES[k.provider] || k.provider)}</span><span class="c4-row-note">${esc(k.hint || 'key stored')}${k.budget_usd_per_day != null ? ` · cap $${Number(k.budget_usd_per_day).toFixed(2)} a day` : ''}${k.last_used_at ? ` · last used ${esc(age(k.last_used_at))}` : ''}</span><span class="c4-inline-actions"><form method="post" action="/agents/connections/${esc(k.provider)}/test"><button class="c4-opill" type="submit">Test</button></form><form method="post" action="/agents/connections/${esc(k.provider)}/remove"><button class="c4-opill c4-opill-quiet" type="submit">Remove</button></form></span></li>`).join('');const plans = (list.plans || []).map((p) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-skip" aria-hidden="true"></span><span class="c4-row-label">${esc(p.label || p.provider || 'a plan')}</span><span class="c4-row-note">on ${esc(p.machine || 'your machine')}</span>${pill('On your machine', 'skip')}</li>`).join('');-- return `<section class="c4-card" id="connections"><div class="c4-card-head"><h2>Connections</h2><span class="c4-muted">Keys live in OpenVibe.AI, never here</span></div>++ return `<section class="c4-card" id="connections"><div class="c4-card-head"><h2>Connections</h2></div>++<p class="c4-sub">Keys live in OpenVibe.AI, never here</p>${problem ? note(`Your connections could not be read: ${esc(problem.detail)}`, 'bad') : ''}${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-emptyline">No provider keys yet. Add one below and the router can use it, and so can a task.</div>`}<h3 class="c4-subhead">Add or replace a key</h3>@@@ -399,7 +405,8 @@ ${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-econst kind = m.state === 'online' ? 'ok' : m.state === 'revoked' ? 'bad' : 'skip';return `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${kind}" aria-hidden="true"></span><span class="c4-row-label">${esc(m.name || m.node_id || m.id)}</span><span class="c4-row-note">${esc(machines.STATE_WORD[m.state] || m.state)}${m.last_seen_at ? ` · seen ${esc(age(m.last_seen_at))}` : ' · never seen'}${m.paired_for && m.paired_for.service ? ` · paired for ${esc(m.paired_for.service)}` : ''}</span>${m.state === 'revoked' ? pill('Revoked', 'bad') : `<form method="post" action="/machines/${esc(m.id)}/revoke"><button class="c4-opill c4-opill-quiet" type="submit">Revoke</button></form>`}</li>`;}).join('');-- const pairCard = `<section class="c4-card" id="pair"><div class="c4-card-head"><h2>Pair a machine</h2><span class="c4-muted">One command, one code</span></div>++ const pairCard = `<section class="c4-card" id="pair"><div class="c4-card-head"><h2>Pair a machine</h2></div>++<p class="c4-sub">One command, one code</p><ol class="c4-steps"><li><b>Get a one-time code</b> on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. It lasts ten minutes and works once.</li><li><b>Run the installer on the machine</b>, with the code it gives you: <code class="c4-code-inline">curl -fsSL https://openvibe.bot/install | sh -s -- --worker</code></li>@@@ -412,7 +419,7 @@ ${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-e${badCode ? note('a pairing code is 8 letters and digits, like ABCD-1234', 'bad') : ''}${code ? `<div class="c4-pair-command"><p class="c4-kv"><span>On the machine</span><code class="c4-code-inline">sudo openvibe-node pair ${esc(code)} --network https://openvibe.network</code></p><p class="c4-kv"><span>Or with the installer</span><code class="c4-code-inline">curl -fsSL https://openvibe.bot/install | sh -s -- --network https://openvibe.network --pairing <span class="c4-note-inline">pair_… from openvibe.bot</span> --code ${esc(code)} --worker</code></p><p class="c4-hint">The code is the one openvibe.bot gave you for this machine; Codes never mints it. Reload this page once the installer says it is paired.</p></div>` : ''}</section>`;-- const content = `${header('Machines', 'Your own computers, paired with your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Your machines</h2><span class="c4-muted">${(mine.machines || []).length} paired</span></div>${mine.ok ? (list ? `<ul class="c4-rows">${list}</ul>` : '<div class="c4-emptyline">No paired computers yet. Pair one below and it shows up here.</div>') : note(`Your machines could not be read. ${esc((mine.problem && mine.problem.detail) || 'Sign in again.')}`, 'bad')}</section>${pairCard}` : signIn(req)}`;++ const content = `${header('Machines', 'Your own computers, paired with your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Your machines</h2></div><p class="c4-sub">${(mine.machines || []).length} paired</p>${mine.ok ? (list ? `<ul class="c4-rows">${list}</ul>` : '<div class="c4-emptyline">No paired computers yet. Pair one below and it shows up here.</div>') : note(`Your machines could not be read. ${esc((mine.problem && mine.problem.detail) || 'Sign in again.')}`, 'bad')}</section>${pairCard}` : signIn(req)}`;page(req, res, 'Machines', wrap('/machines', content, !!viewer(req)));});r.post('/machines/:id/revoke', form, async (req, res) => {@@@ -431,7 +438,7 @@ ${code ? `<div class="c4-pair-command"><p class="c4-kv"><span>On the machine</splet report = { days: [], totals: { runs: 0, attempts: 0, minutes: 0, tokens: 0 }, window: 14 };if (viewer(req)) report = await usage.daily(req.viewer.subject, { days: 14 });const rows = report.days.map((d) => `<tr><td>${esc(d.day)}</td><td>${d.runs}</td><td>${d.attempts}</td><td>${d.minutes} min</td><td>${d.tokens.toLocaleString('en-US')}</td></tr>`).join('');-- const content = `${header('Usage', `The last ${report.window} days of your tasks.`)}${viewer(req) ? `<section class="c4-card"><div class="c4-card-head"><h2>Runs, minutes and tokens</h2><span class="c4-muted">Totals below</span></div>++ const content = `${header('Usage', `The last ${report.window} days of your tasks.`)}${viewer(req) ? `<section class="c4-card"><div class="c4-card-head"><h2>Runs, minutes and tokens</h2></div><p class="c4-sub">Totals below</p>${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Usage by day"><table class="c4-table"><thead><tr><th scope="col">Day</th><th scope="col">Runs</th><th scope="col">Attempts</th><th scope="col">Minutes</th><th scope="col">Tokens</th></tr></thead><tbody>${rows}</tbody><tfoot><tr><th scope="row">Total</th><td>${report.totals.runs}</td><td>${report.totals.attempts}</td><td>${report.totals.minutes} min</td><td>${report.totals.tokens.toLocaleString('en-US')}</td></tr></tfoot></table></div>` : '<div class="c4-emptyline">No usage yet. <a href="/#c4-prompt">Start a task</a> and its runs, minutes and tokens appear here.</div>'}<p class="c4-hint">Runs are tasks, attempts are the tries inside them, minutes are the time an attempt was running, and tokens are what its session reported.</p>@@@ -31,6 +31,19 @@ const NAV = [{ label: 'Community', href: '/policy', icon: 'fa-people-group', more: true },];++// The workspace's own sections (Codes C4c). The shared Frame owns the page's navigation (openvibe.design/patterns/++// single-navbar), so on the workspace pages the Frame navbar's links ARE these sections and the page carries no++// sidebar of its own. The product's own places (Get started, Run on your machine) move into More for a signed-in++// person, so a workspace page has one navigation and not two.++const WORKSPACE_NAV = [++ { label: 'Home', href: '/', icon: 'fa-house' },++ { label: 'Tasks', href: '/tasks', icon: 'fa-list' },++ { label: 'Agents', href: '/agents', icon: 'fa-robot' },++ { label: 'Machines', href: '/machines', icon: 'fa-display' },++ { label: 'Usage', href: '/usage', icon: 'fa-chart-line' },++ { label: 'Settings', href: '/settings', icon: 'fa-gear' },++];++const hashes = new Map();function assetVersion(rel) {if (hashes.has(rel)) return hashes.get(rel);@@@ -60,11 +73,23 @@ function renderPage(o) {const c4Workspace = pathOnly === '/' || /^\/(tasks|agents|machines|usage|settings)(\/|$)/.test(pathOnly);const loginNext = encodeURIComponent(pathNow);const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;++ // One navigation per page. On the workspace pages a signed-in person's navbar carries the workspace sections, with++ // the current one active, and the product's own places fold into More; a visitor who is not signed in keeps the++ // product's links, because the sections hold the visitor's own work and a visitor has none yet.++ const here = (href) => pathOnly === href || (href !== '/' && pathOnly.startsWith(`${href}/`));++ const links = (signedIn && c4Workspace)++ ? [++ ...WORKSPACE_NAV.map((l) => ({ ...l, active: here(l.href) })),++ // The catalogue of harnesses is the product's Agents page; the workspace's own Agents page is above, so the++ // product's one is named for what it is.++ ...NAV.map((l) => ({ ...l, more: true, label: l.href === '/harnesses' ? 'Agent catalogue' : l.label })),++ ]++ : NAV.map((l) => ({ ...l, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) }));const nav = {service: 'codes',brandName: SITE_NAME,apiBase: NETWORK_URL,-- links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),++ links,appBar: c4Workspace ? false : [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)history: { type: 'page', title: o.title || SITE_NAME },silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,@@@ -88,6 +113,8 @@ function renderPage(o) {return shell.page({name: SITE_NAME,lang: 'en',++ // The workspace pages carry their own phone tab bar, so the page's bottom inset is theirs (C4c).++ bodyClass: c4Workspace ? 'ov-workspace' : '',title: o.title || `${SITE_NAME}: code with any agent`,titleSuffix: o.title ? ` · ${SITE_NAME}` : '',siteName: SITE_NAME,nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++/**++ * Codes C4c: one navigation per page, and a phone page whose last line clears the tab bar.++ *++ * - the shared Frame navbar carries the workspace's sections; the page has no sidebar of its own++ * (openvibe.design/patterns/single-navbar)++ * - a visitor who is not signed in keeps the product's links, and a signed-in person's navbar folds them into More++ * - the workspace reserves the phone tab bar's height at the bottom of the page, so the last line and the footer++ * clear it when the page is scrolled to the end (openvibe.design/patterns/bottom-tabs)++ * - a card's muted note sits under its heading, not beside it, where it read as a second title++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++++/** The navbar's own configuration, as the page hands it to the Frame. */++const navbarOf = (text) => {++ const m = text.match(/window\.__OV_PAGE = (\{.*\});/);++ assert.ok(m, 'the page carries the Frame configuration');++ return JSON.parse(m[1]).navbar;++};++/** The links the navbar shows at the top, and the ones it folds into More. */++const split = (links) => ({++ top: links.filter((l) => !l.more).map((l) => l.label),++ more: links.filter((l) => l.more).map((l) => l.label),++});++++(async () => {++ const t = await boot();++ const ada = t.network.addUser('c4c-ada');++ try {++ await check('a signed-in person’s workspace page has one navigation: the Frame navbar’s sections', async () => {++ const page = await t.get('/tasks', { as: ada });++ assert.strictEqual(page.status, 200);++ const links = navbarOf(page.text).links;++ const { top, more } = split(links);++ assert.deepStrictEqual(top, ['Home', 'Tasks', 'Agents', 'Machines', 'Usage', 'Settings'], 'the sections are the navbar');++ assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'the product’s places fold into More');++ assert.ok(!top.includes('Agents') || top.filter((l) => l === 'Agents').length === 1, 'Agents is in one place only');++ const tasks = links.find((l) => l.href === '/tasks');++ assert.ok(tasks && tasks.active, 'the section the person is in is active');++ assert.ok(!links.find((l) => l.href === '/agents').active, 'and no other one is');++ // The page carries no sidebar of its own: the Frame owns the navigation.++ assert.ok(!page.text.includes('c4-side'), 'no second navigation on the page');++ assert.ok(!page.text.includes('c4-layout'), 'and no sidebar column');++ });++++ await check('the section that is not the person’s own — the product’s catalogue — is named for what it is', async () => {++ const page = await t.get('/tasks', { as: ada });++ const { more } = split(navbarOf(page.text).links);++ assert.ok(more.includes('Agent catalogue'), 'the catalogue is the catalogue, not a second Agents');++ assert.ok(!more.includes('Agents'), 'and there is no second Agents in More');++ });++++ await check('a visitor who is not signed in keeps the product’s links', async () => {++ const page = await t.get('/tasks');++ assert.strictEqual(page.status, 200);++ const { top, more } = split(navbarOf(page.text).links);++ assert.ok(top.includes('Get started') && top.includes('Run on your machine'), 'the product’s places are at the top');++ assert.ok(!top.includes('Tasks'), 'and the workspace’s sections are not');++ assert.ok(more.includes('Improve OpenVibe'), 'the rest folds into More');++ });++++ await check('a page that is not a workspace page keeps the product’s links for a signed-in person too', async () => {++ const page = await t.get('/harnesses', { as: ada });++ assert.strictEqual(page.status, 200);++ const { top } = split(navbarOf(page.text).links);++ assert.ok(top.includes('Get started') && top.includes('Run on your machine'), 'the product’s places are at the top');++ assert.ok(!top.includes('Machines'), 'and the workspace’s sections are not');++ });++++ await check('the phone page reserves the tab bar’s height, so nothing hides under it', async () => {++ const page = await t.get('/settings', { as: ada });++ assert.ok(page.text.includes('<body class="ov-workspace">'), 'the workspace page says it carries a tab bar');++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'c4.css'), 'utf8');++ assert.ok(/body\.ov-workspace\{[^}]*--c4-tabs-h:calc\(58px \+ env\(safe-area-inset-bottom,0px\)\)/.test(css),++ 'the tab bar’s height is its own height plus the safe area');++ assert.ok(/body\.ov-workspace\{[^}]*padding-bottom:var\(--c4-tabs-h\)/.test(css),++ 'and the page reserves it at the bottom');++ assert.ok(/\.c4-mobile\{[^}]*height:var\(--c4-tabs-h\)/.test(css), 'the bar itself is that tall');++ });++++ await check('a card’s muted note sits under its heading, not beside it', async () => {++ const page = await t.get('/', { as: ada });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('<h2>Needs you</h2></div><p class="c4-sub">Approvals and questions</p>'),++ 'the note is the card’s subtitle');++ assert.ok(!page.text.includes('<h2>Needs you</h2><span'), 'and not a second title beside the heading');++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -1,10 +1,8 @@
@@ -1,10 +1,8 @@
.c4{--c4-bg:#f7f8fa;--c4-surface:#fff;--c4-ink:#18212b;--c4-muted:#687583;--c4-line:#e1e6eb;--c4-accent:#176b5b;--c4-dot:#98a5aa;--c4-dot-ok:#69a24f;--c4-dot-bad:#ce7441;--c4-dot-skip:#b3bcc1;--c4-dot-open:#179579;--c4-ok-bg:#edf6e9;--c4-ok-ink:#47713d;--c4-bad-bg:#fff0e9;--c4-bad-ink:#995128;--c4-bad-line:#e7c4b2;--c4-skip-bg:#f0f2f3;--c4-skip-ink:#5b6870;--c4-open-bg:#e9f4f1;--c4-open-ink:#176b5b;--c4-quiet-bg:#eef2f3;--c4-quiet-ink:#4f5d67;--c4-quiet:#f3f5f6;--c4-ghost:#f7f8f9;color:var(--c4-ink);max-width:1540px;margin:0 auto;padding:0 32px 44px}
.c4{--c4-bg:#f7f8fa;--c4-surface:#fff;--c4-ink:#18212b;--c4-muted:#687583;--c4-line:#e1e6eb;--c4-accent:#176b5b;--c4-dot:#98a5aa;--c4-dot-ok:#69a24f;--c4-dot-bad:#ce7441;--c4-dot-skip:#b3bcc1;--c4-dot-open:#179579;--c4-ok-bg:#edf6e9;--c4-ok-ink:#47713d;--c4-bad-bg:#fff0e9;--c4-bad-ink:#995128;--c4-bad-line:#e7c4b2;--c4-skip-bg:#f0f2f3;--c4-skip-ink:#5b6870;--c4-open-bg:#e9f4f1;--c4-open-ink:#176b5b;--c4-quiet-bg:#eef2f3;--c4-quiet-ink:#4f5d67;--c4-quiet:#f3f5f6;--c4-ghost:#f7f8f9;color:var(--c4-ink);max-width:1540px;margin:0 auto;padding:0 32px 44px}
-.c4-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:36px;padding-top:26px}
-.c4-side nav{position:sticky;top:24px;display:grid;gap:5px}
-.c4-side a{display:flex;align-items:center;gap:11px;min-height:44px;padding:9px 12px;border-radius:9px;color:var(--c4-muted);text-decoration:none;font-size:14px}
-.c4-side svg{width:19px;height:19px;flex:none}
-.c4-side a[aria-current],.c4-mobile a[aria-current]{color:var(--c4-accent);background:#e9f4f1;font-weight:650}
-.c4-main{min-width:0;max-width:1200px;width:100%}
+/* One navigation per page: the shared Frame navbar carries the workspace's sections, so the page has no sidebar
+ of its own (openvibe.design/patterns/single-navbar). */
+.c4-main{min-width:0;max-width:1200px;width:100%;padding-top:26px}
+.c4-mobile a[aria-current]{color:var(--c4-accent);background:#e9f4f1;font-weight:650}
.c4-title{display:flex;justify-content:space-between;align-items:end;margin:0 0 22px}
.c4-title{display:flex;justify-content:space-between;align-items:end;margin:0 0 22px}
.c4-title h1{font-size:clamp(26px,3vw,36px);letter-spacing:-.045em;margin:0;line-height:1.12}
.c4-title h1{font-size:clamp(26px,3vw,36px);letter-spacing:-.045em;margin:0;line-height:1.12}
.c4-title p{margin:8px 0 0;color:var(--c4-muted);font-size:14px;line-height:1.5}
.c4-title p{margin:8px 0 0;color:var(--c4-muted);font-size:14px;line-height:1.5}
@@ -12,6 +10,7 @@
@@ -12,6 +10,7 @@
.c4-card h2{font-size:16px;letter-spacing:-.02em;margin:0}
.c4-card h2{font-size:16px;letter-spacing:-.02em;margin:0}
.c4-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}
.c4-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}
.c4-card-head a,.c4-card a:not(.c4-button){color:var(--c4-accent);font-size:13px}
.c4-card-head a,.c4-card a:not(.c4-button){color:var(--c4-accent);font-size:13px}
+.c4-sub{margin:6px 0 12px;color:var(--c4-muted);font-size:13px;line-height:1.45}
.c4-composer{border-color:#d5e4df;background:linear-gradient(140deg,#fff,#f8fcfa)}
.c4-composer{border-color:#d5e4df;background:linear-gradient(140deg,#fff,#f8fcfa)}
.c4-composer form>label{display:block;font-size:15px;font-weight:650;margin-bottom:12px}
.c4-composer form>label{display:block;font-size:15px;font-weight:650;margin-bottom:12px}
.c4-composer textarea{width:100%;min-height:105px;resize:vertical;border:1px solid var(--c4-line);border-radius:9px;padding:13px;font:inherit;color:var(--c4-ink);background:#fff}
.c4-composer textarea{width:100%;min-height:105px;resize:vertical;border:1px solid var(--c4-line);border-radius:9px;padding:13px;font:inherit;color:var(--c4-ink);background:#fff}
@@ -38,11 +37,11 @@
@@ -38,11 +37,11 @@
.c4-progress-toggle{width:100%;display:flex;align-items:center;gap:9px;text-align:left;border:0;border-radius:9px;background:#edf5f2;color:var(--c4-ink);padding:12px 14px;min-height:46px;font:inherit;font-size:13px;cursor:pointer}.c4-progress-meta{color:var(--c4-muted);font-size:12px;margin-left:auto}.c4-tabs{display:flex;gap:5px;border-bottom:1px solid var(--c4-line);margin:16px 0 0;overflow-x:auto}.c4-tabs a{padding:11px 12px;color:var(--c4-muted);text-decoration:none;white-space:nowrap;border-bottom:2px solid transparent;font-size:13px;min-height:44px}.c4-tabs a[aria-selected=true]{color:var(--c4-accent);border-color:var(--c4-accent);font-weight:650}.c4-panel{padding:14px 0 4px;min-height:58px}.c4-panel[hidden]{display:none}.c4-panel p{font-size:13px;color:var(--c4-muted)}.c4-log{white-space:pre-wrap;overflow-wrap:anywhere;max-height:360px;overflow:auto;background:#f6f8f8;border-radius:8px;padding:12px;font-size:12px;color:#314047}.c4-code{white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f7f7;padding:12px;border-radius:8px;font-size:12px}
.c4-progress-toggle{width:100%;display:flex;align-items:center;gap:9px;text-align:left;border:0;border-radius:9px;background:#edf5f2;color:var(--c4-ink);padding:12px 14px;min-height:46px;font:inherit;font-size:13px;cursor:pointer}.c4-progress-meta{color:var(--c4-muted);font-size:12px;margin-left:auto}.c4-tabs{display:flex;gap:5px;border-bottom:1px solid var(--c4-line);margin:16px 0 0;overflow-x:auto}.c4-tabs a{padding:11px 12px;color:var(--c4-muted);text-decoration:none;white-space:nowrap;border-bottom:2px solid transparent;font-size:13px;min-height:44px}.c4-tabs a[aria-selected=true]{color:var(--c4-accent);border-color:var(--c4-accent);font-weight:650}.c4-panel{padding:14px 0 4px;min-height:58px}.c4-panel[hidden]{display:none}.c4-panel p{font-size:13px;color:var(--c4-muted)}.c4-log{white-space:pre-wrap;overflow-wrap:anywhere;max-height:360px;overflow:auto;background:#f6f8f8;border-radius:8px;padding:12px;font-size:12px;color:#314047}.c4-code{white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f7f7;padding:12px;border-radius:8px;font-size:12px}
.c4-settings{display:grid;gap:12px;font-size:13px}.c4-safety{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.c4-safety-card{display:flex;align-items:flex-start;gap:10px;border:1px solid var(--c4-line);border-radius:10px;padding:14px;cursor:pointer;min-height:90px}.c4-safety-card:has(input:checked){border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent)}.c4-safety-card input{margin-top:3px;accent-color:var(--c4-accent)}.c4-safety-card span{display:grid;gap:6px}.c4-safety-card small{color:var(--c4-muted);font-size:12px;line-height:1.4}
.c4-settings{display:grid;gap:12px;font-size:13px}.c4-safety{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.c4-safety-card{display:flex;align-items:flex-start;gap:10px;border:1px solid var(--c4-line);border-radius:10px;padding:14px;cursor:pointer;min-height:90px}.c4-safety-card:has(input:checked){border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent)}.c4-safety-card input{margin-top:3px;accent-color:var(--c4-accent)}.c4-safety-card span{display:grid;gap:6px}.c4-safety-card small{color:var(--c4-muted);font-size:12px;line-height:1.4}
.c4 a:focus-visible,.c4 button:focus-visible,.c4 input:focus-visible,.c4 textarea:focus-visible,.c4 select:focus-visible{outline:2px solid #188c75;outline-offset:2px}
.c4 a:focus-visible,.c4 button:focus-visible,.c4 input:focus-visible,.c4 textarea:focus-visible,.c4 select:focus-visible{outline:2px solid #188c75;outline-offset:2px}
-@media(max-width:900px){.c4{padding-left:22px;padding-right:22px}.c4-layout{grid-template-columns:180px minmax(0,1fr);gap:22px}}
-@media(max-width:760px){.c4{padding:0 15px calc(78px + env(safe-area-inset-bottom))}.c4-layout{display:block;padding-top:18px}.c4-side{display:none}.c4-title{margin-bottom:16px}.c4-card{padding:16px;border-radius:11px}.c4-grid{grid-template-columns:1fr;gap:0}.c4-controls{display:grid;grid-template-columns:1fr 1fr}.c4-controls label{min-width:0}.c4-controls select,.c4-controls input{width:100%}.c4-controls .c4-button{grid-column:1/-1}.c4-task-detail{padding:17px}.c4-task-heading{gap:8px}.c4-task-heading>.c4-status{flex:none}.c4-chips{gap:6px}.c4-chip{font-size:11px;padding:5px 8px}.c4-outcome{grid-template-columns:30px minmax(0,1fr);gap:8px}.c4-outcome-actions{grid-column:1/-1;justify-content:flex-start;padding-left:38px}.c4-opill{min-height:44px;padding:0 11px;font-size:12px}.c4-mobile{display:grid;position:fixed;z-index:20;bottom:0;left:0;right:0;grid-template-columns:repeat(5,1fr);padding:4px max(6px,env(safe-area-inset-left)) calc(4px + env(safe-area-inset-bottom));background:var(--c4-surface);border-top:1px solid var(--c4-line)}.c4-mobile a{display:flex;min-height:48px;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;font-size:10px;padding:4px 2px;border-radius:8px;color:var(--c4-muted);text-decoration:none}.c4-mobile svg{width:19px;height:19px}.c4-search{align-items:stretch;flex-wrap:wrap}.c4-search label{width:100%}.c4-safety{grid-template-columns:1fr}.c4-safety-card{min-height:68px}.c4-progress-toggle{flex-wrap:wrap}.c4-progress-meta{margin-left:0}.c4-task-meta{font-size:10px}}
+@media(max-width:900px){.c4{padding-left:22px;padding-right:22px}}
+@media(max-width:760px){.c4{padding:0 15px 24px}.c4-main{padding-top:18px}.c4-title{margin-bottom:16px}.c4-card{padding:16px;border-radius:11px}.c4-grid{grid-template-columns:1fr;gap:0}.c4-controls{display:grid;grid-template-columns:1fr 1fr}.c4-controls label{min-width:0}.c4-controls select,.c4-controls input{width:100%}.c4-controls .c4-button{grid-column:1/-1}.c4-task-detail{padding:17px}.c4-task-heading{gap:8px}.c4-task-heading>.c4-status{flex:none}.c4-chips{gap:6px}.c4-chip{font-size:11px;padding:5px 8px}.c4-outcome{grid-template-columns:30px minmax(0,1fr);gap:8px}.c4-outcome-actions{grid-column:1/-1;justify-content:flex-start;padding-left:38px}.c4-opill{min-height:44px;padding:0 11px;font-size:12px}.c4-mobile{display:grid;position:fixed;z-index:20;bottom:0;left:0;right:0;grid-template-columns:repeat(6,1fr);height:var(--c4-tabs-h);box-sizing:border-box;padding:0 max(6px,env(safe-area-inset-left)) env(safe-area-inset-bottom,0px);background:var(--c4-surface);border-top:1px solid var(--c4-line)}.c4-mobile a{display:flex;min-height:44px;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;font-size:10px;padding:4px 2px;border-radius:8px;color:var(--c4-muted);text-decoration:none}.c4-mobile svg{width:19px;height:19px}.c4-search{align-items:stretch;flex-wrap:wrap}.c4-search label{width:100%}.c4-safety{grid-template-columns:1fr}.c4-safety-card{min-height:68px}.c4-progress-toggle{flex-wrap:wrap}.c4-progress-meta{margin-left:0}.c4-task-meta{font-size:10px}}
:root[data-theme-mode="dark"] .c4{--c4-bg:#101518;--c4-surface:#171f23;--c4-ink:#eaf0f1;--c4-muted:#a3b0b4;--c4-line:#2d393d;--c4-accent:#75d0ba;--c4-dot:#5b6870;--c4-dot-ok:#afd39c;--c4-dot-bad:#f0b18c;--c4-dot-skip:#5b6870;--c4-dot-open:#75d0ba;--c4-ok-bg:#243423;--c4-ok-ink:#afd39c;--c4-bad-bg:#39291f;--c4-bad-ink:#f0b18c;--c4-bad-line:#5a4034;--c4-skip-bg:#263034;--c4-skip-ink:#cad4d6;--c4-open-bg:#1d3831;--c4-open-ink:#75d0ba;--c4-quiet-bg:#20292d;--c4-quiet-ink:#cad4d6;--c4-quiet:#20292d;--c4-ghost:#11181b}
:root[data-theme-mode="dark"] .c4{--c4-bg:#101518;--c4-surface:#171f23;--c4-ink:#eaf0f1;--c4-muted:#a3b0b4;--c4-line:#2d393d;--c4-accent:#75d0ba;--c4-dot:#5b6870;--c4-dot-ok:#afd39c;--c4-dot-bad:#f0b18c;--c4-dot-skip:#5b6870;--c4-dot-open:#75d0ba;--c4-ok-bg:#243423;--c4-ok-ink:#afd39c;--c4-bad-bg:#39291f;--c4-bad-ink:#f0b18c;--c4-bad-line:#5a4034;--c4-skip-bg:#263034;--c4-skip-ink:#cad4d6;--c4-open-bg:#1d3831;--c4-open-ink:#75d0ba;--c4-quiet-bg:#20292d;--c4-quiet-ink:#cad4d6;--c4-quiet:#20292d;--c4-ghost:#11181b}
:root[data-theme-mode="dark"] .c4-composer{background:linear-gradient(140deg,#171f23,#182722);border-color:#315148}:root[data-theme-mode="dark"] .c4 textarea,:root[data-theme-mode="dark"] .c4 select,:root[data-theme-mode="dark"] .c4 input{background:#11181b;color:var(--c4-ink)}
:root[data-theme-mode="dark"] .c4-composer{background:linear-gradient(140deg,#171f23,#182722);border-color:#315148}:root[data-theme-mode="dark"] .c4 textarea,:root[data-theme-mode="dark"] .c4 select,:root[data-theme-mode="dark"] .c4 input{background:#11181b;color:var(--c4-ink)}
-:root[data-theme-mode="dark"] .c4-side a[aria-current],:root[data-theme-mode="dark"] .c4-mobile a[aria-current]{background:#1d3831}:root[data-theme-mode="dark"] .c4-log,:root[data-theme-mode="dark"] .c4-code{background:#11181b;color:#d3dfdf}:root[data-theme-mode="dark"] .c4-button-light{background:#20292d;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-obadge{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-progress-toggle{background:#1d3831;color:var(--c4-ink)}
+:root[data-theme-mode="dark"] .c4-mobile a[aria-current]{background:#1d3831}:root[data-theme-mode="dark"] .c4-log,:root[data-theme-mode="dark"] .c4-code{background:#11181b;color:#d3dfdf}:root[data-theme-mode="dark"] .c4-button-light{background:#20292d;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-obadge{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-progress-toggle{background:#1d3831;color:var(--c4-ink)}
:root[data-theme-mode="dark"] .c4-status{background:#263034;color:#cad4d6}:root[data-theme-mode="dark"] .c4-running,:root[data-theme-mode="dark"] .c4-queued{background:#1d3831;color:#75d0ba}:root[data-theme-mode="dark"] .c4-failed{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-done,:root[data-theme-mode="dark"] .c4-succeeded{background:#243423;color:#afd39c}
:root[data-theme-mode="dark"] .c4-status{background:#263034;color:#cad4d6}:root[data-theme-mode="dark"] .c4-running,:root[data-theme-mode="dark"] .c4-queued{background:#1d3831;color:#75d0ba}:root[data-theme-mode="dark"] .c4-failed{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-done,:root[data-theme-mode="dark"] .c4-succeeded{background:#243423;color:#afd39c}
.c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4-progress-content[data-live="false"]{grid-template-rows:1fr;opacity:1}.c4-progress-inner{min-height:0;overflow:hidden}.c4-usage-row{grid-template-columns:minmax(0,1fr) auto}.c4-usage-row .c4-task-title,.c4-usage-row .c4-task-meta{grid-column:1}.c4-usage-row>b{grid-column:2;grid-row:1/3;align-self:center}
.c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4-progress-content[data-live="false"]{grid-template-rows:1fr;opacity:1}.c4-progress-inner{min-height:0;overflow:hidden}.c4-usage-row{grid-template-columns:minmax(0,1fr) auto}.c4-usage-row .c4-task-title,.c4-usage-row .c4-task-meta{grid-column:1}.c4-usage-row>b{grid-column:2;grid-row:1/3;align-self:center}
.page:has(.c4){width:100%;max-width:none;padding:0;min-height:0}.c4-attempts{display:grid;gap:6px;margin:10px 0;padding:0;list-style:none}.c4-attempt:before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 1px;border-radius:50%;background:var(--c4-accent)}.c4-attempt{color:var(--c4-muted);font-size:12px}.c4-log[hidden]{display:none}
.page:has(.c4){width:100%;max-width:none;padding:0;min-height:0}.c4-attempts{display:grid;gap:6px;margin:10px 0;padding:0;list-style:none}.c4-attempt:before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 1px;border-radius:50%;background:var(--c4-accent)}.c4-attempt{color:var(--c4-muted);font-size:12px}.c4-log[hidden]{display:none}
@@ -153,3 +152,9 @@
@@ -153,3 +152,9 @@
.c4-chip[href]{min-height:44px;align-items:center;padding:8px 12px}
.c4-chip[href]{min-height:44px;align-items:center;padding:8px 12px}
.c4-card-head a{display:inline-flex;align-items:center;min-height:44px}
.c4-card-head a{display:inline-flex;align-items:center;min-height:44px}
}
}
+/* C4c: the phone tab bar's own height, and the page's bottom inset for it (openvibe.design/patterns/bottom-tabs).
+ The workspace's tab bar is fixed at the bottom, so the scrollable content reserves its height and the safe area:
+ the last line of the page and the footer clear it when the page is scrolled to the end. */
+@media(max-width:760px){
+ body.ov-workspace{--c4-tabs-h:calc(58px + env(safe-area-inset-bottom,0px));padding-bottom:var(--c4-tabs-h)}
+}
@@ -67,7 +67,12 @@ function createC4Routes(ctx) {
@@ -67,7 +67,12 @@ function createC4Routes(ctx) {
return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra });
return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra });
};
};
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
- const wrap = (current, content, signedIn = false) => `<div class="c4"><div class="c4-layout"><aside class="c4-side"><nav aria-label="Workspace">${LINKS.map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></aside><main class="c4-main">${content}</main></div><nav class="c4-mobile" aria-label="Workspace">${LINKS.slice(0, 5).map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></div>`;
+ /**
+ * The page's frame. There is no sidebar: the shared Frame navbar carries the workspace's sections, so a page has
+ * one navigation (openvibe.design/patterns/single-navbar). On a phone the same sections are the tab bar at the
+ * bottom, and the page reserves its height (openvibe.design/patterns/bottom-tabs).
+ */
+ const wrap = (current, content, signedIn = false) => `<div class="c4"><main class="c4-main">${content}</main><nav class="c4-mobile" aria-label="Workspace">${LINKS.map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></div>`;
const signIn = (req) => `<section class="c4-card c4-empty"><h2>Sign in to see your work</h2><p>Your tasks, machines and usage belong to your OpenVibe account.</p><a class="c4-button" href="/auth/login?next=${encodeURIComponent(req.originalUrl || '/')}">Sign in</a></section>`;
const signIn = (req) => `<section class="c4-card c4-empty"><h2>Sign in to see your work</h2><p>Your tasks, machines and usage belong to your OpenVibe account.</p><a class="c4-button" href="/auth/login?next=${encodeURIComponent(req.originalUrl || '/')}">Sign in</a></section>`;
const sameOrigin = (req) => {
const sameOrigin = (req) => {
const origin = req.get('origin');
const origin = req.get('origin');
@@ -242,7 +247,7 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
@@ -242,7 +247,7 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
const saved = repos.map((repo) => `<a class="c4-chip" href="/?repo=${encodeURIComponent(repo.url)}#c4-prompt" data-c4-saved-repo="${esc(repo.url)}">${icon('<path d="M4 4h16v16l-8-4-8 4z"/>')}${esc(repo.full_name)}</a>`).join('');
const saved = repos.map((repo) => `<a class="c4-chip" href="/?repo=${encodeURIComponent(repo.url)}#c4-prompt" data-c4-saved-repo="${esc(repo.url)}">${icon('<path d="M4 4h16v16l-8-4-8 4z"/>')}${esc(repo.full_name)}</a>`).join('');
const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}
const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}
<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" list="c4-repo-options" data-c4-repo-search data-c4-repo-url="/api/v1/me/repos"><datalist id="c4-repo-options">${repos.map((repo) => `<option value="${esc(repo.url)}">${esc(repo.full_name)}</option>`).join('')}</datalist></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><label>Machine <select name="machine"><option value="">Auto</option>${machineOptions}</select></label><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>
<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" list="c4-repo-options" data-c4-repo-search data-c4-repo-url="/api/v1/me/repos"><datalist id="c4-repo-options">${repos.map((repo) => `<option value="${esc(repo.url)}">${esc(repo.full_name)}</option>`).join('')}</datalist></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><label>Machine <select name="machine"><option value="">Auto</option>${machineOptions}</select></label><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>
-<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2><span class="c4-muted">Approvals and questions</span></div><div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div></section></div>
+<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2></div><p class="c4-sub">Approvals and questions</p><div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div></section></div>
<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>
<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>
${signedIn && !mine.ok && mine.problem ? note(`Your machines could not be read, so the machine picker has only Auto. ${esc(mine.problem.detail || '')}`, 'bad') : ''}
${signedIn && !mine.ok && mine.problem ? note(`Your machines could not be read, so the machine picker has only Auto. ${esc(mine.problem.detail || '')}`, 'bad') : ''}
${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent', 'Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'], ['Your computer or ours', 'Pair your computer with one command. A hosted sandbox is coming soon.'], ['Proof you can inspect', 'See the changes, tests, screenshots and review before you approve.'], ['A cost you can follow', 'Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a, b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;
${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent', 'Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'], ['Your computer or ours', 'Pair your computer with one command. A hosted sandbox is coming soon.'], ['Proof you can inspect', 'See the changes, tests, screenshots and review before you approve.'], ['A cost you can follow', 'Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a, b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;
@@ -332,7 +337,8 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
@@ -332,7 +337,8 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
const now = Date.now();
const now = Date.now();
const rows = (list.keys || []).map((k) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span><span class="c4-row-label">${esc(PROVIDER_NAMES[k.provider] || k.provider)}</span><span class="c4-row-note">${esc(k.hint || 'key stored')}${k.budget_usd_per_day != null ? ` · cap $${Number(k.budget_usd_per_day).toFixed(2)} a day` : ''}${k.last_used_at ? ` · last used ${esc(age(k.last_used_at))}` : ''}</span><span class="c4-inline-actions"><form method="post" action="/agents/connections/${esc(k.provider)}/test"><button class="c4-opill" type="submit">Test</button></form><form method="post" action="/agents/connections/${esc(k.provider)}/remove"><button class="c4-opill c4-opill-quiet" type="submit">Remove</button></form></span></li>`).join('');
const rows = (list.keys || []).map((k) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span><span class="c4-row-label">${esc(PROVIDER_NAMES[k.provider] || k.provider)}</span><span class="c4-row-note">${esc(k.hint || 'key stored')}${k.budget_usd_per_day != null ? ` · cap $${Number(k.budget_usd_per_day).toFixed(2)} a day` : ''}${k.last_used_at ? ` · last used ${esc(age(k.last_used_at))}` : ''}</span><span class="c4-inline-actions"><form method="post" action="/agents/connections/${esc(k.provider)}/test"><button class="c4-opill" type="submit">Test</button></form><form method="post" action="/agents/connections/${esc(k.provider)}/remove"><button class="c4-opill c4-opill-quiet" type="submit">Remove</button></form></span></li>`).join('');
const plans = (list.plans || []).map((p) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-skip" aria-hidden="true"></span><span class="c4-row-label">${esc(p.label || p.provider || 'a plan')}</span><span class="c4-row-note">on ${esc(p.machine || 'your machine')}</span>${pill('On your machine', 'skip')}</li>`).join('');
const plans = (list.plans || []).map((p) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-skip" aria-hidden="true"></span><span class="c4-row-label">${esc(p.label || p.provider || 'a plan')}</span><span class="c4-row-note">on ${esc(p.machine || 'your machine')}</span>${pill('On your machine', 'skip')}</li>`).join('');
- return `<section class="c4-card" id="connections"><div class="c4-card-head"><h2>Connections</h2><span class="c4-muted">Keys live in OpenVibe.AI, never here</span></div>
+ return `<section class="c4-card" id="connections"><div class="c4-card-head"><h2>Connections</h2></div>
+<p class="c4-sub">Keys live in OpenVibe.AI, never here</p>
${problem ? note(`Your connections could not be read: ${esc(problem.detail)}`, 'bad') : ''}
${problem ? note(`Your connections could not be read: ${esc(problem.detail)}`, 'bad') : ''}
${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-emptyline">No provider keys yet. Add one below and the router can use it, and so can a task.</div>`}
${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-emptyline">No provider keys yet. Add one below and the router can use it, and so can a task.</div>`}
<h3 class="c4-subhead">Add or replace a key</h3>
<h3 class="c4-subhead">Add or replace a key</h3>
@@ -399,7 +405,8 @@ ${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-e
@@ -399,7 +405,8 @@ ${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-e
const kind = m.state === 'online' ? 'ok' : m.state === 'revoked' ? 'bad' : 'skip';
const kind = m.state === 'online' ? 'ok' : m.state === 'revoked' ? 'bad' : 'skip';
return `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${kind}" aria-hidden="true"></span><span class="c4-row-label">${esc(m.name || m.node_id || m.id)}</span><span class="c4-row-note">${esc(machines.STATE_WORD[m.state] || m.state)}${m.last_seen_at ? ` · seen ${esc(age(m.last_seen_at))}` : ' · never seen'}${m.paired_for && m.paired_for.service ? ` · paired for ${esc(m.paired_for.service)}` : ''}</span>${m.state === 'revoked' ? pill('Revoked', 'bad') : `<form method="post" action="/machines/${esc(m.id)}/revoke"><button class="c4-opill c4-opill-quiet" type="submit">Revoke</button></form>`}</li>`;
return `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${kind}" aria-hidden="true"></span><span class="c4-row-label">${esc(m.name || m.node_id || m.id)}</span><span class="c4-row-note">${esc(machines.STATE_WORD[m.state] || m.state)}${m.last_seen_at ? ` · seen ${esc(age(m.last_seen_at))}` : ' · never seen'}${m.paired_for && m.paired_for.service ? ` · paired for ${esc(m.paired_for.service)}` : ''}</span>${m.state === 'revoked' ? pill('Revoked', 'bad') : `<form method="post" action="/machines/${esc(m.id)}/revoke"><button class="c4-opill c4-opill-quiet" type="submit">Revoke</button></form>`}</li>`;
}).join('');
}).join('');
- const pairCard = `<section class="c4-card" id="pair"><div class="c4-card-head"><h2>Pair a machine</h2><span class="c4-muted">One command, one code</span></div>
+ const pairCard = `<section class="c4-card" id="pair"><div class="c4-card-head"><h2>Pair a machine</h2></div>
+<p class="c4-sub">One command, one code</p>
<ol class="c4-steps">
<ol class="c4-steps">
<li><b>Get a one-time code</b> on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. It lasts ten minutes and works once.</li>
<li><b>Get a one-time code</b> on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. It lasts ten minutes and works once.</li>
<li><b>Run the installer on the machine</b>, with the code it gives you: <code class="c4-code-inline">curl -fsSL https://openvibe.bot/install | sh -s -- --worker</code></li>
<li><b>Run the installer on the machine</b>, with the code it gives you: <code class="c4-code-inline">curl -fsSL https://openvibe.bot/install | sh -s -- --worker</code></li>
@@ -412,7 +419,7 @@ ${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-e
@@ -412,7 +419,7 @@ ${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-e
${badCode ? note('a pairing code is 8 letters and digits, like ABCD-1234', 'bad') : ''}
${badCode ? note('a pairing code is 8 letters and digits, like ABCD-1234', 'bad') : ''}
${code ? `<div class="c4-pair-command"><p class="c4-kv"><span>On the machine</span><code class="c4-code-inline">sudo openvibe-node pair ${esc(code)} --network https://openvibe.network</code></p><p class="c4-kv"><span>Or with the installer</span><code class="c4-code-inline">curl -fsSL https://openvibe.bot/install | sh -s -- --network https://openvibe.network --pairing <span class="c4-note-inline">pair_… from openvibe.bot</span> --code ${esc(code)} --worker</code></p><p class="c4-hint">The code is the one openvibe.bot gave you for this machine; Codes never mints it. Reload this page once the installer says it is paired.</p></div>` : ''}
${code ? `<div class="c4-pair-command"><p class="c4-kv"><span>On the machine</span><code class="c4-code-inline">sudo openvibe-node pair ${esc(code)} --network https://openvibe.network</code></p><p class="c4-kv"><span>Or with the installer</span><code class="c4-code-inline">curl -fsSL https://openvibe.bot/install | sh -s -- --network https://openvibe.network --pairing <span class="c4-note-inline">pair_… from openvibe.bot</span> --code ${esc(code)} --worker</code></p><p class="c4-hint">The code is the one openvibe.bot gave you for this machine; Codes never mints it. Reload this page once the installer says it is paired.</p></div>` : ''}
</section>`;
</section>`;
- const content = `${header('Machines', 'Your own computers, paired with your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Your machines</h2><span class="c4-muted">${(mine.machines || []).length} paired</span></div>${mine.ok ? (list ? `<ul class="c4-rows">${list}</ul>` : '<div class="c4-emptyline">No paired computers yet. Pair one below and it shows up here.</div>') : note(`Your machines could not be read. ${esc((mine.problem && mine.problem.detail) || 'Sign in again.')}`, 'bad')}</section>${pairCard}` : signIn(req)}`;
+ const content = `${header('Machines', 'Your own computers, paired with your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Your machines</h2></div><p class="c4-sub">${(mine.machines || []).length} paired</p>${mine.ok ? (list ? `<ul class="c4-rows">${list}</ul>` : '<div class="c4-emptyline">No paired computers yet. Pair one below and it shows up here.</div>') : note(`Your machines could not be read. ${esc((mine.problem && mine.problem.detail) || 'Sign in again.')}`, 'bad')}</section>${pairCard}` : signIn(req)}`;
page(req, res, 'Machines', wrap('/machines', content, !!viewer(req)));
page(req, res, 'Machines', wrap('/machines', content, !!viewer(req)));
});
});
r.post('/machines/:id/revoke', form, async (req, res) => {
r.post('/machines/:id/revoke', form, async (req, res) => {
@@ -431,7 +438,7 @@ ${code ? `<div class="c4-pair-command"><p class="c4-kv"><span>On the machine</sp
@@ -431,7 +438,7 @@ ${code ? `<div class="c4-pair-command"><p class="c4-kv"><span>On the machine</sp
let report = { days: [], totals: { runs: 0, attempts: 0, minutes: 0, tokens: 0 }, window: 14 };
let report = { days: [], totals: { runs: 0, attempts: 0, minutes: 0, tokens: 0 }, window: 14 };
if (viewer(req)) report = await usage.daily(req.viewer.subject, { days: 14 });
if (viewer(req)) report = await usage.daily(req.viewer.subject, { days: 14 });
const rows = report.days.map((d) => `<tr><td>${esc(d.day)}</td><td>${d.runs}</td><td>${d.attempts}</td><td>${d.minutes} min</td><td>${d.tokens.toLocaleString('en-US')}</td></tr>`).join('');
const rows = report.days.map((d) => `<tr><td>${esc(d.day)}</td><td>${d.runs}</td><td>${d.attempts}</td><td>${d.minutes} min</td><td>${d.tokens.toLocaleString('en-US')}</td></tr>`).join('');
- const content = `${header('Usage', `The last ${report.window} days of your tasks.`)}${viewer(req) ? `<section class="c4-card"><div class="c4-card-head"><h2>Runs, minutes and tokens</h2><span class="c4-muted">Totals below</span></div>
+ const content = `${header('Usage', `The last ${report.window} days of your tasks.`)}${viewer(req) ? `<section class="c4-card"><div class="c4-card-head"><h2>Runs, minutes and tokens</h2></div><p class="c4-sub">Totals below</p>
${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Usage by day"><table class="c4-table"><thead><tr><th scope="col">Day</th><th scope="col">Runs</th><th scope="col">Attempts</th><th scope="col">Minutes</th><th scope="col">Tokens</th></tr></thead><tbody>${rows}</tbody>
${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Usage by day"><table class="c4-table"><thead><tr><th scope="col">Day</th><th scope="col">Runs</th><th scope="col">Attempts</th><th scope="col">Minutes</th><th scope="col">Tokens</th></tr></thead><tbody>${rows}</tbody>
<tfoot><tr><th scope="row">Total</th><td>${report.totals.runs}</td><td>${report.totals.attempts}</td><td>${report.totals.minutes} min</td><td>${report.totals.tokens.toLocaleString('en-US')}</td></tr></tfoot></table></div>` : '<div class="c4-emptyline">No usage yet. <a href="/#c4-prompt">Start a task</a> and its runs, minutes and tokens appear here.</div>'}
<tfoot><tr><th scope="row">Total</th><td>${report.totals.runs}</td><td>${report.totals.attempts}</td><td>${report.totals.minutes} min</td><td>${report.totals.tokens.toLocaleString('en-US')}</td></tr></tfoot></table></div>` : '<div class="c4-emptyline">No usage yet. <a href="/#c4-prompt">Start a task</a> and its runs, minutes and tokens appear here.</div>'}
<p class="c4-hint">Runs are tasks, attempts are the tries inside them, minutes are the time an attempt was running, and tokens are what its session reported.</p>
<p class="c4-hint">Runs are tasks, attempts are the tries inside them, minutes are the time an attempt was running, and tokens are what its session reported.</p>
@@ -31,6 +31,19 @@ const NAV = [
@@ -31,6 +31,19 @@ const NAV = [
{ label: 'Community', href: '/policy', icon: 'fa-people-group', more: true },
{ label: 'Community', href: '/policy', icon: 'fa-people-group', more: true },
];
];
+// The workspace's own sections (Codes C4c). The shared Frame owns the page's navigation (openvibe.design/patterns/
+// single-navbar), so on the workspace pages the Frame navbar's links ARE these sections and the page carries no
+// sidebar of its own. The product's own places (Get started, Run on your machine) move into More for a signed-in
+// person, so a workspace page has one navigation and not two.
+const WORKSPACE_NAV = [
+ { label: 'Home', href: '/', icon: 'fa-house' },
+ { label: 'Tasks', href: '/tasks', icon: 'fa-list' },
+ { label: 'Agents', href: '/agents', icon: 'fa-robot' },
+ { label: 'Machines', href: '/machines', icon: 'fa-display' },
+ { label: 'Usage', href: '/usage', icon: 'fa-chart-line' },
+ { label: 'Settings', href: '/settings', icon: 'fa-gear' },
+];
+
const hashes = new Map();
const hashes = new Map();
function assetVersion(rel) {
function assetVersion(rel) {
if (hashes.has(rel)) return hashes.get(rel);
if (hashes.has(rel)) return hashes.get(rel);
@@ -60,11 +73,23 @@ function renderPage(o) {
@@ -60,11 +73,23 @@ function renderPage(o) {
const c4Workspace = pathOnly === '/' || /^\/(tasks|agents|machines|usage|settings)(\/|$)/.test(pathOnly);
const c4Workspace = pathOnly === '/' || /^\/(tasks|agents|machines|usage|settings)(\/|$)/.test(pathOnly);
const loginNext = encodeURIComponent(pathNow);
const loginNext = encodeURIComponent(pathNow);
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
+ // One navigation per page. On the workspace pages a signed-in person's navbar carries the workspace sections, with
+ // the current one active, and the product's own places fold into More; a visitor who is not signed in keeps the
+ // product's links, because the sections hold the visitor's own work and a visitor has none yet.
+ const here = (href) => pathOnly === href || (href !== '/' && pathOnly.startsWith(`${href}/`));
+ const links = (signedIn && c4Workspace)
+ ? [
+ ...WORKSPACE_NAV.map((l) => ({ ...l, active: here(l.href) })),
+ // The catalogue of harnesses is the product's Agents page; the workspace's own Agents page is above, so the
+ // product's one is named for what it is.
+ ...NAV.map((l) => ({ ...l, more: true, label: l.href === '/harnesses' ? 'Agent catalogue' : l.label })),
+ ]
+ : NAV.map((l) => ({ ...l, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) }));
const nav = {
const nav = {
service: 'codes',
service: 'codes',
brandName: SITE_NAME,
brandName: SITE_NAME,
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
- links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
+ links,
appBar: c4Workspace ? false : [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)
appBar: c4Workspace ? false : [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)
history: { type: 'page', title: o.title || SITE_NAME },
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
@@ -88,6 +113,8 @@ function renderPage(o) {
@@ -88,6 +113,8 @@ function renderPage(o) {
return shell.page({
return shell.page({
name: SITE_NAME,
name: SITE_NAME,
lang: 'en',
lang: 'en',
+ // The workspace pages carry their own phone tab bar, so the page's bottom inset is theirs (C4c).
+ bodyClass: c4Workspace ? 'ov-workspace' : '',
title: o.title || `${SITE_NAME}: code with any agent`,
title: o.title || `${SITE_NAME}: code with any agent`,
titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
siteName: SITE_NAME,
siteName: SITE_NAME,
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+/**
+ * Codes C4c: one navigation per page, and a phone page whose last line clears the tab bar.
+ *
+ * - the shared Frame navbar carries the workspace's sections; the page has no sidebar of its own
+ * (openvibe.design/patterns/single-navbar)
+ * - a visitor who is not signed in keeps the product's links, and a signed-in person's navbar folds them into More
+ * - the workspace reserves the phone tab bar's height at the bottom of the page, so the last line and the footer
+ * clear it when the page is scrolled to the end (openvibe.design/patterns/bottom-tabs)
+ * - a card's muted note sits under its heading, not beside it, where it read as a second title
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+
+/** The navbar's own configuration, as the page hands it to the Frame. */
+const navbarOf = (text) => {
+ const m = text.match(/window\.__OV_PAGE = (\{.*\});/);
+ assert.ok(m, 'the page carries the Frame configuration');
+ return JSON.parse(m[1]).navbar;
+};
+/** The links the navbar shows at the top, and the ones it folds into More. */
+const split = (links) => ({
+ top: links.filter((l) => !l.more).map((l) => l.label),
+ more: links.filter((l) => l.more).map((l) => l.label),
+});
+
+(async () => {
+ const t = await boot();
+ const ada = t.network.addUser('c4c-ada');
+ try {
+ await check('a signed-in person’s workspace page has one navigation: the Frame navbar’s sections', async () => {
+ const page = await t.get('/tasks', { as: ada });
+ assert.strictEqual(page.status, 200);
+ const links = navbarOf(page.text).links;
+ const { top, more } = split(links);
+ assert.deepStrictEqual(top, ['Home', 'Tasks', 'Agents', 'Machines', 'Usage', 'Settings'], 'the sections are the navbar');
+ assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'the product’s places fold into More');
+ assert.ok(!top.includes('Agents') || top.filter((l) => l === 'Agents').length === 1, 'Agents is in one place only');
+ const tasks = links.find((l) => l.href === '/tasks');
+ assert.ok(tasks && tasks.active, 'the section the person is in is active');
+ assert.ok(!links.find((l) => l.href === '/agents').active, 'and no other one is');
+ // The page carries no sidebar of its own: the Frame owns the navigation.
+ assert.ok(!page.text.includes('c4-side'), 'no second navigation on the page');
+ assert.ok(!page.text.includes('c4-layout'), 'and no sidebar column');
+ });
+
+ await check('the section that is not the person’s own — the product’s catalogue — is named for what it is', async () => {
+ const page = await t.get('/tasks', { as: ada });
+ const { more } = split(navbarOf(page.text).links);
+ assert.ok(more.includes('Agent catalogue'), 'the catalogue is the catalogue, not a second Agents');
+ assert.ok(!more.includes('Agents'), 'and there is no second Agents in More');
+ });
+
+ await check('a visitor who is not signed in keeps the product’s links', async () => {
+ const page = await t.get('/tasks');
+ assert.strictEqual(page.status, 200);
+ const { top, more } = split(navbarOf(page.text).links);
+ assert.ok(top.includes('Get started') && top.includes('Run on your machine'), 'the product’s places are at the top');
+ assert.ok(!top.includes('Tasks'), 'and the workspace’s sections are not');
+ assert.ok(more.includes('Improve OpenVibe'), 'the rest folds into More');
+ });
+
+ await check('a page that is not a workspace page keeps the product’s links for a signed-in person too', async () => {
+ const page = await t.get('/harnesses', { as: ada });
+ assert.strictEqual(page.status, 200);
+ const { top } = split(navbarOf(page.text).links);
+ assert.ok(top.includes('Get started') && top.includes('Run on your machine'), 'the product’s places are at the top');
+ assert.ok(!top.includes('Machines'), 'and the workspace’s sections are not');
+ });
+
+ await check('the phone page reserves the tab bar’s height, so nothing hides under it', async () => {
+ const page = await t.get('/settings', { as: ada });
+ assert.ok(page.text.includes('<body class="ov-workspace">'), 'the workspace page says it carries a tab bar');
+ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'c4.css'), 'utf8');
+ assert.ok(/body\.ov-workspace\{[^}]*--c4-tabs-h:calc\(58px \+ env\(safe-area-inset-bottom,0px\)\)/.test(css),
+ 'the tab bar’s height is its own height plus the safe area');
+ assert.ok(/body\.ov-workspace\{[^}]*padding-bottom:var\(--c4-tabs-h\)/.test(css),
+ 'and the page reserves it at the bottom');
+ assert.ok(/\.c4-mobile\{[^}]*height:var\(--c4-tabs-h\)/.test(css), 'the bar itself is that tall');
+ });
+
+ await check('a card’s muted note sits under its heading, not beside it', async () => {
+ const page = await t.get('/', { as: ada });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('<h2>Needs you</h2></div><p class="c4-sub">Approvals and questions</p>'),
+ 'the note is the card’s subtitle');
+ assert.ok(!page.text.includes('<h2>Needs you</h2><span'), 'and not a second title beside the heading');
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });