openvibers/openvibe.games Commits
The home on a phone: the boards are one pick block, the rest are folds (#215)
Changes
@@@ -397,6 +397,83 @@box-shadow: 0 8px 48px rgba(var(--accent-rgb, 59, 130, 246), 0.7);}}++ /* ── the home on a phone (the owner's short-pages rule, 2026-10-30) ──++ Pick: on a phone the home's blocks of the same kind are one at a time, picked with a row of chips.++ Radio inputs and :has(), no script: the chips and the one-block rule live under 640 px and only where++ :has() is understood, so a wider screen, or an older browser, shows every block and no chips. */++ .pick-head { display: none; }++ .pick-row { display: none; }++ .pick-pane { min-width: 0; }++ @supports selector(:has(*)) {++ @media (max-width: 640px) {++ .pick-head { display: block; }++ .pick-head h2 { margin: 0 0 4px; font-size: 1.25rem; }++ .pick-row {++ display: flex;++ gap: 8px;++ overflow-x: auto;++ margin: 10px -20px 0;++ padding: 2px 20px 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);++ color: var(--text-dim);++ font-size: 0.9rem;++ font-weight: 650;++ white-space: nowrap;++ cursor: pointer;++ }++ .pick-row input:checked + label {++ color: var(--text);++ border-color: var(--accent-light);++ background: rgba(var(--accent-rgb, 59, 130, 246), 0.12);++ }++ .pick-row input:focus-visible + label { outline: 2px solid var(--accent-light); outline-offset: 2px; }++ .pick-pane { display: none; }++ /* The chip names the block, so the block's own heading gives way to it. */++ .home-pick-sec > h2 { display: none; }++ }++ }++ /* The fold: closed on a phone, and where there is room it looks like the section it replaces. */++ .gm-fold-block { margin: 0 auto 40px; max-width: 1100px; padding: 0 20px; }++ .gm-fold-block > summary {++ display: flex;++ align-items: center;++ justify-content: space-between;++ gap: 10px;++ min-height: 48px;++ padding: 0 16px;++ cursor: pointer;++ color: var(--text);++ font-weight: 700;++ list-style: none;++ border: 1px solid var(--border);++ border-radius: 14px;++ background: var(--bg-card);++ }++ .gm-fold-block > summary::-webkit-details-marker { display: none; }++ .gm-fold-block > summary h2 { margin: 0; padding: 0; border: 0; font-size: 1.05rem; }++ .gm-fold-block > summary span { color: var(--text-dim); font-weight: 600; font-size: 0.86rem; text-align: right; }++ .gm-fold-block > summary:focus-visible { outline: 2px solid var(--accent-light); outline-offset: -2px; border-radius: 14px; }++ .gm-fold-block .home-sec { margin: 12px 0 0; padding: 0; }++ .gm-fold-block .home-sec > h2 { display: none; }++ .gm-fold-block .ov-shipped-home { margin: 12px 0 0 !important; padding: 0 !important; }++ @media (min-width: 641px) {++ .gm-fold-block { border: 0; background: none; margin-top: 40px; padding: 0 20px; }++ .gm-fold-block > summary { display: block; min-height: 0; padding: 0; border: 0; background: none; }++ .gm-fold-block > summary h2 { margin: 0 0 6px; font-size: 1.25rem; border-bottom: 1px solid var(--border); padding-bottom: 0.2em; }++ .gm-fold-block > summary span { display: none; }++ }/* ── live sections (net/portalHome.ts renders them on the server) ── */.home-sec {max-width: 1100px;@@@ -1244,7 +1321,10 @@<!-- What shipped on OpenVibe.Games: the network's shared "shipped" views (openvibe-shared-- shipped.js, which the shared footer loads), the same on every OpenVibe site. -->++ shipped.js, which the shared footer loads), the same on every OpenVibe site. A fold on a phone:++ closed there, open where there is room, and the list stays in the page. -->++ <details class="gm-fold gm-fold-block" id="home-shipped" open>++ <summary><h2>Recently shipped on OpenVibe.Games</h2><span>tap to open</span></summary><sectionclass="ov-shipped-home"aria-label="What shipped"@@@ -1270,6 +1350,8 @@</p></noscript></section>++ </details>++ <script>(function(){var d=document.getElementById("home-shipped");if(!d)return;var k='ov_fold_'+'home-shipped';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><!-- Shared cross-site footer (/shared/footer.js, this site's pinned copy): network links, legal, signed-in row. --><footer id="ov-footer" class="ovf"></footer>@@@ -4,6 +4,7 @@ import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'import type { CreatorWorld } from '@openvibe/persistence'import type { Logger } from '@openvibe/shared'import { escapeHtml } from './notFound.js'++import { foldBlock, pickBlock, pickRules, pickStyle } from './shortHome.js'import type { SteamGame } from '../steam/steamCatalog.js'/**@@@ -196,9 +197,9 @@ function boardCard(o: {unit: (score: number) => stringhref: stringmore: string--}): string {-- if (!o.rows.length) return ''-- return `<article class="board-card">++}): { label: string; html: string } | null {++ if (!o.rows.length) return null++ const html = `<article class="board-card"><h3>${esc(o.title)} <small>${esc(o.board)}</small></h3><ol class="board-rows">${o.rows.slice(0, 5)@@@ -209,6 +210,7 @@ function boardCard(o: {.join('')}</ol><a class="card-link" href="${esc(o.href)}">${esc(o.more)}</a></article>`++ return { label: o.title, html }}function boardsSection(d: HomeData): string {@@@ -239,12 +241,20 @@ function boardsSection(d: HomeData): string {href: '/play/vibe-heroes',more: 'Build your team →',}),-- ].filter(Boolean)++ ].filter((c): c is { label: string; html: string } => c !== null)if (!cards.length) return ''-- return `<section class="home-sec" aria-labelledby="home-boards">++ // The boards are blocks of the same kind: on a phone a row of chips picks one of them and the chip names it, so++ // one board is read at a time. Every board stays in the page.++ const panes = cards.map((c, i) => ({ id: `board-${i}`, label: c.label, html: c.html }))++ const rules = pickRules(++ 'boards',++ panes.map((p) => p.id),++ )++ return `${pickStyle([rules])}++<section class="home-sec home-pick-sec" aria-labelledby="home-boards"><h2 id="home-boards">Top players</h2><p class="home-lede">The servers score every round, so these boards are what really happened. Signed-in players get their name on them.</p>-- <div class="board-grid">${cards.join('')}</div>++ ${pickBlock({ key: 'boards', label: 'The boards', head: 'Top players', panes })}</section>`}@@@ -252,8 +262,11 @@ function achievementsSection(d: HomeData): string {const all = d.titles.flatMap((t) => t.achievements.map((a) => ({ ...a, title: t })))if (!all.length) return ''all.sort((a, b) => b.unlockedBy - a.unlockedBy || a.title.name.localeCompare(b.title.name))-- return `<section class="home-sec" aria-labelledby="home-achievements">-- <h2 id="home-achievements">Achievements to earn</h2>++ return foldBlock({++ id: 'home-achievements',++ head: '<h2 id="home-achievements">Achievements to earn</h2>',++ html: `<section class="home-sec" aria-labelledby="home-achievements">++ <h2>Achievements to earn</h2><p class="home-lede">Every achievement is kept on OpenVibe.Play with the number of players who earned it. Some give an item for your OpenVibe inventory.</p><ul class="ach-grid">${all.slice(0, 8)@@@ -273,7 +286,8 @@ function achievementsSection(d: HomeData): string {`<a class="card-link" href="${PLAY_SITE}/t/${encodeURIComponent(t.id)}">${esc(t.name)} on OpenVibe.Play →</a>`,).join(' ')}</p>--</section>`++</section>`,++ })}function worldCard(w: CreatorWorld, now: number): string {@@@ -367,17 +381,25 @@ const HOW: readonly { title: string; text: string }[] = []function howSection(): string {-- return `<section class="home-sec" aria-labelledby="home-how">-- <h2 id="home-how">How it works</h2>++ return foldBlock({++ id: 'home-how',++ head: '<h2 id="home-how">How it works</h2>',++ html: `<section class="home-sec" aria-labelledby="home-how">++ <h2>How it works</h2><ol class="how-steps">${HOW.map((s) => `<li><b>${esc(s.title)}</b><span>${esc(s.text)}</span></li>`).join('')}</ol>--</section>`++</section>`,++ })}function faqSection(): string {-- return `<section class="home-sec" aria-labelledby="home-faq">-- <h2 id="home-faq">Questions</h2>++ return foldBlock({++ id: 'home-faq',++ head: '<h2 id="home-faq">Questions</h2>',++ html: `<section class="home-sec" aria-labelledby="home-faq">++ <h2>Questions</h2><div class="faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div>--</section>`++</section>`,++ })}/** The home page's JSON-LD beyond the site and game nodes discovery.ts already writes: the games and the FAQ. */nnew file mode 100644@@@ -0,0 +1,64 @@++import { describe, expect, it } from 'vitest'++import { readFileSync } from 'node:fs'++import { join } from 'node:path'++import { fileURLToPath } from 'node:url'++import { foldBlock, pickBlock, pickRules, pickStyle } from './shortHome.js'++++const root = fileURLToPath(new URL('../../../..', import.meta.url))++++/**++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the blocks of the same kind 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 block stays in the page for crawlers; a wider screen shows them all and no chips.++ */++describe('the home on a phone', () => {++ it('the boards are one pick block: a chip per board, the first one picked', () => {++ const panes = [++ { id: 'board-0', label: 'Scraplandia', html: '<article class="board-card">a</article>' },++ { id: 'board-1', label: 'Salvage Rush', html: '<article class="board-card">b</article>' },++ ]++ const html = pickBlock({ key: 'boards', label: 'The boards', head: 'Top players', panes })++ expect(html).toContain('<div class="pick" id="pick-boards">')++ expect(html).toContain('<div class="pick-row" role="radiogroup" aria-label="The boards">')++ const chips = [++ ...html.matchAll(++ /<input type="radio" name="pick-boards" id="pk-boards-([a-z0-9-]+)" value="\1"( checked)?><label for="pk-boards-\1">([^<]+)<\/label>/g,++ ),++ ]++ expect(chips.map((m) => m[1])).toEqual(['board-0', 'board-1'])++ expect(chips.filter((m) => m[2]).map((m) => m[1])).toEqual(['board-0'])++ expect((html.match(/class="pick-pane"/g) ?? []).length).toBe(2)++ // Every board stays in the page for the crawlers.++ expect(html).toContain('board-card">a')++ expect(html).toContain('board-card">b')++ // The rule that shows the picked board is written for this page.++ expect(pickRules('boards', ['board-0', 'board-1'])).toBe(++ '.pick:has(#pk-boards-board-0:checked) #pp-boards-board-0{display:block}.pick:has(#pk-boards-board-1:checked) #pp-boards-board-1{display:block}',++ )++ expect(pickStyle([pickRules('boards', ['board-0'])])).toContain('@media (max-width:640px)')++ })++++ it('a fold is open in the page and closed on a phone by its own script', () => {++ const html = foldBlock({++ id: 'home-faq',++ head: '<h2 id="home-faq">Questions</h2>',++ html: '<p>the answers</p>',++ })++ expect(html).toContain('<details class="gm-fold gm-fold-block" id="home-faq" open>')++ expect(html).toContain(++ '<summary><h2 id="home-faq">Questions</h2><span>tap to open</span></summary>',++ )++ expect(html).toContain('the answers')++ expect(html).toContain('document.getElementById("home-faq")')++ expect(html).toContain("matchMedia('(max-width:640px)')")++ })++++ it('the CSS guard: the chips are hidden and the blocks all show until 640 px and where :has() is understood', () => {++ const css = readFileSync(join(root, 'apps/client/index.html'), 'utf8')++ expect(css).toMatch(/\.pick-row \{ display: none; \}/)++ expect(css).toMatch(/@supports selector\(:has\(\*\)\) \{/)++ expect(css).toMatch(/@media \(max-width: 640px\) \{/)++ expect(css).toMatch(/\.pick-row label \{[\s\S]*?min-height: 44px/)++ expect(css).toMatch(/\.pick-pane \{ display: none; \}/)++ })++})nnew file mode 100644@@@ -0,0 +1,72 @@++/**++ * 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 apps/client/index.html; the rule that shows the picked block is written for the page,++ * because there is one per block.++ */++import { escapeHtml } from './notFound.js'++++const esc = (v: unknown): string => escapeHtml(String(v ?? ''))++++export interface PickPane {++ id: string++ label: string++ html: string++}++++/**++ * A pick block. The panes are the page's own blocks; the block's heading gives way to the chip that names it on a++ * phone, and the chips are gone on a wide screen, where every pane shows at once.++ */++export function pickBlock(o: {++ key: string++ label: string++ head?: string++ panes: PickPane[]++}): string {++ const panes = o.panes ?? []++ if (!panes.length) return ''++ const chips = panes++ .map(++ (p, i) =>++ `<input type="radio" name="pick-${esc(o.key)}" id="pk-${esc(o.key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(o.key)}-${esc(p.id)}">${esc(p.label)}</label>`,++ )++ .join('')++ const body = panes++ .map((p) => `<div class="pick-pane" id="pp-${esc(o.key)}-${esc(p.id)}">${p.html}</div>`)++ .join('')++ const heading = `<h2>${esc(o.head || o.label)}</h2>`++ return `<div class="pick" id="pick-${esc(o.key)}"><div class="pick-bar"><div class="pick-head">${heading}</div><div class="pick-row" role="radiogroup" aria-label="${esc(o.label)}">${chips}</div></div><div class="pick-body">${body}</div></div>`++}++++/** The rules for every pane of a pick block, in the order they appear: one rule per pane, so the CSS is written for the page. */++export function pickRules(key: string, ids: string[]): string {++ return ids++ .map((id) => `.pick:has(#pk-${key}-${id}:checked) #pp-${key}-${id}{display:block}`)++ .join('')++}++++/** The rule that shows the picked block, as a style tag the page carries. */++export function pickStyle(rules: string[]): string {++ if (!rules.length) return ''++ return `<style>@supports selector(:has(*)){@media (max-width:640px){${rules.join('')}}}</style>`++}++++/**++ * A fold. The head is the heading the block had; the html is what it holds.++ */++export function foldBlock(o: { id: string; head: string; html: string }): string {++ return `<details class="gm-fold gm-fold-block" id="${esc(o.id)}" open><summary>${o.head}<span>tap to open</span></summary>${o.html}</details>${foldScript(o.id)}`++}++++/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */++export function foldScript(id: string): string {++ 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>`++}nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/net/shortHome.test.ts'++// covers: 'apps/server/src/net'++// Generated by node test/run.js --index; do not edit.
@@ -397,6 +397,83 @@
@@ -397,6 +397,83 @@
box-shadow: 0 8px 48px rgba(var(--accent-rgb, 59, 130, 246), 0.7);
box-shadow: 0 8px 48px rgba(var(--accent-rgb, 59, 130, 246), 0.7);
}
}
}
}
+ /* ── the home on a phone (the owner's short-pages rule, 2026-10-30) ──
+ Pick: on a phone the home's blocks of the same kind are one at a time, picked with a row of chips.
+ Radio inputs and :has(), no script: the chips and the one-block rule live under 640 px and only where
+ :has() is understood, so a wider screen, or an older browser, shows every block and no chips. */
+ .pick-head { display: none; }
+ .pick-row { display: none; }
+ .pick-pane { min-width: 0; }
+ @supports selector(:has(*)) {
+ @media (max-width: 640px) {
+ .pick-head { display: block; }
+ .pick-head h2 { margin: 0 0 4px; font-size: 1.25rem; }
+ .pick-row {
+ display: flex;
+ gap: 8px;
+ overflow-x: auto;
+ margin: 10px -20px 0;
+ padding: 2px 20px 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);
+ color: var(--text-dim);
+ font-size: 0.9rem;
+ font-weight: 650;
+ white-space: nowrap;
+ cursor: pointer;
+ }
+ .pick-row input:checked + label {
+ color: var(--text);
+ border-color: var(--accent-light);
+ background: rgba(var(--accent-rgb, 59, 130, 246), 0.12);
+ }
+ .pick-row input:focus-visible + label { outline: 2px solid var(--accent-light); outline-offset: 2px; }
+ .pick-pane { display: none; }
+ /* The chip names the block, so the block's own heading gives way to it. */
+ .home-pick-sec > h2 { display: none; }
+ }
+ }
+ /* The fold: closed on a phone, and where there is room it looks like the section it replaces. */
+ .gm-fold-block { margin: 0 auto 40px; max-width: 1100px; padding: 0 20px; }
+ .gm-fold-block > summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ min-height: 48px;
+ padding: 0 16px;
+ cursor: pointer;
+ color: var(--text);
+ font-weight: 700;
+ list-style: none;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--bg-card);
+ }
+ .gm-fold-block > summary::-webkit-details-marker { display: none; }
+ .gm-fold-block > summary h2 { margin: 0; padding: 0; border: 0; font-size: 1.05rem; }
+ .gm-fold-block > summary span { color: var(--text-dim); font-weight: 600; font-size: 0.86rem; text-align: right; }
+ .gm-fold-block > summary:focus-visible { outline: 2px solid var(--accent-light); outline-offset: -2px; border-radius: 14px; }
+ .gm-fold-block .home-sec { margin: 12px 0 0; padding: 0; }
+ .gm-fold-block .home-sec > h2 { display: none; }
+ .gm-fold-block .ov-shipped-home { margin: 12px 0 0 !important; padding: 0 !important; }
+ @media (min-width: 641px) {
+ .gm-fold-block { border: 0; background: none; margin-top: 40px; padding: 0 20px; }
+ .gm-fold-block > summary { display: block; min-height: 0; padding: 0; border: 0; background: none; }
+ .gm-fold-block > summary h2 { margin: 0 0 6px; font-size: 1.25rem; border-bottom: 1px solid var(--border); padding-bottom: 0.2em; }
+ .gm-fold-block > summary span { display: none; }
+ }
/* ── live sections (net/portalHome.ts renders them on the server) ── */
/* ── live sections (net/portalHome.ts renders them on the server) ── */
.home-sec {
.home-sec {
max-width: 1100px;
max-width: 1100px;
@@ -1244,7 +1321,10 @@
@@ -1244,7 +1321,10 @@
<!-- What shipped on OpenVibe.Games: the network's shared "shipped" views (openvibe-shared
<!-- What shipped on OpenVibe.Games: the network's shared "shipped" views (openvibe-shared
- shipped.js, which the shared footer loads), the same on every OpenVibe site. -->
+ shipped.js, which the shared footer loads), the same on every OpenVibe site. A fold on a phone:
+ closed there, open where there is room, and the list stays in the page. -->
+ <details class="gm-fold gm-fold-block" id="home-shipped" open>
+ <summary><h2>Recently shipped on OpenVibe.Games</h2><span>tap to open</span></summary>
<section
<section
class="ov-shipped-home"
class="ov-shipped-home"
aria-label="What shipped"
aria-label="What shipped"
@@ -1270,6 +1350,8 @@
@@ -1270,6 +1350,8 @@
</p></noscript
</p></noscript
>
>
</section>
</section>
+ </details>
+ <script>(function(){var d=document.getElementById("home-shipped");if(!d)return;var k='ov_fold_'+'home-shipped';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>
<!-- Shared cross-site footer (/shared/footer.js, this site's pinned copy): network links, legal, signed-in row. -->
<!-- Shared cross-site footer (/shared/footer.js, this site's pinned copy): network links, legal, signed-in row. -->
<footer id="ov-footer" class="ovf"></footer>
<footer id="ov-footer" class="ovf"></footer>
@@ -4,6 +4,7 @@ import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
@@ -4,6 +4,7 @@ import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
import type { CreatorWorld } from '@openvibe/persistence'
import type { CreatorWorld } from '@openvibe/persistence'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
import { escapeHtml } from './notFound.js'
import { escapeHtml } from './notFound.js'
+import { foldBlock, pickBlock, pickRules, pickStyle } from './shortHome.js'
import type { SteamGame } from '../steam/steamCatalog.js'
import type { SteamGame } from '../steam/steamCatalog.js'
/**
/**
@@ -196,9 +197,9 @@ function boardCard(o: {
@@ -196,9 +197,9 @@ function boardCard(o: {
unit: (score: number) => string
unit: (score: number) => string
href: string
href: string
more: string
more: string
-}): string {
- if (!o.rows.length) return ''
- return `<article class="board-card">
+}): { label: string; html: string } | null {
+ if (!o.rows.length) return null
+ const html = `<article class="board-card">
<h3>${esc(o.title)} <small>${esc(o.board)}</small></h3>
<h3>${esc(o.title)} <small>${esc(o.board)}</small></h3>
<ol class="board-rows">${o.rows
<ol class="board-rows">${o.rows
.slice(0, 5)
.slice(0, 5)
@@ -209,6 +210,7 @@ function boardCard(o: {
@@ -209,6 +210,7 @@ function boardCard(o: {
.join('')}</ol>
.join('')}</ol>
<a class="card-link" href="${esc(o.href)}">${esc(o.more)}</a>
<a class="card-link" href="${esc(o.href)}">${esc(o.more)}</a>
</article>`
</article>`
+ return { label: o.title, html }
}
}
function boardsSection(d: HomeData): string {
function boardsSection(d: HomeData): string {
@@ -239,12 +241,20 @@ function boardsSection(d: HomeData): string {
@@ -239,12 +241,20 @@ function boardsSection(d: HomeData): string {
href: '/play/vibe-heroes',
href: '/play/vibe-heroes',
more: 'Build your team →',
more: 'Build your team →',
}),
}),
- ].filter(Boolean)
+ ].filter((c): c is { label: string; html: string } => c !== null)
if (!cards.length) return ''
if (!cards.length) return ''
- return `<section class="home-sec" aria-labelledby="home-boards">
+ // The boards are blocks of the same kind: on a phone a row of chips picks one of them and the chip names it, so
+ // one board is read at a time. Every board stays in the page.
+ const panes = cards.map((c, i) => ({ id: `board-${i}`, label: c.label, html: c.html }))
+ const rules = pickRules(
+ 'boards',
+ panes.map((p) => p.id),
+ )
+ return `${pickStyle([rules])}
+<section class="home-sec home-pick-sec" aria-labelledby="home-boards">
<h2 id="home-boards">Top players</h2>
<h2 id="home-boards">Top players</h2>
<p class="home-lede">The servers score every round, so these boards are what really happened. Signed-in players get their name on them.</p>
<p class="home-lede">The servers score every round, so these boards are what really happened. Signed-in players get their name on them.</p>
- <div class="board-grid">${cards.join('')}</div>
+ ${pickBlock({ key: 'boards', label: 'The boards', head: 'Top players', panes })}
</section>`
</section>`
}
}
@@ -252,8 +262,11 @@ function achievementsSection(d: HomeData): string {
@@ -252,8 +262,11 @@ function achievementsSection(d: HomeData): string {
const all = d.titles.flatMap((t) => t.achievements.map((a) => ({ ...a, title: t })))
const all = d.titles.flatMap((t) => t.achievements.map((a) => ({ ...a, title: t })))
if (!all.length) return ''
if (!all.length) return ''
all.sort((a, b) => b.unlockedBy - a.unlockedBy || a.title.name.localeCompare(b.title.name))
all.sort((a, b) => b.unlockedBy - a.unlockedBy || a.title.name.localeCompare(b.title.name))
- return `<section class="home-sec" aria-labelledby="home-achievements">
- <h2 id="home-achievements">Achievements to earn</h2>
+ return foldBlock({
+ id: 'home-achievements',
+ head: '<h2 id="home-achievements">Achievements to earn</h2>',
+ html: `<section class="home-sec" aria-labelledby="home-achievements">
+ <h2>Achievements to earn</h2>
<p class="home-lede">Every achievement is kept on OpenVibe.Play with the number of players who earned it. Some give an item for your OpenVibe inventory.</p>
<p class="home-lede">Every achievement is kept on OpenVibe.Play with the number of players who earned it. Some give an item for your OpenVibe inventory.</p>
<ul class="ach-grid">${all
<ul class="ach-grid">${all
.slice(0, 8)
.slice(0, 8)
@@ -273,7 +286,8 @@ function achievementsSection(d: HomeData): string {
@@ -273,7 +286,8 @@ function achievementsSection(d: HomeData): string {
`<a class="card-link" href="${PLAY_SITE}/t/${encodeURIComponent(t.id)}">${esc(t.name)} on OpenVibe.Play →</a>`,
`<a class="card-link" href="${PLAY_SITE}/t/${encodeURIComponent(t.id)}">${esc(t.name)} on OpenVibe.Play →</a>`,
)
)
.join(' ')}</p>
.join(' ')}</p>
-</section>`
+</section>`,
+ })
}
}
function worldCard(w: CreatorWorld, now: number): string {
function worldCard(w: CreatorWorld, now: number): string {
@@ -367,17 +381,25 @@ const HOW: readonly { title: string; text: string }[] = [
@@ -367,17 +381,25 @@ const HOW: readonly { title: string; text: string }[] = [
]
]
function howSection(): string {
function howSection(): string {
- return `<section class="home-sec" aria-labelledby="home-how">
- <h2 id="home-how">How it works</h2>
+ return foldBlock({
+ id: 'home-how',
+ head: '<h2 id="home-how">How it works</h2>',
+ html: `<section class="home-sec" aria-labelledby="home-how">
+ <h2>How it works</h2>
<ol class="how-steps">${HOW.map((s) => `<li><b>${esc(s.title)}</b><span>${esc(s.text)}</span></li>`).join('')}</ol>
<ol class="how-steps">${HOW.map((s) => `<li><b>${esc(s.title)}</b><span>${esc(s.text)}</span></li>`).join('')}</ol>
-</section>`
+</section>`,
+ })
}
}
function faqSection(): string {
function faqSection(): string {
- return `<section class="home-sec" aria-labelledby="home-faq">
- <h2 id="home-faq">Questions</h2>
+ return foldBlock({
+ id: 'home-faq',
+ head: '<h2 id="home-faq">Questions</h2>',
+ html: `<section class="home-sec" aria-labelledby="home-faq">
+ <h2>Questions</h2>
<div class="faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div>
<div class="faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div>
-</section>`
+</section>`,
+ })
}
}
/** The home page's JSON-LD beyond the site and game nodes discovery.ts already writes: the games and the FAQ. */
/** The home page's JSON-LD beyond the site and game nodes discovery.ts already writes: the games and the FAQ. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { foldBlock, pickBlock, pickRules, pickStyle } from './shortHome.js'
+
+const root = fileURLToPath(new URL('../../../..', import.meta.url))
+
+/**
+ * The home on a phone (the owner's short-pages rule, 2026-10-30): the blocks of the same kind 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 block stays in the page for crawlers; a wider screen shows them all and no chips.
+ */
+describe('the home on a phone', () => {
+ it('the boards are one pick block: a chip per board, the first one picked', () => {
+ const panes = [
+ { id: 'board-0', label: 'Scraplandia', html: '<article class="board-card">a</article>' },
+ { id: 'board-1', label: 'Salvage Rush', html: '<article class="board-card">b</article>' },
+ ]
+ const html = pickBlock({ key: 'boards', label: 'The boards', head: 'Top players', panes })
+ expect(html).toContain('<div class="pick" id="pick-boards">')
+ expect(html).toContain('<div class="pick-row" role="radiogroup" aria-label="The boards">')
+ const chips = [
+ ...html.matchAll(
+ /<input type="radio" name="pick-boards" id="pk-boards-([a-z0-9-]+)" value="\1"( checked)?><label for="pk-boards-\1">([^<]+)<\/label>/g,
+ ),
+ ]
+ expect(chips.map((m) => m[1])).toEqual(['board-0', 'board-1'])
+ expect(chips.filter((m) => m[2]).map((m) => m[1])).toEqual(['board-0'])
+ expect((html.match(/class="pick-pane"/g) ?? []).length).toBe(2)
+ // Every board stays in the page for the crawlers.
+ expect(html).toContain('board-card">a')
+ expect(html).toContain('board-card">b')
+ // The rule that shows the picked board is written for this page.
+ expect(pickRules('boards', ['board-0', 'board-1'])).toBe(
+ '.pick:has(#pk-boards-board-0:checked) #pp-boards-board-0{display:block}.pick:has(#pk-boards-board-1:checked) #pp-boards-board-1{display:block}',
+ )
+ expect(pickStyle([pickRules('boards', ['board-0'])])).toContain('@media (max-width:640px)')
+ })
+
+ it('a fold is open in the page and closed on a phone by its own script', () => {
+ const html = foldBlock({
+ id: 'home-faq',
+ head: '<h2 id="home-faq">Questions</h2>',
+ html: '<p>the answers</p>',
+ })
+ expect(html).toContain('<details class="gm-fold gm-fold-block" id="home-faq" open>')
+ expect(html).toContain(
+ '<summary><h2 id="home-faq">Questions</h2><span>tap to open</span></summary>',
+ )
+ expect(html).toContain('the answers')
+ expect(html).toContain('document.getElementById("home-faq")')
+ expect(html).toContain("matchMedia('(max-width:640px)')")
+ })
+
+ it('the CSS guard: the chips are hidden and the blocks all show until 640 px and where :has() is understood', () => {
+ const css = readFileSync(join(root, 'apps/client/index.html'), 'utf8')
+ expect(css).toMatch(/\.pick-row \{ display: none; \}/)
+ expect(css).toMatch(/@supports selector\(:has\(\*\)\) \{/)
+ expect(css).toMatch(/@media \(max-width: 640px\) \{/)
+ expect(css).toMatch(/\.pick-row label \{[\s\S]*?min-height: 44px/)
+ expect(css).toMatch(/\.pick-pane \{ display: none; \}/)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+/**
+ * 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 apps/client/index.html; the rule that shows the picked block is written for the page,
+ * because there is one per block.
+ */
+import { escapeHtml } from './notFound.js'
+
+const esc = (v: unknown): string => escapeHtml(String(v ?? ''))
+
+export interface PickPane {
+ id: string
+ label: string
+ html: string
+}
+
+/**
+ * A pick block. The panes are the page's own blocks; the block's heading gives way to the chip that names it on a
+ * phone, and the chips are gone on a wide screen, where every pane shows at once.
+ */
+export function pickBlock(o: {
+ key: string
+ label: string
+ head?: string
+ panes: PickPane[]
+}): string {
+ const panes = o.panes ?? []
+ if (!panes.length) return ''
+ const chips = panes
+ .map(
+ (p, i) =>
+ `<input type="radio" name="pick-${esc(o.key)}" id="pk-${esc(o.key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(o.key)}-${esc(p.id)}">${esc(p.label)}</label>`,
+ )
+ .join('')
+ const body = panes
+ .map((p) => `<div class="pick-pane" id="pp-${esc(o.key)}-${esc(p.id)}">${p.html}</div>`)
+ .join('')
+ const heading = `<h2>${esc(o.head || o.label)}</h2>`
+ return `<div class="pick" id="pick-${esc(o.key)}"><div class="pick-bar"><div class="pick-head">${heading}</div><div class="pick-row" role="radiogroup" aria-label="${esc(o.label)}">${chips}</div></div><div class="pick-body">${body}</div></div>`
+}
+
+/** The rules for every pane of a pick block, in the order they appear: one rule per pane, so the CSS is written for the page. */
+export function pickRules(key: string, ids: string[]): string {
+ return ids
+ .map((id) => `.pick:has(#pk-${key}-${id}:checked) #pp-${key}-${id}{display:block}`)
+ .join('')
+}
+
+/** The rule that shows the picked block, as a style tag the page carries. */
+export function pickStyle(rules: string[]): string {
+ if (!rules.length) return ''
+ return `<style>@supports selector(:has(*)){@media (max-width:640px){${rules.join('')}}}</style>`
+}
+
+/**
+ * A fold. The head is the heading the block had; the html is what it holds.
+ */
+export function foldBlock(o: { id: string; head: string; html: string }): string {
+ return `<details class="gm-fold gm-fold-block" id="${esc(o.id)}" open><summary>${o.head}<span>tap to open</span></summary>${o.html}</details>${foldScript(o.id)}`
+}
+
+/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */
+export function foldScript(id: string): string {
+ 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>`
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/net/shortHome.test.ts'
+// covers: 'apps/server/src/net'
+// Generated by node test/run.js --index; do not edit.