openvibers/openvibe.codes Commits

Codes C4c: one navigation per page, and nothing hides under the phone tabs (#180)

OP OpenVibers committed 14 days ago · 61a077fe10 · 4 files, +148 −16

The workspace pages carried two navigations: the shared Frame's navbar and a sidebar
of their own, so "Agents" appeared in both. The shared Frame owns the page's
navigation (openvibe.design/patterns/single-navbar), so the sidebar is gone and, for
a signed-in person, the Frame navbar's links are the workspace's sections - Home,
Tasks, Agents, Machines, Usage, Settings - with the one the person is in marked
active. The product's own places (Get started, Run on your machine) stay for a
visitor who is not signed in and fold into More for a signed-in one, where the
harness catalogue is named "Agent catalogue" so there is no second Agents anywhere.

On a phone the same sections are the tab bar at the bottom, and the page now
reserves its height (its own height plus env(safe-area-inset-bottom)), so the last
line and the footer clear the bar when the page is scrolled to the end
(openvibe.design/patterns/bottom-tabs).

A card's muted note no longer sits beside its heading, where "Needs you" read as two
titles: it is the card's subtitle under the heading now.

Changes

ddiff --git a/public/css/c4.css b/public/css/c4.css
iindex c54124d..6a99b57 100644
---- a/public/css/c4.css
++++ b/public/css/c4.css
@@@ -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)}
++}
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex 2c7645f..f0cb976 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -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</h
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 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-e
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>`;
}).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</sp
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 });
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>
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex e9adb9b..5a3005d 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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,
ddiff --git a/test/c4c.test.js b/test/c4c.test.js
nnew file mode 100644
iindex 0000000..91034dd
---- /dev/null
++++ b/test/c4c.test.js
@@@ -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&rsquo;s workspace page has one navigation: the Frame navbar&rsquo;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&rsquo;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&rsquo;s own &mdash; the product&rsquo;s catalogue &mdash; 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&rsquo;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&rsquo;s places are at the top');
++ assert.ok(!top.includes('Tasks'), 'and the workspace&rsquo;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&rsquo;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&rsquo;s places are at the top');
++ assert.ok(!top.includes('Machines'), 'and the workspace&rsquo;s sections are not');
++ });
++
++ await check('the phone page reserves the tab bar&rsquo;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&rsquo;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&rsquo;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&rsquo;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); });