openvibers/openvibe.exchange Commits

Exchange: search, chat and deep dive work again; every page's plain elements styled; /compute, the home and the deep dive designed (#229)

OP OpenVibers committed 1 days ago · 5f32a6bbf9 · 9 files, +225 −19

The owner, 11-12, phone screenshots: search "broken" (both engines "unavailable"), the
compute list a raw table off the screen, the home's reasons "bad layout … add icons …
improve", the deep dive's boxes unstyled with odd dots, a broken icon in the menu.

- The service token asked Network for ai.run.create and ai.run.read; Exchange holds
  only ai.run.create, so Network refused the token outright and chat, Try it, presets,
  key privacy and the deep dive's service calls failed. It asks for ai.run.create (AI's
  deep-dive reads take it). OpenVibe.AI #498 fixes the OpenVibe engine itself.
- Search says why an engine did not answer (sign-in, too many, not this kind, no
  answer) and logs the status, never the catch-all.
- Every page's main carries Shared's classless base (.ov-base + kit-layout.css): text
  fields, textareas, selects, details, links, code and tables follow the theme.
- /compute: cards by kind (GPUs, computers), cheapest first and marked, the memory and
  region in words, offers and interruptible tagged, each provider's price page a tap
  away, "checked yesterday", a notice when prices pass their review date, and links to
  OpenVibe.Compare and the JSON (GET /api/compute/prices).
- The home: the three reasons are cards with their marks and links; pricing lines carry
  their marks; questions are an accordion with marks and a turning chevron.
- The deep dive: themed fields with room between them, the depth's dot drawn in the
  accent, the price note under its button with a lock.
- The menu's Deep dive icon is the compass (fa-binoculars has no glyph); a test checks
  every menu icon resolves.

Changes

ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex 05e8041..130fc1c 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -732,3 +732,21 @@ label.dd-depth:has(input:checked) { border-color: var(--accent); background: col
.dd-chips { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; padding: 0; }
.dd-chips li { padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-secondary); font-size: .8rem; color: var(--text-secondary); }
@media (max-width: 640px) { .dd-depth-row { grid-template-columns: minmax(0, 1fr); } .dd-depth-price { font-size: 1.35rem; } }
++
++/* ── Deep dive, 11-12 (the owner: unstyled boxes, "update the little dots", spacing, the label by the price) ── */
++.dd-form { display: grid; gap: 18px; }
++.ov-base .dd-field { margin: 0; }
++.dd-field > span { display: block; margin-bottom: 8px; font-weight: 700; }
++.dd-field textarea { line-height: 1.45; }
++/* The depth's dot: drawn, the site's accent when chosen. */
++label.dd-depth input { appearance: none; -webkit-appearance: none; display: grid; place-items: center; width: 22px; height: 22px; margin: 0; border: 2px solid color-mix(in srgb, var(--text-primary, #fff) 30%, transparent); border-radius: 50%; background: transparent; cursor: pointer; transition: border-color var(--ov-dur-2, .2s) ease; }
++label.dd-depth input::before { content: ""; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); transform: scale(0); transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }
++label.dd-depth input:checked { border-color: var(--accent); }
++label.dd-depth input:checked::before { transform: scale(1); }
++label.dd-depth input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++.dd-go { display: grid; justify-items: start; gap: 8px; }
++.dd-go-note { display: flex; align-items: center; gap: 7px; margin: 0; color: var(--text-muted); font-size: .88rem; }
++.dd-go-note svg { flex: none; width: 15px; height: 15px; }
++.dd-go-note { align-items: flex-start; }
++.dd-go-note svg { margin-top: 3px; }
++.dd > .dd-form + *, .dd > .dd-error + * { margin-top: 26px; }
ddiff --git a/public/css/platform.css b/public/css/platform.css
iindex a6fe19a..be7c276 100644
---- a/public/css/platform.css
++++ b/public/css/platform.css
@@@ -409,3 +409,74 @@
.xs-q .ovk-btn { width: 48px; padding: 0; justify-content: center; }
}
@media (prefers-reduced-motion: reduce) { .xs-more > summary::after { transition: none; } }
++
++/* ── 11-12 design pass (the owner: "bad layout … add icons … improve"). ── */
++/* Why Exchange: three cards, each with its mark, its words and its way on. */
++.xp-features { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin: 24px 0; }
++.xp-feature { display: grid; align-content: start; gap: 8px; padding: 18px; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); }
++.xp-feature h3 { margin: 4px 0 0; font-size: 1.05rem; letter-spacing: -.01em; }
++.xp-feature p { margin: 0; color: var(--ovk-muted); line-height: 1.5; }
++.xp-feature-ic, .xpr-ic, .xp-q-ic, .xc-ic { display: inline-grid; place-items: center; flex: none; width: 38px; height: 38px; border-radius: 12px; color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovk-accent) 22%, transparent); }
++.xp-feature-ic svg, .xpr-ic svg, .xp-q-ic svg, .xc-ic svg { width: 19px; height: 19px; }
++.xp-feature-a { display: inline-flex; align-items: center; gap: 6px; justify-self: start; margin-top: 4px; color: var(--ovk-accent); font-weight: 700; text-decoration: none; }
++.xp-feature-a svg { width: 15px; height: 15px; transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }
++.xp-feature-a:hover svg { transform: translateX(3px); }
++/* Pricing: each line has its mark. */
++.xpr-list li { grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 14px; }
++.xpr-t { display: grid; gap: 2px; min-width: 0; }
++.xpr-t > span { color: var(--ovk-muted); font-size: .92rem; }
++.xpr-list .xpr-ic { width: 34px; height: 34px; border-radius: 10px; }
++@media (min-width: 760px) { .xpr-list li { grid-template-columns: auto minmax(0, 1fr); } .xpr-t { grid-template-columns: minmax(200px, 1fr) 2fr; align-items: baseline; gap: 16px; } }
++/* Questions: an accordion, each with its mark and a chevron that turns. */
++.xp-faq { display: grid; gap: 8px; padding: 4px 14px 14px; }
++.ov-base .xp-q { margin: 0; padding: 0; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel-2, var(--ovk-panel)); overflow: hidden; }
++.xp-q > summary { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 8px 12px; list-style: none; cursor: pointer; font-weight: 700; }
++.xp-q > summary::-webkit-details-marker { display: none; }
++.xp-q .xp-q-ic { width: 32px; height: 32px; border-radius: 10px; }
++.xp-q-t { flex: 1; min-width: 0; }
++.xp-q-chev { display: grid; color: var(--ovk-muted); transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }
++.xp-q-chev svg { width: 18px; height: 18px; }
++.xp-q[open] .xp-q-chev { transform: rotate(180deg); }
++.xp-q-a { padding: 0 14px 14px 56px; color: var(--ovk-muted); }
++.xp-q-a p { margin: 0 0 6px; }
++.xp-q-a a { font-weight: 700; }
++
++/* /compute: cards by kind, the cheapest marked. */
++.xc-jump { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 18px; }
++.xc-jump a { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; padding: 0 14px; border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel); color: var(--ovk-text); font-weight: 650; font-size: .92rem; text-decoration: none; }
++.xc-jump a svg { width: 16px; height: 16px; color: var(--ovk-accent); }
++.xc-jump a:hover { border-color: color-mix(in srgb, var(--ovk-accent) 50%, var(--ovk-line)); }
++.xc-stale { padding: 10px 14px; border-radius: 12px; background: color-mix(in srgb, var(--ovk-warn, #f59e0b) 14%, transparent); color: var(--ovk-text); }
++.xc-group { margin: 0 0 26px; scroll-margin-top: calc(var(--ovnav-h, 56px) + 12px); }
++.ov-base .xc-group h2 { display: flex; align-items: center; gap: 10px; margin: 0 0 4px; font-size: 1.2rem; }
++.xc-group .xc-ic { width: 32px; height: 32px; border-radius: 10px; }
++.xc-n { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; background: color-mix(in srgb, var(--ovk-text) 8%, transparent); color: var(--ovk-muted); font-size: .76rem; font-weight: 700; }
++.xc-note { margin: 0 0 10px; color: var(--ovk-muted); font-size: .92rem; }
++.xc-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
++.ov-base .xc-list li + li { margin: 0; }
++.xc-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 12px; padding: 12px 10px 12px 14px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }
++.xc-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 11px; background: color-mix(in srgb, var(--ovk-accent) 16%, var(--ovk-panel-2, transparent)); color: var(--ovk-text); font-weight: 800; }
++.xc-t { display: grid; gap: 2px; min-width: 0; }
++.xc-t b { font-size: 1rem; }
++.xc-t small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font-size: .85rem; }
++.xc-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
++.xc-tag { padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--ovk-text) 8%, transparent); color: var(--ovk-muted); font-size: .72rem; font-weight: 700; }
++.xc-tag.is-best { background: color-mix(in srgb, #22c55e 18%, transparent); color: #86efac; }
++.xc-tag.is-promo { background: color-mix(in srgb, var(--ovk-accent) 16%, transparent); color: var(--ovk-accent); }
++.xc-p { display: grid; justify-items: end; gap: 1px; text-align: right; font-variant-numeric: tabular-nums; }
++.xc-p b { font-size: 1.15rem; letter-spacing: -.01em; white-space: nowrap; }
++.xc-p b small { margin-left: 2px; color: var(--ovk-muted); font-size: .72rem; font-weight: 650; }
++.xc-p > small { color: var(--ovk-muted); font-size: .72rem; white-space: nowrap; }
++.xc-src { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; color: var(--ovk-muted); }
++.xc-src:hover { color: var(--ovk-text); background: color-mix(in srgb, var(--ovk-text) 6%, transparent); }
++.xc-src svg { width: 17px; height: 17px; }
++.xc-foot { margin-top: 18px; color: var(--ovk-muted); font-size: .9rem; }
++@media (max-width: 420px) { .xc-row { grid-template-columns: auto minmax(0, 1fr) auto; } .xc-src { display: none; } .xc-p > small { display: none; } }
++/* The older `.xpr-list span` (muted) must not reach the title or the mark. */
++.xpr-list .xpr-t { color: var(--ovk-text); }
++.xpr-list .xpr-t > span { color: var(--ovk-muted); }
++.xpr-list .xpr-ic { color: var(--ovk-accent); }
++.ovk-section > .xpr-note, .xpr > .xpr-note { margin: 12px 16px 14px; }
++/* The chevron points down, and up when open. */
++.xp-q-chev { transform: rotate(90deg); }
++.xp-q[open] .xp-q-chev { transform: rotate(-90deg); }
ddiff --git a/server/config.js b/server/config.js
iindex dc34a80..003621b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -67,7 +67,10 @@ function load(env = process.env) {
ai: {
url: trim(env.OV_AI_URL || 'https://ai.openvibe.services'),
audience: env.OV_AI_AUDIENCE || 'openvibe.ai',
-- scope: env.OV_AI_SCOPE || 'ai.run.create ai.run.read',
++ // Only what Network grants Exchange. Asking for ai.run.read too (#222) made Network refuse the whole token
++ // (invalid_scope), so chat, Try it, presets and the deep dive's service calls failed; AI's deep-dive reads
++ // accept ai.run.create.
++ scope: env.OV_AI_SCOPE || 'ai.run.create',
consoleScope: env.OV_AI_CONSOLE_SCOPE || 'ai.usage.read ai.workflow.manage ai.provider.manage',
timeoutMs: Math.max(2000, int(env.EXCHANGE_AI_TIMEOUT_MS, 30_000)),
waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 2ce55bc..3f6433f 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -76,8 +76,7 @@ function createPageRoutes(ctx) {
r.get('/compute', (req, res) => {
const { prices, checked_at } = require('../compute-prices');
-- const rows = prices.map(p => `<tr><td>${esc(p.provider)}</td><td>${esc(p.id)}</td><td>${esc(p.gpu || `${p.cpu} vCPU / ${p.ram_mb} MiB`)}</td><td>$${p.hourly.toFixed(6)}/hour</td><td>$${p.per_second.toFixed(9)}/second</td><td><a href="${esc(p.source)}">Provider price</a></td></tr>`).join('');
-- return page(req,res,{title:'Compute prices',body:raw(`<main><h1>Compute prices</h1><p>Checked ${esc(checked_at)}. Base compute only; storage, network and plan fees may apply. Promotional balances are account-specific.</p><p><a href="/">Model prices</a> · <a href="https://openvibe.network/admin#ai">Owner provider settings</a></p><div style="overflow-x:auto"><table><thead><tr><th>Provider</th><th>Price ID</th><th>Resources</th><th>Hourly</th><th>Per second</th><th>Source</th></tr></thead><tbody>${rows}</tbody></table></div></main>`)});
++ return page(req, res, { title: 'Compute prices', description: 'What an hour of a computer or a GPU costs at Baseten, Daytona, E2B, Fly.io and RunPod: base compute, checked weekly.', body: raw(platform.computePage({ prices, checked_at })) });
});
// ── Exchange v1, retired ──────────────────────────────────
ddiff --git a/server/http/search.js b/server/http/search.js
iindex 72837ae..6faea8a 100644
---- a/server/http/search.js
++++ b/server/http/search.js
@@@ -39,11 +39,14 @@ function createSearchRoutes({ config, ai }) {
},
)),
};
-- } catch {
-- return {
-- engine,
-- error: 'This engine is unavailable or does not support this search kind.',
-- };
++ } catch (err) {
++ const status = err && err.status;
++ console.warn('[exchange] search', engine, kind, status || '', (err && err.code) || '', (err && err.message) || '');
++ const why = status === 401 || status === 403 ? 'Your sign-in could not search. Sign out and in again.'
++ : status === 429 ? 'Too many searches this minute. Try again shortly.'
++ : status === 400 || status === 422 ? 'This engine does not take this kind of search.'
++ : 'This engine did not answer. Try the other one, or again in a minute.';
++ return { engine, error: why };
}
}),
);
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 5deb94d..f4fb395 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -23,7 +23,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV = [
{ label: 'Models', href: '/models', icon: 'fa-microchip' },
{ label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },
-- { label: 'Deep dive', href: '/deep-dive', icon: 'fa-binoculars' },
++ { label: 'Deep dive', href: '/deep-dive', icon: 'fa-compass' }, // fa-binoculars has no glyph: it drew nothing
{ label: 'Pricing', href: '/pricing', icon: 'fa-tag' },
{ label: 'Docs', href: '/docs', icon: 'fa-code' },
{ label: 'Status', href: '/status', icon: 'fa-gauge' },
@@@ -103,6 +103,7 @@ function renderPage(o) {
${appIcon.headTags({ site: site.icon || 'exchange' })}
<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<link rel="stylesheet" href="${ovServe.url('kit-data.css')}">
++<link rel="stylesheet" href="${ovServe.url('kit-layout.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${asset('css/exchange.css')}">
@@@ -117,7 +118,7 @@ ${o.head || ''}
body: `<a class="skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
--<main id="main" class="ex ov-room ${o.class || ''}" style="${rooms.style('exchange')}">
++<main id="main" class="ex ov-room ov-base ${o.class || ''}" style="${rooms.style('exchange')}">
${render(o.body)}
</main>
<script>
ddiff --git a/server/views/deep-dive.js b/server/views/deep-dive.js
iindex ed5459f..b7b85a7 100644
---- a/server/views/deep-dive.js
++++ b/server/views/deep-dive.js
@@@ -82,7 +82,7 @@ function homePage({ viewer, depths = [], capture = null, error = null }) {
<label class="dd-field"><span>What are you designing?</span><textarea name="question" rows="3" maxlength="500" required placeholder="a phone-first robot control panel"></textarea></label>
${depthCards(cards)}
<label class="dd-field"><span>Sites you already know (optional, one per line)</span><textarea name="sites" rows="2" maxlength="2048" placeholder="https://example.com/panel"></textarea></label>
--<p class="dd-go">${kit.button({ label: 'Start the deep dive', type: 'submit' })}<span class="ovk-note">The price is held before it starts; you can cancel at any step.</span></p>
++<div class="dd-go">${kit.button({ label: 'Start the deep dive', type: 'submit' })}<p class="dd-go-note">${require('openvibe-shared/glyphs').svg('lock')}The price is held before it starts; you can cancel at any step.</p></div>
</form>${error ? `<p class="dd-error" role="alert">${esc(error)}</p>` : ''}`
: signIn();
return `<div class="ex-wrap dd">${kit.head({ title: 'Deep dive', sub: 'Expert design research: we search everywhere, capture the best sites at phone and computer sizes, and tell you what works and why.' })}${form}${signedIn ? '' : `<section class="ovk-section"><h2>The depths and their prices</h2>${depthList(cards)}</section>`}`
ddiff --git a/server/views/platform.js b/server/views/platform.js
iindex 30d7bec..a2ddac7 100644
---- a/server/views/platform.js
++++ b/server/views/platform.js
@@@ -82,18 +82,75 @@ function table(models, searchMeta = null) {
}).join('')}</ul>`;
}
// What each way of calling costs, in the reader's words (home's Pricing section and /pricing). Opus, 11-09.
++const glyph = (name) => require('openvibe-shared/glyphs').svg(name);
const PRICING = [
-- ['Your own provider key', "The provider's price. OpenVibe adds 0%, with no cap and no end date."],
-- ['Managed models', 'The input and output price per million tokens shown on each model, before you call.'],
-- ["Open models on OpenVibe's GPUs", 'A fixed price per million tokens, the same on every call. Coming next.'],
-- ['Your own endpoint', 'An hourly price, quoted before it starts; it stops when idle.'],
-- ['Search', "Each engine's price per 1,000 searches; the free engines first."],
++ ['key', 'Your own provider key', "The provider's price. OpenVibe adds 0%, with no cap and no end date."],
++ ['coin', 'Managed models', 'The input and output price per million tokens shown on each model, before you call.'],
++ ['chip', "Open models on OpenVibe's GPUs", 'A fixed price per million tokens, the same on every call. Coming next.'],
++ ['server', 'Your own endpoint', 'An hourly price, quoted before it starts; it stops when idle.'],
++ ['search', 'Search', "Each engine's price per 1,000 searches; the free engines first."],
];
--const pricingRows = () => `<ul class="xpr-list">${PRICING.map(([name, what]) => `<li><strong>${esc(name)}</strong><span>${esc(what)}</span></li>`).join('')}</ul>`
++const pricingRows = () => `<ul class="xpr-list">${PRICING.map(([icon, name, what]) => `<li><span class="xpr-ic" aria-hidden="true">${glyph(icon)}</span><span class="xpr-t"><strong>${esc(name)}</strong><span>${esc(what)}</span></span></li>`).join('')}</ul>`
+ '<p class="xpr-note">Paid models open soon. Every price shows before you call, and nothing is charged until then.</p>';
function pricing() {
return section('Pricing', pricingRows());
}
++// Why Exchange: three cards, each with its mark, its words and the page that says more (the owner, 11-12: the
++// reasons read as bare text flush against their boxes).
++const REASONS = [
++ ['key', '0% fee on your own key', "Bring your provider key and pay the provider's price, nothing more. No cap, no end date.", '/dashboard/keys', 'Add a key'],
++ ['route', 'The cheapest route', "Every call goes to the cheapest provider that is up. Open models on OpenVibe's own GPUs, at a fixed price per million tokens, are coming next.", '/compute', 'Compute prices'],
++ ['shield', 'Uncensored, 18+', 'Uncensored models for adults who opt in. The content floor applies on every request.', '/uncensored', 'What the floor covers'],
++];
++function reasons() {
++ return `<section class="xp-features" aria-label="Why OpenVibe.Exchange">${REASONS.map(([icon, title, text, href, label]) =>
++ `<article class="xp-feature"><span class="xp-feature-ic" aria-hidden="true">${glyph(icon)}</span><h3>${esc(title)}</h3><p>${esc(text)}</p><a class="xp-feature-a" href="${href}">${esc(label)}<span aria-hidden="true">${glyph('arrow-right')}</span></a></article>`).join('')}</section>`;
++}
++const QUESTIONS = [
++ ['code', 'Can I use my existing client?', 'Yes. Point any OpenAI-compatible client at the Exchange endpoint with your Exchange key; Anthropic-style clients work through /v1/messages.', '/docs', 'Read the docs'],
++ ['chat', 'Where can I talk to a model?', 'In Vibe on OpenVibe.Chat: talk to any model here, or two side by side.', 'https://openvibe.chat/vibe', 'Open Vibe'],
++ ['chart', 'Where do the numbers come from?', 'Prices come from the live catalogue. Speed and reliability come from measured requests. A number nobody has measured stays unknown, never guessed.', '/status', 'See the status'],
++];
++function questions() {
++ return section('Questions', `<div class="xp-faq">${QUESTIONS.map(([icon, q, a, href, label]) =>
++ `<details class="xp-q"><summary><span class="xp-q-ic" aria-hidden="true">${glyph(icon)}</span><span class="xp-q-t">${esc(q)}</span><span class="xp-q-chev" aria-hidden="true">${glyph('chevron')}</span></summary><div class="xp-q-a"><p>${esc(a)}</p><a href="${href}">${esc(label)}</a></div></details>`).join('')}</div>`);
++}
++
++// /compute: what an hour of a computer or a GPU costs at each provider (the owner, 11-12: the raw table ran off a
++// phone; "this compute list should be on openvibe.compare … or at least interconnected"). Cards by kind, the cheapest
++// marked, the provider's own price page one tap away; the same list is GET /api/compute/prices, and OpenVibe.Compare
++// compares it.
++const PROVIDER = { baseten: 'Baseten', daytona: 'Daytona', e2b: 'E2B', fly: 'Fly.io', runpod: 'RunPod', modal: 'Modal', vast: 'Vast.ai', lambda: 'Lambda' };
++const REGION = { 'us-pdx-1': 'US West', 'eu-lon-1': 'London', us: 'US', eu: 'EU', iad: 'Virginia', ewr: 'New Jersey', 'provider-default': "The provider's region" };
++const gb = (mb) => `${Math.round((Number(mb) || 0) / 1024)} GB`;
++const perHour = (n) => `$${Number(n).toFixed(n < 0.1 ? 3 : 2)}`;
++function ago(iso, now) {
++ const days = Math.floor((now - Date.parse(iso)) / 86400000);
++ return !Number.isFinite(days) ? 'recently' : days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days} days ago`;
++}
++function computePage({ prices = [], checked_at, now = Date.now() }) {
++ const kinds = [['gpu', 'GPUs', 'chip', 'For models and anything that needs a graphics card.'], ['cpu', 'Computers', 'server', 'Sandboxes and desks: two cores and a few GB of memory.']];
++ const stale = prices.some((p) => p.expires_at && now > p.expires_at);
++ const row = (p, cheapest) => {
++ const name = PROVIDER[p.provider] || p.provider;
++ const what = p.gpu ? `${p.gpu} · ${gb(p.gpu_mb)}` : `${p.cpu} vCPU · ${gb(p.ram_mb)} memory`;
++ const where = REGION[p.region] || p.region || '';
++ const tags = [cheapest ? '<span class="xc-tag is-best">Cheapest</span>' : '', p.interruptible ? '<span class="xc-tag">Can be interrupted</span>' : '', p.promotion ? `<span class="xc-tag is-promo" title="${esc(p.promotion)}">Offer</span>` : ''].join('');
++ return `<li class="xc-row"><span class="xc-mark" aria-hidden="true">${esc(name.charAt(0))}</span><span class="xc-t"><b>${esc(name)}</b><small>${esc([what, where].filter(Boolean).join(' · '))}</small>${tags ? `<span class="xc-tags">${tags}</span>` : ''}</span>`
++ + `<span class="xc-p"><b>${perHour(p.hourly)}<small>/hour</small></b><small>$${Number(p.per_second).toFixed(6)}/second</small></span>`
++ + `<a class="xc-src" href="${esc(p.source)}" rel="noopener nofollow" target="_blank" aria-label="${esc(name)}'s price page">${glyph('open')}</a></li>`;
++ };
++ const groups = kinds.map(([kind, title, icon, note]) => {
++ const list = prices.filter((p) => (p.kind || (p.gpu ? 'gpu' : 'cpu')) === kind).sort((a, b) => a.hourly - b.hourly);
++ if (!list.length) return '';
++ return `<section class="xc-group" id="${kind}"><h2><span class="xc-ic" aria-hidden="true">${glyph(icon)}</span>${esc(title)} <span class="xc-n">${list.length}</span></h2><p class="xc-note">${esc(note)}</p><ul class="xc-list">${list.map((p, i) => row(p, i === 0)).join('')}</ul></section>`;
++ }).join('');
++ return `<div class="ex-wrap xc">${kit.head({ title: 'Compute prices', sub: `What an hour of a computer or a GPU costs at each provider, checked ${ago(checked_at, now)}.` })}`
++ + `<nav class="xc-jump" aria-label="Kinds"><a href="#gpu">${glyph('chip')}GPUs</a><a href="#cpu">${glyph('server')}Computers</a><a href="https://openvibe.compare/compute">${glyph('scale')}Compare them on OpenVibe.Compare</a></nav>`
++ + (stale ? '<p class="xc-stale" role="status">Some of these prices are past their review date: check the provider before you rely on one.</p>' : '')
++ + groups
++ + `<p class="xc-foot">Base compute only: storage, network and plan fees may apply, and offers are per account. The same list is <a href="/api/compute/prices">GET /api/compute/prices</a> (JSON, open to anyone). <a href="/">Model prices</a></p></div>`;
++}
/** /pricing: the page, with its head (it had no h1). */
function pricingPage() {
return `<div class="ex-wrap xp">${kit.head({ title: 'Pricing', sub: 'What a call costs, before you make it.' })}<section class="xpr">${pricingRows()}</section></div>`;
@@@ -117,7 +174,7 @@ function home({ catalog, usage, viewer }) {
'median time to first token',
],
],
-- )}${section('Popular models', table([...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0)).slice(0, 10)))}<div class="xp-reasons">${section('0% fee on your own key', '<p>Bring your provider key and pay the provider\'s price, nothing more. No cap, no end date.</p>')}${section('The cheapest route', '<p>Every call goes to the cheapest provider that is up. Open models on OpenVibe\'s own GPUs, at a fixed price per million tokens, are coming next.</p>')}${section('Uncensored, 18+', '<p>Uncensored models for adults who opt in. The content floor applies on every request.</p><a href="/uncensored">What the floor covers</a>')}</div>${pricing()}${section('Questions', '<details><summary>Can I use my existing client?</summary><p>Yes. Use the OpenAI-compatible endpoint with your Exchange key.</p></details><details><summary>Where can I talk to a model?</summary><p><a href="https://openvibe.chat/vibe">Open Chat</a> to talk or compare models.</p></details><details><summary>Where do the numbers come from?</summary><p>Prices come from the live catalogue. Speed and reliability come from measured requests. Missing measurements stay unknown.</p></details>')}</div>`;
++ )}${section('Popular models', table([...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0)).slice(0, 10)))}${reasons()}${pricing()}${questions()}</div>`;
}
function modelsPage({ catalog, query = {}, searchResults = null }) {
const all = listed(catalog);
@@@ -186,4 +243,4 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {
const sub = rows.length ? `${rows.length} to call now · live prices, measured speed` : 'None to call now match · the registry is below';
return `<div class="ex-wrap xp xm-page">${kit.head({ title: 'Models', sub })}${unavailable(catalog)}<div class="xm-find">${filters}<nav class="ex-chips" aria-label="Model filters">${ordered.join('')}</nav></div>${table(rows, searchMeta)}<p class="ex-more"><a href="https://openvibe.compare/">Compare models side by side on OpenVibe.Compare →</a></p></div>`;
}
--module.exports = { home, modelsPage, pricing, pricingPage, table };
++module.exports = { computePage, home, modelsPage, pricing, pricingPage, table };
ddiff --git a/test/exchange-polish-1112.test.js b/test/exchange-polish-1112.test.js
nnew file mode 100644
iindex 0000000..b122c9b
---- /dev/null
++++ b/test/exchange-polish-1112.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++/**
++ * The 11-12 design pass and fixes (the owner, phone screenshots of openvibe.exchange: search "broken", the compute list
++ * a raw table off the screen, the home's reasons "bad layout … add icons … improve", the deep dive's boxes unstyled, a
++ * broken icon in the menu).
++ * node test/exchange-polish-1112.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const glyphs = require('openvibe-shared/glyphs');
++
++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
++
++// The service token asks only for what Network grants Exchange (ai.run.read made Network refuse the whole token).
++const config = require('../server/config').load({});
++assert.strictEqual(config.ai.scope, 'ai.run.create');
++
++// Every menu icon is a glyph that exists (fa-binoculars drew nothing for Deep dive).
++const { NAV } = require('../server/render/layout');
++for (const item of NAV) if (item.icon) assert.ok(glyphs.fromFa(item.icon), `${item.label}: ${item.icon} has a glyph`);
++// Plain elements get the themed base.
++const layout = read('server/render/layout.js');
++assert.ok(/class="ex ov-room ov-base /.test(layout) && /kit-layout\.css/.test(layout), 'main carries .ov-base and its stylesheet');
++
++// Search says why an engine did not answer.
++const search = read('server/http/search.js');
++assert.ok(!/This engine is unavailable or does not support this search kind/.test(search), 'no catch-all message');
++assert.ok(/status === 401 \|\| status === 403/.test(search) && /status === 429/.test(search), 'a reason by status');
++
++// /compute: cards by kind, cheapest first and marked, the provider's page linked, no table; the JSON stays.
++const platform = require('../server/views/platform');
++const { prices, checked_at } = require('../server/compute-prices');
++const html = platform.computePage({ prices, checked_at, now: Date.parse(checked_at) + 86400000 });
++assert.ok(!/<table/.test(html), 'no table');
++assert.match(html, /checked yesterday/);
++const gpu = html.slice(html.indexOf('id="gpu"'), html.indexOf('id="cpu"'));
++const hourly = [...gpu.matchAll(/\$([0-9.]+)<small>\/hour/g)].map((m) => Number(m[1]));
++assert.deepStrictEqual(hourly, [...hourly].sort((a, b) => a - b), 'cheapest first');
++assert.ok(gpu.indexOf('is-best') > 0 && gpu.indexOf('is-best') < gpu.indexOf('xc-row', gpu.indexOf('xc-row') + 1), 'the first is marked Cheapest');
++assert.match(html, /openvibe\.compare\/compute/, 'linked to OpenVibe.Compare');
++assert.match(html, /\/api\/compute\/prices/, 'the same list as JSON');
++assert.match(read('server/app.js'), /app\.get\('\/api\/compute\/prices'/);
++const stale = platform.computePage({ prices, checked_at, now: Date.parse('2027-01-01') });
++assert.match(stale, /past their review date/, 'old prices say so');
++
++// The home: three reason cards with marks and links, pricing lines with marks, questions as an accordion.
++const home = read('server/views/platform.js');
++assert.ok(/class="xp-features"/.test(home) && /xp-feature-ic/.test(home) && /class="xp-faq"/.test(home) && /xpr-ic/.test(home));
++
++// The deep dive's note sits under its button.
++assert.match(read('server/views/deep-dive.js'), /<p class="dd-go-note">/);
++assert.match(read('public/css/exchange.css'), /label\.dd-depth input \{ appearance: none;/, 'the dot is drawn');
++console.log('exchange-polish-1112: scope, menu glyphs, the base, search reasons, /compute cards, the home, the deep dive');