openvibers/openvibe.trade Commits
Trade home: show every pane on a phone; chips are links (#179)
Changes
@@@ -105,9 +105,10 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }.tr-faq summary { cursor: pointer; font-weight: 600; padding: 12px 0; }.tr-faq p { margin: 0 0 14px; color: var(--text-secondary, #96a7c2); }.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }--@media (max-width: 600px) { .tr-search { flex-wrap: wrap; } .tr-search button { width: 100%; } .tr-stats b { font-size: 1.3rem; } .tr-stats a { padding: 12px; } .tr-cards > li:nth-child(n+5) { display: none; } }++@media (max-width: 600px) { .tr-search { flex-wrap: wrap; } .tr-search button { width: 100%; } .tr-stats b { font-size: 1.3rem; } .tr-stats a { padding: 12px; } }/* Phones (2026-10-29): a taste and a door. The briefs, reports and euro rates are one row each that scrolls sideways-- (every card is still there), the lists show their first rows (each is a link away), the figures are one strip. */++ (every card is still there), each long list shows eight rows and keeps its link to the full list, the figures are++ one strip. */.tr-fold { font-family: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }.tr-fold > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 0 16px; cursor: pointer; font-weight: 700; list-style: none; }.tr-fold > summary::-webkit-details-marker { display: none; }@@@ -120,8 +121,6 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }.tr-cards > li { flex: 0 0 84%; 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-sec .tr-chips > li:nth-child(n+9), .tr-sec > .docs > li:nth-child(n+6) { 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; } }@@@ -144,30 +143,22 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }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: on a phone every pane is shown, stacked, each with its own heading; the chips are a row of links that++ jump to a pane (the owner's rule, 2026-10-31: never hide content to shorten). A wide screen shows the panes as++ before, with no chips. */++.pick-head, .pick-row { display: none; }++@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; }--} }++ .pick-chip { 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; text-decoration: none; }++ .pick-chip:hover, .pick-chip:focus-visible { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); }++ .pick-chip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++ .pick .pick-pane { scroll-margin-top: 72px; }++ .pick .pick-pane + .pick-pane { margin-top: 26px; }++}/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section itreplaces: a heading and its content, with no card around it. */@@@ -71,7 +71,7 @@ function createPages(ctx) {const [kinds, docStats, obsStats, briefStats, briefs, rateRows, reportDocs, forms, recentDocs, movers] = await Promise.all([instruments.countByKind(), documents.stats(now - DAY), observations.stats(), ctx.briefs.stats(), ctx.briefs.recent(6),observations.rates(), documents.recentOf(['10-K', '10-Q', '20-F', '40-F'], { limit: 8 }), documents.formCounts(now - 7 * DAY),-- documents.recent(10), instruments.page({ limit: 12, offset: 0 }),++ documents.recent(8), instruments.page({ limit: 8, offset: 0 }),]);const rates = await Promise.all(rateRows.map(async (r) => ({ ...r, dto: await observations.dto(r.latest, { now }) })));const faq = home.faqItems();@@@ -88,8 +88,6 @@ function createPages(ctx) {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),@@@ -12,7 +12,7 @@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 { pickBlock, foldBlock } = require('./short');const count = (n) => Number(n).toLocaleString('en-US');@@@ -95,7 +95,7 @@ function reportCards(list, urls) {/** This week's filings by form: chips with the plain name, the code and the count, each to /filings?form=. */function formChips(counts) {if (!counts.length) return '';-- return html`<ul class="tr-chips">${counts.slice(0, 16).map((c) => html`<li><a href="/filings?form=${encodeURIComponent(c.form_type)}">${formLabel(c.form_type) ? html`${formLabel(c.form_type)} <span class="muted">(${c.form_type})</span>` : c.form_type}<span class="tr-n"><span class="sr-only">: </span>${count(c.count)}</span></a></li>`)}</ul>`;++ return html`<ul class="tr-chips">${counts.slice(0, 8).map((c) => html`<li><a href="/filings?form=${encodeURIComponent(c.form_type)}">${formLabel(c.form_type) ? html`${formLabel(c.form_type)} <span class="muted">(${c.form_type})</span>` : c.form_type}<span class="tr-n"><span class="sr-only">: </span>${count(c.count)}</span></a></li>`)}</ul>`;}/** Questions people ask, answered from how the site works (also the page's FAQPage JSON-LD). */@@@ -130,13 +130,6 @@ function homePanes(d, 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 }],@@@ -185,4 +178,4 @@ ${raw(foldBlock({<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, homePanes, paneRules, faqItems, faqJsonLd, change };++module.exports = { homePage, homePanes, faqItems, faqJsonLd, change };@@@ -1,42 +1,30 @@'use strict';/**-- * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.++ * The home on a phone (the owner's rule, 2026-10-31: never hide content to shorten): every block is shown, stacked,++ * each with its own heading. The chips are links that jump to a block.*-- * 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.++ * pickBlock() several blocks of the same kind, all shown; the chips are a row of links to them, never a picker.* 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.++ * The CSS for both lives in public/css/trade.css.*/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.++ * A pick block. The panes are the page's own blocks, all shown, each with its own heading; the chips are links that++ * jump to them.** 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 chips = panes.map((p) => `<a class="pick-chip" href="#${esc(p.id)}">${esc(p.label)}</a>`).join('');++ // The pane carries the id the page already links to (an anchor in the hero, for instance), so the chip and any++ // link to it land on that block.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('');++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head"><h2>${esc(head || label)}</h2></div><nav class="pick-row" aria-label="${esc(label)}">${chips}</nav></div><div class="pick-body">${body}</div></div>`;}/**@@@ -53,4 +41,4 @@ 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 };++module.exports = { pickBlock, foldBlock, foldScript };@@@ -1,9 +1,8 @@'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.++ * The home on a phone (the owner's rule, 2026-10-31: never hide content to shorten): the home's six kinds of market++ * information are one pick block, every pane is shown, stacked, with its own heading, and the chips are a row of++ * links that jump to a pane. The parts a reader rarely needs are folds.*/const assert = require('assert');const { boot, check, done } = require('./helpers/boot');@@@ -26,30 +25,34 @@ const quiet = { log() {}, warn() {}, error() {} };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 () => {++ await check('the home is one pick block: a link per pane, every pane in the page and showing', 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.++ assert.ok(!/<input type="radio"/.test(block), 'no radio in the pick block');++ assert.match(block, /<nav class="pick-row" aria-label="The market, by kind">/);++ const chips = [...block.matchAll(/<a class="pick-chip" href="#([a-z]+)">[^<]+<\/a>/g)];++ assert.ok(chips.length >= 4, `a link per pane (${chips.length})`);++ // Every pane is in the page, and every link points at one of them.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');++ assert.deepStrictEqual(panes, chips.map((m) => m[1]), 'every pane is in the page, one per link');});-- await check('the CSS guard: the chips are hidden and the panes all show until 640 px and where :has() is understood', async () => {++ await check('the CSS shows every pane on a phone; the chips are links, not a picker', 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');++ assert.ok(!page.includes('.pick:has('), 'no inline rule picks a pane');++ assert.ok(!page.includes('pk-data'), 'no radio ids on the page');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');++ assert.match(css, /\.pick-head, \.pick-row \{ display: none; \}/, 'the chips are hidden by default');++ assert.match(css, /@media \(max-width: 640px\) \{/, 'the phone rules are under 640 px');++ assert.match(css, /\.pick-row \{ display: flex/, 'the chips are a row on a phone');++ assert.match(css, /\.pick-chip \{[^}]*min-height: 44px/, 'a chip is a 44 px link target');++ assert.ok(!/\.pick[^{}]*\.pick-pane[^{}]*\{[^}]*display:\s*none/.test(css), 'no rule hides a pane');++ assert.ok(!/\.pick[^{}]*:target/.test(css), 'no :target pane rule');++ assert.ok(!/\.pick[^{}]*\.pick-pane[^{}]*h2[^{}]*\{[^}]*display:\s*none/.test(css), 'a pane keeps its heading on a phone');});await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {
@@ -105,9 +105,10 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }
@@ -105,9 +105,10 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }
.tr-faq summary { cursor: pointer; font-weight: 600; padding: 12px 0; }
.tr-faq summary { cursor: pointer; font-weight: 600; padding: 12px 0; }
.tr-faq p { margin: 0 0 14px; color: var(--text-secondary, #96a7c2); }
.tr-faq p { margin: 0 0 14px; color: var(--text-secondary, #96a7c2); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
-@media (max-width: 600px) { .tr-search { flex-wrap: wrap; } .tr-search button { width: 100%; } .tr-stats b { font-size: 1.3rem; } .tr-stats a { padding: 12px; } .tr-cards > li:nth-child(n+5) { display: none; } }
+@media (max-width: 600px) { .tr-search { flex-wrap: wrap; } .tr-search button { width: 100%; } .tr-stats b { font-size: 1.3rem; } .tr-stats a { padding: 12px; } }
/* Phones (2026-10-29): a taste and a door. The briefs, reports and euro rates are one row each that scrolls sideways
/* Phones (2026-10-29): a taste and a door. The briefs, reports and euro rates are one row each that scrolls sideways
- (every card is still there), the lists show their first rows (each is a link away), the figures are one strip. */
+ (every card is still there), each long list shows eight rows and keeps its link to the full list, the figures are
+ one strip. */
.tr-fold { font-family: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }
.tr-fold { font-family: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }
.tr-fold > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 0 16px; cursor: pointer; font-weight: 700; list-style: none; }
.tr-fold > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 0 16px; cursor: pointer; font-weight: 700; list-style: none; }
.tr-fold > summary::-webkit-details-marker { display: none; }
.tr-fold > summary::-webkit-details-marker { display: none; }
@@ -120,8 +121,6 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }
@@ -120,8 +121,6 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }
.tr-cards > li { flex: 0 0 84%; scroll-snap-align: start; }
.tr-cards > li { flex: 0 0 84%; scroll-snap-align: start; }
.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[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; } }
@@ -144,30 +143,22 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }
@@ -144,30 +143,22 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }
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: on a phone every pane is shown, stacked, each with its own heading; the chips are a row of links that
+ jump to a pane (the owner's rule, 2026-10-31: never hide content to shorten). A wide screen shows the panes as
+ before, with no chips. */
+.pick-head, .pick-row { display: none; }
+@media (max-width: 640px) {
.pick-bar { margin-top: 30px; }
.pick-bar { margin-top: 30px; }
.pick-head { display: block; }
.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-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 { 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::-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; }
-} }
+ .pick-chip { 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; text-decoration: none; }
+ .pick-chip:hover, .pick-chip:focus-visible { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); }
+ .pick-chip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+ .pick .pick-pane { scroll-margin-top: 72px; }
+ .pick .pick-pane + .pick-pane { margin-top: 26px; }
+}
/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it
/* 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. */
replaces: a heading and its content, with no card around it. */
@@ -71,7 +71,7 @@ function createPages(ctx) {
@@ -71,7 +71,7 @@ function createPages(ctx) {
const [kinds, docStats, obsStats, briefStats, briefs, rateRows, reportDocs, forms, recentDocs, movers] = await Promise.all([
const [kinds, docStats, obsStats, briefStats, briefs, rateRows, reportDocs, forms, recentDocs, movers] = await Promise.all([
instruments.countByKind(), documents.stats(now - DAY), observations.stats(), ctx.briefs.stats(), ctx.briefs.recent(6),
instruments.countByKind(), documents.stats(now - DAY), observations.stats(), ctx.briefs.stats(), ctx.briefs.recent(6),
observations.rates(), documents.recentOf(['10-K', '10-Q', '20-F', '40-F'], { limit: 8 }), documents.formCounts(now - 7 * DAY),
observations.rates(), documents.recentOf(['10-K', '10-Q', '20-F', '40-F'], { limit: 8 }), documents.formCounts(now - 7 * DAY),
- documents.recent(10), instruments.page({ limit: 12, offset: 0 }),
+ documents.recent(8), instruments.page({ limit: 8, offset: 0 }),
]);
]);
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();
@@ -88,8 +88,6 @@ function createPages(ctx) {
@@ -88,8 +88,6 @@ function createPages(ctx) {
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),
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
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 { pickBlock, foldBlock } = require('./short');
const count = (n) => Number(n).toLocaleString('en-US');
const count = (n) => Number(n).toLocaleString('en-US');
@@ -95,7 +95,7 @@ function reportCards(list, urls) {
@@ -95,7 +95,7 @@ function reportCards(list, urls) {
/** This week's filings by form: chips with the plain name, the code and the count, each to /filings?form=. */
/** This week's filings by form: chips with the plain name, the code and the count, each to /filings?form=. */
function formChips(counts) {
function formChips(counts) {
if (!counts.length) return '';
if (!counts.length) return '';
- return html`<ul class="tr-chips">${counts.slice(0, 16).map((c) => html`<li><a href="/filings?form=${encodeURIComponent(c.form_type)}">${formLabel(c.form_type) ? html`${formLabel(c.form_type)} <span class="muted">(${c.form_type})</span>` : c.form_type}<span class="tr-n"><span class="sr-only">: </span>${count(c.count)}</span></a></li>`)}</ul>`;
+ return html`<ul class="tr-chips">${counts.slice(0, 8).map((c) => html`<li><a href="/filings?form=${encodeURIComponent(c.form_type)}">${formLabel(c.form_type) ? html`${formLabel(c.form_type)} <span class="muted">(${c.form_type})</span>` : c.form_type}<span class="tr-n"><span class="sr-only">: </span>${count(c.count)}</span></a></li>`)}</ul>`;
}
}
/** Questions people ask, answered from how the site works (also the page's FAQPage JSON-LD). */
/** Questions people ask, answered from how the site works (also the page's FAQPage JSON-LD). */
@@ -130,13 +130,6 @@ function homePanes(d, urls) {
@@ -130,13 +130,6 @@ function homePanes(d, urls) {
].filter((p) => p.html);
].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 }],
@@ -185,4 +178,4 @@ ${raw(foldBlock({
@@ -185,4 +178,4 @@ ${raw(foldBlock({
<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, homePanes, paneRules, faqItems, faqJsonLd, change };
+module.exports = { homePage, homePanes, faqItems, faqJsonLd, change };
@@ -1,42 +1,30 @@
@@ -1,42 +1,30 @@
'use strict';
'use strict';
/**
/**
- * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
+ * The home on a phone (the owner's rule, 2026-10-31: never hide content to shorten): every block is shown, stacked,
+ * each with its own heading. The chips are links that jump to a block.
*
*
- * 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.
+ * pickBlock() several blocks of the same kind, all shown; the chips are a row of links to them, never a picker.
* foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup
* 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
* 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.
* 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.
+ * The CSS for both lives in public/css/trade.css.
*/
*/
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
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.
+ * A pick block. The panes are the page's own blocks, all shown, each with its own heading; the chips are links that
+ * jump to them.
*
*
* pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
* pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
*/
*/
function pickBlock({ key, label, head = '', panes }) {
function pickBlock({ key, label, head = '', panes }) {
if (!panes.length) return '';
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 chips = panes.map((p) => `<a class="pick-chip" href="#${esc(p.id)}">${esc(p.label)}</a>`).join('');
+ // The pane carries the id the page already links to (an anchor in the hero, for instance), so the chip and any
+ // link to it land on that block.
const body = panes.map((p) => `<div class="pick-pane" id="${esc(p.id)}">${p.html}</div>`).join('');
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('');
+ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head"><h2>${esc(head || label)}</h2></div><nav class="pick-row" aria-label="${esc(label)}">${chips}</nav></div><div class="pick-body">${body}</div></div>`;
}
}
/**
/**
@@ -53,4 +41,4 @@ function foldScript(id) {
@@ -53,4 +41,4 @@ 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>`;
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 };
+module.exports = { pickBlock, foldBlock, foldScript };
@@ -1,9 +1,8 @@
@@ -1,9 +1,8 @@
'use strict';
'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.
+ * The home on a phone (the owner's rule, 2026-10-31: never hide content to shorten): the home's six kinds of market
+ * information are one pick block, every pane is shown, stacked, with its own heading, and the chips are a row of
+ * links that jump to a pane. The parts a reader rarely needs are folds.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
@@ -26,30 +25,34 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -26,30 +25,34 @@ const quiet = { log() {}, warn() {}, error() {} };
VALUES ('tbr_test_1', @iid, 'A short brief.', '[]', '[]', '[]', @as_of, 'h1', 'run_1', 'test-model', 'report', @created)`)
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 });
.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 () => {
+ await check('the home is one pick block: a link per pane, every pane in the page and showing', async () => {
const r = await t.get('/');
const r = await t.get('/');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
const page = 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);
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.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.
+ assert.ok(!/<input type="radio"/.test(block), 'no radio in the pick block');
+ assert.match(block, /<nav class="pick-row" aria-label="The market, by kind">/);
+ const chips = [...block.matchAll(/<a class="pick-chip" href="#([a-z]+)">[^<]+<\/a>/g)];
+ assert.ok(chips.length >= 4, `a link per pane (${chips.length})`);
+ // Every pane is in the page, and every link points at one of them.
const panes = [...block.matchAll(/<div class="pick-pane" id="([a-z]+)">/g)].map((m) => m[1]);
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');
+ assert.deepStrictEqual(panes, chips.map((m) => m[1]), 'every pane is in the page, one per link');
});
});
- await check('the CSS guard: the chips are hidden and the panes all show until 640 px and where :has() is understood', async () => {
+ await check('the CSS shows every pane on a phone; the chips are links, not a picker', async () => {
const r = await t.get('/');
const r = await t.get('/');
const page = r.text;
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');
+ assert.ok(!page.includes('.pick:has('), 'no inline rule picks a pane');
+ assert.ok(!page.includes('pk-data'), 'no radio ids on the page');
const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'trade.css'), 'utf8');
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');
+ assert.match(css, /\.pick-head, \.pick-row \{ display: none; \}/, 'the chips are hidden by default');
+ assert.match(css, /@media \(max-width: 640px\) \{/, 'the phone rules are under 640 px');
+ assert.match(css, /\.pick-row \{ display: flex/, 'the chips are a row on a phone');
+ assert.match(css, /\.pick-chip \{[^}]*min-height: 44px/, 'a chip is a 44 px link target');
+ assert.ok(!/\.pick[^{}]*\.pick-pane[^{}]*\{[^}]*display:\s*none/.test(css), 'no rule hides a pane');
+ assert.ok(!/\.pick[^{}]*:target/.test(css), 'no :target pane rule');
+ assert.ok(!/\.pick[^{}]*\.pick-pane[^{}]*h2[^{}]*\{[^}]*display:\s*none/.test(css), 'a pane keeps its heading on a phone');
});
});
await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {
await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {