openvibers/openvibe.trade Commits

Home on phones: one kind of market information at a time (#150)

OP OpenVibers committed 14 days ago · 2f97dd5ab3 · 7 files, +230 −26

The front page's six sections (the AI briefs, the euro reference rates, the
reports, the filings by form, the newest filings and the companies with
something new) were about 3,200 px of a 7.1-screen phone home. On a phone
(640 px and under) a row of chips picks one of them: radio inputs and :has(),
no script, and every section stays in the page for crawlers. A wider screen,
or a browser without :has(), shows every section and no chips.

The questions and the shipped list are parts a reader rarely needs: they are
folds now, closed on a phone and open where there is room. A link that points
at a section (the hero's "AI briefs") still opens that section on a phone.

The phone home goes from 5,435 px (6.4 screens) to 2,437 px (2.9 screens) with
the same data; the footer counted. The desktop page looks as it did.

Changes

ddiff --git a/public/css/trade.css b/public/css/trade.css
iindex a4d9ffc..33e323f 100644
---- a/public/css/trade.css
++++ b/public/css/trade.css
@@@ -14,7 +14,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visib
which has its own padding, that put the hero and "How it works" 24px right of everything after them. */
.page > .sc-hero, .page > .sc-sec, .page > .sc-cta { padding-left: 0; padding-right: 0; }
/* The front page's own sections read as the showcase's do: same heading face and size as "What is here". */
--.sc-sec ~ h2, .sc-sec ~ section > h2 { font-family: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif); font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.02em; line-height: 1.2; margin-top: 44px; }
++.sc-sec ~ h2, .sc-sec ~ section > h2 { font-family: var(--ov-font, 'Inter', system-ui, sans-serif); font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.02em; line-height: 1.2; margin-top: 44px; }
h1 { font-size: 1.9rem; line-height: 1.2; margin: 1rem 0 .5rem; }
h1 .name { font-weight: 400; color: var(--text-secondary, #96a7c2); font-size: 1.3rem; }
h2 { font-size: 1.3rem; margin: 1.8rem 0 .5rem; }
@@@ -121,7 +121,7 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }
.tr-sec .tr-rates > li { flex: 0 0 46%; scroll-snap-align: start; }
.tr-stats > li { flex: 0 0 auto; width: 132px; scroll-snap-align: start; }
.tr-sec .tr-chips > li:nth-child(n+9), .tr-sec > .docs > li:nth-child(n+6) { display: none; }
-- .tr-sec[aria-labelledby="movers"] tbody tr:nth-child(n+9) { display: none; }
++ .tr-sec[aria-labelledby="movers-h"] tbody tr:nth-child(n+9) { display: none; }
.tr-fold > summary span { display: none; }
}
@media (prefers-reduced-motion: reduce) { .tr-stats a { transition: none; } }
@@@ -143,3 +143,44 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }
.tr-card-top a::after { content: ''; position: absolute; left: 0; right: 0; top: -9px; bottom: -9px; }
table.data a { display: inline-block; min-height: 44px; }
}
++
++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page's six kinds of market information are
++ one at a time, picked with a row of chips. Radio inputs and :has(), no script: the chips and the one-pane rule live
++ under 640 px and only where :has() is understood, so a wider screen, or an older browser, shows every pane and no
++ chips. Every pane stays in the page for crawlers. */
++.pick-head { display: none; }
++.pick-row { display: none; }
++@supports selector(:has(*)) { @media (max-width: 640px) {
++ .pick-bar { margin-top: 30px; }
++ .pick-head { display: block; }
++ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px, 2.4vw, 28px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); letter-spacing: -.02em; }
++ .pick-row { display: flex; gap: 8px; overflow-x: auto; margin: 10px -16px 0; padding: 2px 16px 4px; scrollbar-width: none; overscroll-behavior-x: contain; }
++ .pick-row::-webkit-scrollbar { display: none; }
++ .pick-row input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
++ .pick-row label { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; cursor: pointer; }
++ .pick-row input:checked + label { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
++ .pick-row input:focus-visible + label { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++ .pick .pick-pane { display: none; }
++ /* A link that points at a pane (the hero's "AI briefs", the numbers beside it) opens that pane on a phone too. */
++ .pick .pick-pane:target { display: block !important; }
++ .pick:has(.pick-pane:target) .pick-pane:not(:target) { display: none !important; }
++ /* The chip names the pane, so the pane's own heading gives way to it; the link to more stays. */
++ .pick .pick-pane .tr-head h2 { display: none; }
++ .pick .pick-pane .tr-head { justify-content: flex-end; }
++} }
++
++/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it
++ replaces: a heading and its content, with no card around it. */
++.tr-fold-block { margin-top: 32px; }
++.tr-fold-block > summary h2 { margin: 0; font: 700 clamp(19px, 2.2vw, 26px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }
++.tr-fold-block .tr-faq { margin-top: 12px; }
++/* The shipped list carries its own title; the fold's summary already names it. */
++.tr-fold-block .ov-shipped-home h3 { display: none; }
++@media (min-width: 641px) {
++ .tr-fold-block { border: 0; background: none; border-radius: 0; margin-top: 56px; padding: 0; }
++ .tr-fold-block > summary { display: block; min-height: 0; padding: 0; }
++ .tr-fold-block > summary h2 { margin: 0 0 6px; font: 700 clamp(22px, 2.6vw, 30px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }
++ .tr-fold-block > summary span { display: none; }
++ .tr-fold-block .tr-faq { margin-top: 12px; }
++}
++@media (max-width: 640px) { .tr-fold-block { margin-top: 24px; } .tr-fold-block > summary h2 { font-size: 1.05rem; } }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 680a61d..a359655 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -70,23 +70,26 @@ function createPages(ctx) {
]);
const rates = await Promise.all(rateRows.map(async (r) => ({ ...r, dto: await observations.dto(r.latest, { now }) })));
const faq = home.faqItems();
++ const data = {
++ stats: {
++ companies: (kinds.equity || 0) + (kinds.fund || 0), currencies: kinds.currency || 0, instruments: Object.values(kinds).reduce((a, b) => a + b, 0),
++ filings: docStats.total, filingsDay: docStats.day, figureCompanies: obsStats.companies, briefs: briefStats.total,
++ },
++ briefs, rates, reports: await withInstruments(reportDocs), forms, recent: await withInstruments(recentDocs),
++ movers: { instruments: movers.instruments, ...await headlines(movers.instruments, now) }, sync: await sync.state(), faq,
++ };
common.page(req, res, {
title: 'SEC filings, company figures, AI briefs', canonical: urls.abs('/'), decision: listing('/'),
description: 'SEC filings as they arrive, the figures inside them, euro reference rates and AI briefs that cite every number, for about 8,000 US companies. Information only.',
// The home page carries the site's ai-summary meta and WebPage JSON-LD (v1.3.0 forwards it).
summary: SITE_SUMMARY, styles: [showcase.STYLESHEET], bodyClass: 'home', feeds: FEEDS,
++ // The rule that shows the picked pane of the home's pick block: one per pane, written for this page.
++ inlineStyles: [home.paneRules(data, urls)],
jsonLd: [
seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en', potentialAction: { '@type': 'SearchAction', target: `${urls.abs('/resolve')}?q={q}`, 'query-input': 'required name=q' } }),
home.faqJsonLd(faq),
],
-- body: home.homePage({
-- stats: {
-- companies: (kinds.equity || 0) + (kinds.fund || 0), currencies: kinds.currency || 0, instruments: Object.values(kinds).reduce((a, b) => a + b, 0),
-- filings: docStats.total, filingsDay: docStats.day, figureCompanies: obsStats.companies, briefs: briefStats.total,
-- },
-- briefs, rates, reports: await withInstruments(reportDocs), forms, recent: await withInstruments(recentDocs),
-- movers: { instruments: movers.instruments, ...await headlines(movers.instruments, now) }, sync: await sync.state(), faq,
-- }, urls),
++ body: home.homePage(data, urls),
});
});
ddiff --git a/server/render/home.js b/server/render/home.js
iindex dba590f..e3b3c62 100644
---- a/server/render/home.js
++++ b/server/render/home.js
@@@ -12,13 +12,14 @@
const { html, raw } = require('openvibe-publishing/ssr');
const showcase = require('openvibe-shared/showcase');
const { when, staleBadge, grouped, clip, formLabel, filingLine, instrumentsTable } = require('./views');
++const { pickBlock, pickRules, foldBlock } = require('./short');
const count = (n) => Number(n).toLocaleString('en-US');
function section({ id, title, lede, more, body, note }) {
if (!body) return '';
-- return html`<section class="tr-sec" aria-labelledby="${id}">
--<div class="tr-head"><h2 id="${id}">${title}</h2>${more ? html`<a class="tr-more" href="${more.href}">${more.label}<span aria-hidden="true"> →</span></a>` : ''}</div>
++ return html`<section class="tr-sec" aria-labelledby="${id}-h">
++<div class="tr-head"><h2 id="${id}-h">${title}</h2>${more ? html`<a class="tr-more" href="${more.href}">${more.label}<span aria-hidden="true"> →</span></a>` : ''}</div>
${lede ? html`<p class="tr-lede">${lede}</p>` : ''}
${raw(String(body))}
${note ? html`<p class="muted">${note}</p>` : ''}
@@@ -113,6 +114,29 @@ function faqJsonLd(items) {
return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: items.map((x) => ({ '@type': 'Question', name: x.q, acceptedAnswer: { '@type': 'Answer', text: x.a } })) };
}
++/**
++ * The home's six kinds of market information, each one a pane of the pick block. A pane with nothing to show is left
++ * out, so the chips and the rules that show them match the page.
++ */
++function homePanes(d, urls) {
++ const s = d.stats;
++ return [
++ { id: 'briefs', label: 'AI briefs', html: section({ id: 'briefs', title: 'Latest AI briefs', lede: 'Short summaries of a company\'s latest numbers and filings, each point linked to the figure it rests on.', body: briefCards(d.briefs, urls), note: 'Written by OpenVibe.AI from SEC filings and the numbers in them, not reviewed by a person. Informational only; not investment advice.' }) },
++ { id: 'rates', label: 'Euro rates', html: section({ id: 'rates', title: 'Euro reference rates', lede: 'What one euro was worth in each currency on the latest rate date, as each source stated it. A change compares the two newest rates of the same source.', body: ratesTable(d.rates, urls) }) },
++ { id: 'reports', label: 'Reports', html: section({ id: 'reports', title: 'New annual and quarterly reports', lede: 'The latest 10-K and 10-Q filings, with the period figures read from them on each company\'s page.', more: { href: '/filings?form=10-Q', label: 'All quarterly reports' }, body: reportCards(d.reports, urls) }) },
++ { id: 'forms', label: 'By form', html: section({ id: 'forms', title: 'Filed in the last 7 days', lede: 'Filings by form type, the most first.', more: { href: '/filings', label: 'Every filing' }, body: formChips(d.forms) }) },
++ { id: 'recent', label: 'Filings', html: section({ id: 'recent', title: 'Newest filings', lede: 'As they arrive from the SEC, each with when it was published and when it was retrieved.', more: { href: '/filings', label: 'More filings' }, body: d.recent.length ? html`<ul class="docs">${d.recent.map((r) => filingLine(r, urls))}</ul>` : '' }) },
++ { id: 'movers', label: 'Companies', html: section({ id: 'movers', title: 'Companies with something new', lede: 'The newest filing or figure first.', more: { href: '/instruments', label: `All ${count(s.instruments)} companies and currencies` }, body: d.movers.instruments.length ? instrumentsTable({ instruments: d.movers.instruments, latest: d.movers.latest, latestDocs: d.movers.latestDocs, urls }) : '' }) },
++ ].filter((p) => p.html);
++}
++
++/** The rules that show the picked pane of the home's pick block: one per pane, written for this page. */
++function paneRules(d, urls) {
++ const ids = homePanes(d, urls).map((p) => p.id);
++ if (!ids.length) return '';
++ return `@supports selector(:has(*)){@media (max-width:640px){${pickRules('data', ids)}}}`;
++}
++
/**
* d: { stats: { companies, currencies, filings, filingsDay, filers, figures, figureCompanies, briefs },
* briefs, rates: [{ symbol, name, latest, previous, dto }], reports, recent: [{ document, instrument }],
@@@ -137,12 +161,12 @@ function homePage(d, urls) {
return html`${hero}
<p class="muted">What this site is not: there is no order entry, no brokerage, no custody of money or assets, no marketplace and no personal recommendation here.</p>
--${section({ id: 'briefs', title: 'Latest AI briefs', lede: 'Short summaries of a company\'s latest numbers and filings, each point linked to the figure it rests on.', body: briefCards(d.briefs, urls), note: 'Written by OpenVibe.AI from SEC filings and the numbers in them, not reviewed by a person. Informational only; not investment advice.' })}
--${section({ id: 'rates', title: 'Euro reference rates', lede: 'What one euro was worth in each currency on the latest rate date, as each source stated it. A change compares the two newest rates of the same source.', body: ratesTable(d.rates, urls) })}
--${section({ id: 'reports', title: 'New annual and quarterly reports', lede: 'The latest 10-K and 10-Q filings, with the period figures read from them on each company\'s page.', more: { href: '/filings?form=10-Q', label: 'All quarterly reports' }, body: reportCards(d.reports, urls) })}
--${section({ id: 'forms', title: 'Filed in the last 7 days', lede: 'Filings by form type, the most first.', more: { href: '/filings', label: 'Every filing' }, body: formChips(d.forms) })}
--${section({ id: 'recent', title: 'Newest filings', lede: 'As they arrive from the SEC, each with when it was published and when it was retrieved.', more: { href: '/filings', label: 'More filings' }, body: d.recent.length ? html`<ul class="docs">${d.recent.map((r) => filingLine(r, urls))}</ul>` : '' })}
--${section({ id: 'movers', title: 'Companies with something new', lede: 'The newest filing or figure first.', more: { href: '/instruments', label: `All ${count(s.instruments)} companies and currencies` }, body: d.movers.instruments.length ? instrumentsTable({ instruments: d.movers.instruments, latest: d.movers.latest, latestDocs: d.movers.latestDocs, urls }) : '' })}
++${raw(pickBlock({
++ key: 'data',
++ label: 'The market, by kind',
++ head: 'The market, by kind',
++ panes: homePanes(d, urls),
++ }))}
<div class="tr-sec"><details class="tr-fold"><summary>What is here<span>companies, numbers, briefs, filings, alerts</span></summary>${raw(showcase.features({
items: [
{ icon: 'ov:search', title: 'Find a company', text: 'By ticker, SEC CIK or company name. One match takes you straight there; several are listed, never guessed between.', href: '/instruments' },
@@@ -153,9 +177,12 @@ ${section({ id: 'movers', title: 'Companies with something new', lede: 'The newe
{ icon: 'ov:check', title: 'Source freshness', text: 'Every source with when it last answered; a source that falls behind marks its values stale.', href: '/sources' },
],
}))}</details></div>
--<section class="tr-sec" aria-labelledby="faq"><div class="tr-head"><h2 id="faq">Questions and answers</h2></div>
--<div class="tr-faq">${d.faq.map((x) => html`<details><summary>${x.q}</summary><p>${x.a}</p></details>`)}</div></section>
++${raw(foldBlock({
++ id: 'faq',
++ head: '<h2 id="faq-h">Questions and answers</h2>',
++ html: html`<div class="tr-faq">${d.faq.map((x) => html`<details><summary>${x.q}</summary><p>${x.a}</p></details>`)}</div>`,
++ }))}
<p>Feeds of recent documents: <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a>. Source freshness: <a href="/sources">sources</a>.${d.sync && d.sync.last_ok_at ? html` Last synchronised with OpenVibe.Sources ${when(d.sync.last_ok_at)}.` : ''}</p>`;
}
--module.exports = { homePage, faqItems, faqJsonLd, change };
++module.exports = { homePage, homePanes, paneRules, faqItems, faqJsonLd, change };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 6a2a50f..9320c43 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -17,6 +17,7 @@ const path = require('path');
const layout = require('openvibe-publishing/layout');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const frame = require('openvibe-shared/frame');
++const { foldBlock } = require('./short');
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Trade';
@@@ -89,7 +90,8 @@ function renderPage(o) {
description: o.description || `Instruments, timestamped observations and filings with their sources. ${DISCLAIMER}`,
canonical: o.canonical,
// The head names the page's own Markdown twin (openvibe-shared/agent-layer).
-- head: agentHeadTags(o),
++ // The rules that show the picked pane of a pick block are written for the page, because there is one per pane.
++ head: [agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
decision: o.decision,
type: o.type || 'website',
image: o.image,
@@@ -112,7 +114,12 @@ function renderPage(o) {
header: `<p class="disclaimer" role="note"><strong>${esc(DISCLAIMER)}</strong> Every number shows when it was observed and where it came from; stale sources are labelled as stale.</p>`,
body: o.body,
shipped: [
-- o.path === '/' ? frame.shipped({ service: 'trade', title: `Recently shipped on ${SITE_NAME}` }) : '',
++ // The shipped list is a part a reader rarely needs: a fold on a phone, the section it was where there is room.
++ o.path === '/' ? foldBlock({
++ id: 'shipped',
++ head: `<h2 id="h-shipped">Recently shipped on ${SITE_NAME}</h2>`,
++ html: frame.shipped({ service: 'trade', title: `Recently shipped on ${SITE_NAME}` }),
++ }) : '',
`<p class="disclaimer disclaimer-foot" role="note">${esc(DISCLAIMER)} OpenVibe.Trade holds no money or assets, takes no orders and gives no personal recommendations.</p>`,
].filter(Boolean).join('\n'),
bodyClass: o.bodyClass,
ddiff --git a/server/render/short.js b/server/render/short.js
nnew file mode 100644
iindex 0000000..3586242
---- /dev/null
++++ b/server/render/short.js
@@@ -0,0 +1,56 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
++ *
++ * pickBlock() several blocks of the same kind, one at a time on a phone, picked with a row of chips. Radio
++ * inputs and :has(), no script: every block stays in the page for crawlers, and a wider screen (or a
++ * browser without :has()) shows them all and no chips.
++ * foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup
++ * keeps it open, so a browser without the script shows it as before; the script closes it on a narrow
++ * screen and remembers the reader's choice.
++ *
++ * The CSS for both lives in public/css/trade.css; the rule that shows the picked block is written for the page,
++ * because there is one per block.
++ */
++const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++
++/**
++ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
++ * heading gives way to the chip that names it.
++ *
++ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
++ */
++function pickBlock({ key, label, head = '', panes }) {
++ if (!panes.length) return '';
++ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');
++ // The pane carries the id the page already links to (an anchor in the hero, for instance), so a link to it still
++ // opens that pane on a phone (the CSS shows the pane a link points at).
++ const body = panes.map((p) => `<div class="pick-pane" id="${esc(p.id)}">${p.html}</div>`).join('');
++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head"><h2>${esc(head || label)}</h2></div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body">${body}</div></div>`;
++}
++
++/** The rule that shows the picked block of a pick block: one per block, so the CSS is written for the page. */
++function pickRule(key, id) {
++ return `.pick:has(#pk-${key}-${id}:checked) #${id}{display:block}`;
++}
++
++/** The rules for every block of a pick block, in the order they appear. */
++function pickRules(key, ids) {
++ return ids.map((id) => pickRule(key, id)).join('');
++}
++
++/**
++ * A fold. The head is the heading the block had; the html is what it holds.
++ *
++ * foldBlock({ id, head, html })
++ */
++function foldBlock({ id, head, html }) {
++ return `<details class="tr-fold tr-fold-block" id="${esc(id)}" open><summary>${head}<span>tap to open</span></summary>${html}</details>${foldScript(id)}`;
++}
++
++/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */
++function foldScript(id) {
++ return `<script>(function(){var d=document.getElementById(${JSON.stringify(id)});if(!d)return;var k='ov_fold_'+${JSON.stringify(id)};var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>`;
++}
++
++module.exports = { pickBlock, pickRule, pickRules, foldBlock, foldScript };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..82d9d07
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,70 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the home's six kinds of market information are
++ * one pick block, a row of chips picks one of them, and the parts a reader rarely needs are folds. Radio inputs and
++ * :has(), no script for the chips; every pane stays in the page for crawlers; a wider screen shows them all and no
++ * chips.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const quiet = { log() {}, warn() {}, error() {} };
++
++(async () => {
++ const t = await boot({ log: quiet });
++ const db = t.ctx.store.db;
++ try {
++ // The data the home's panes show: a currency with a rate, a company with a price, a filing and a brief.
++ const usd = await t.ctx.instruments.create({ kind: 'currency', symbol: 'USD', name: 'US Dollar' }, 'usr_test');
++ await t.observe(usd, { metric: 'rate', value: '1.0925', unit: 'ratio', source_ref: 'rate-usd-1', observed_at: t.iso(t.T0 - 86400e3), retrieved_at: t.iso(t.T0 - 43200e3) });
++ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });
++ await t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'price-acme-1', observed_at: t.iso(t.T0 - 3600e3), retrieved_at: t.iso(t.T0 - 1800e3) });
++ t.sources.setSource('sec-xbrl-filings', { lastSuccessAt: t.iso(t.T0 - 60e3), staleAfterSec: 2700 });
++ t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000001/0001234567-26-000001-index.htm', title: 'ACME CORP', summary: '10-K', published_at: '2026-09-22T10:00:00Z', retrieved_at: '2026-09-22T10:05:00Z' });
++ await t.ctx.sync.run();
++ await db.prepare(`INSERT INTO trade_ai_briefs (id, instrument_id, summary, points, cites, gaps, as_of, inputs_hash, run_id, model, trigger, created_at)
++ VALUES ('tbr_test_1', @iid, 'A short brief.', '[]', '[]', '[]', @as_of, 'h1', 'run_1', 'test-model', 'report', @created)`)
++ .run({ iid: acme.id, as_of: t.T0, created: t.T0 });
++
++ await check('the home is one pick block: a chip per pane, the first picked, every pane in the page', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200, r.text);
++ const page = r.text;
++ const block = page.slice(page.indexOf('<div class="pick" id="pick-data">'), page.indexOf('<div class="pick" id="pick-data">') + 6000);
++ assert.ok(block.startsWith('<div class="pick" id="pick-data">'), 'the pick block is on the home');
++ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="The market, by kind">/);
++ const chips = [...block.matchAll(/<input type="radio" name="pick-data" id="pk-data-([a-z]+)" value="\1"( checked)?><label for="pk-data-\1">([^<]+)<\/label>/g)];
++ assert.ok(chips.length >= 4, `a chip per pane (${chips.length})`);
++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first pane is picked');
++ // Every pane stays in the page, with the id its chip picks.
++ const panes = [...block.matchAll(/<div class="pick-pane" id="([a-z]+)">/g)].map((m) => m[1]);
++ assert.deepStrictEqual(panes, chips.map((m) => m[1]), 'every pane is in the page, one per chip');
++ });
++
++ await check('the CSS guard: the chips are hidden and the panes all show until 640 px and where :has() is understood', async () => {
++ const r = await t.get('/');
++ const page = r.text;
++ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick:has\(#pk-data-briefs:checked\) #briefs\{display:block\}/, 'the rule that shows the picked pane');
++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'trade.css'), 'utf8');
++ assert.match(css, /\.pick-row \{ display: none; \}/, 'the chips are hidden by default');
++ assert.match(css, /@supports selector\(:has\(\*\)\) \{ @media \(max-width: 640px\) \{/, 'the phone rules are under :has() and 640 px');
++ assert.match(css, /\.pick-row label \{[^}]*min-height: 44px/, 'a chip is a 44 px target');
++ assert.match(css, /\.pick \.pick-pane \{ display: none; \}/, 'the panes are hidden on a phone');
++ });
++
++ await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {
++ const r = await t.get('/');
++ const page = r.text;
++ for (const id of ['faq', 'shipped']) {
++ assert.ok(page.includes(`<details class="tr-fold tr-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);
++ assert.ok(page.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);
++ }
++ // The content of the folds stays in the page.
++ assert.match(page, /<h2 id="faq-h">Questions and answers<\/h2>/);
++ assert.match(page, /Is this investment advice\?/);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 46bcdf1..b5bddc9 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -13,13 +13,13 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
htmlRawKB: 41, // 36.2 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); ~32.5 with the curated front page (numbers, sections, six questions and their JSON-LD; 2026-10-22); 27.4 with the AI briefs (2026-10-15)
-- htmlBrotliKB: 8.8, // 8.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 7.7 with the curated front page; 6.2 before (4.7 before the showcase)
++ htmlBrotliKB: 9, // 8.9 with the phone pick block (the chips and the fold script, 2026-10-30); 8.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 7.7 with the curated front page; 6.2 before (4.7 before the showcase)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 2 (trade.css, openvibe-shared showcase.css)
-- cssRawKB: 42, // 40.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 38.4 the home's phone rows and fold (2026-10-29); 34 openvibe-shared 4.26.1 (hero visual); 28.5 with Shared 4.13.0 (2026-10-26: account links); ~23 with the front page's numbers, cards, rate cards, chips and questions (2026-10-22); 18.0 before (4.7 before showcase.css)
-- cssBrotliKB: 8.9, // 8.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 8.3 the home's phone rows and fold (2026-10-29); 6.3 with Shared 4.13.0 (2026-10-26: account links); ~5.3 with the front page (2026-10-22); 4.5 with the tablet table rule (1.3 before showcase.css)
++ cssRawKB: 44, // 43.9 with the phone pick block and the folds (2026-10-30); 40.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 38.4 the home's phone rows and fold (2026-10-29); 34 openvibe-shared 4.26.1 (hero visual); 28.5 with Shared 4.13.0 (2026-10-26: account links); ~23 with the front page's numbers, cards, rate cards, chips and questions (2026-10-22); 18.0 before (4.7 before showcase.css)
++ cssBrotliKB: 9.5, // 9.4 with the phone pick block and the folds (2026-10-30); 8.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 8.3 the home's phone rows and fold (2026-10-29); 6.3 with Shared 4.13.0 (2026-10-26: account links); ~5.3 with the front page (2026-10-22); 4.5 with the tablet table rule (1.3 before showcase.css)
externalFiles: 1, // 0
};