openvibers/openvibe.trade Commits
Home on phones: one kind of market information at a time (#150)
Changes
@@@ -14,7 +14,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visibwhich 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; } }@@@ -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),});});@@@ -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 };@@@ -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,nnew file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++/**++ * 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 };nnew file mode 100644@@@ -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); });@@@ -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};
@@ -14,7 +14,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visib
@@ -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. */
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; }
.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". */
/* 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 { 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; }
h1 .name { font-weight: 400; color: var(--text-secondary, #96a7c2); font-size: 1.3rem; }
h2 { font-size: 1.3rem; margin: 1.8rem 0 .5rem; }
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; }
@@ -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-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-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 .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; }
.tr-fold > summary span { display: none; }
}
}
@media (prefers-reduced-motion: reduce) { .tr-stats a { transition: 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; }
@@ -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; }
.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; }
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; } }
@@ -70,23 +70,26 @@ function createPages(ctx) {
@@ -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 rates = await Promise.all(rateRows.map(async (r) => ({ ...r, dto: await observations.dto(r.latest, { now }) })));
const faq = home.faqItems();
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, {
common.page(req, res, {
title: 'SEC filings, company figures, AI briefs', canonical: urls.abs('/'), decision: listing('/'),
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.',
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).
// 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,
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: [
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' } }),
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),
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),
});
});
});
});
@@ -12,13 +12,14 @@
@@ -12,13 +12,14 @@
const { html, raw } = require('openvibe-publishing/ssr');
const { html, raw } = require('openvibe-publishing/ssr');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const { when, staleBadge, grouped, clip, formLabel, filingLine, instrumentsTable } = require('./views');
const { when, staleBadge, grouped, clip, formLabel, filingLine, instrumentsTable } = require('./views');
+const { pickBlock, pickRules, foldBlock } = require('./short');
const count = (n) => Number(n).toLocaleString('en-US');
const count = (n) => Number(n).toLocaleString('en-US');
function section({ id, title, lede, more, body, note }) {
function section({ id, title, lede, more, body, note }) {
if (!body) return '';
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>` : ''}
${lede ? html`<p class="tr-lede">${lede}</p>` : ''}
${raw(String(body))}
${raw(String(body))}
${note ? html`<p class="muted">${note}</p>` : ''}
${note ? html`<p class="muted">${note}</p>` : ''}
@@ -113,6 +114,29 @@ function faqJsonLd(items) {
@@ -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 } })) };
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 },
* d: { stats: { companies, currencies, filings, filingsDay, filers, figures, figureCompanies, briefs },
* briefs, rates: [{ symbol, name, latest, previous, dto }], reports, recent: [{ document, instrument }],
* briefs, rates: [{ symbol, name, latest, previous, dto }], reports, recent: [{ document, instrument }],
@@ -137,12 +161,12 @@ function homePage(d, urls) {
@@ -137,12 +161,12 @@ function homePage(d, urls) {
return html`${hero}
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>
<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({
<div class="tr-sec"><details class="tr-fold"><summary>What is here<span>companies, numbers, briefs, filings, alerts</span></summary>${raw(showcase.features({
items: [
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' },
{ 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
@@ -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' },
{ 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>
}))}</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>`;
<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 };
@@ -17,6 +17,7 @@ const path = require('path');
@@ -17,6 +17,7 @@ const path = require('path');
const layout = require('openvibe-publishing/layout');
const layout = require('openvibe-publishing/layout');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
+const { foldBlock } = require('./short');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Trade';
const SITE_NAME = 'OpenVibe.Trade';
@@ -89,7 +90,8 @@ function renderPage(o) {
@@ -89,7 +90,8 @@ function renderPage(o) {
description: o.description || `Instruments, timestamped observations and filings with their sources. ${DISCLAIMER}`,
description: o.description || `Instruments, timestamped observations and filings with their sources. ${DISCLAIMER}`,
canonical: o.canonical,
canonical: o.canonical,
// The head names the page's own Markdown twin (openvibe-shared/agent-layer).
// 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,
decision: o.decision,
type: o.type || 'website',
type: o.type || 'website',
image: o.image,
image: o.image,
@@ -112,7 +114,12 @@ function renderPage(o) {
@@ -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>`,
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,
body: o.body,
shipped: [
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>`,
`<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'),
].filter(Boolean).join('\n'),
bodyClass: o.bodyClass,
bodyClass: o.bodyClass,
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+/**
+ * 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -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); });
@@ -13,13 +13,13 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -13,13 +13,13 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
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)
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)
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)
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)
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)
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
externalFiles: 1, // 0
};
};