openvibers/openvibe.games Commits

The home on a phone: the boards are one pick block, the rest are folds (#215)

OP OpenVibers committed 14 days ago · 885775ae1d · 5 files, +259 −16

Changes

ddiff --git a/apps/client/index.html b/apps/client/index.html
iindex 1b84044..f76bd22 100644
---- a/apps/client/index.html
++++ b/apps/client/index.html
@@@ -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>
<section
class="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>
ddiff --git a/apps/server/src/net/portalHome.ts b/apps/server/src/net/portalHome.ts
iindex df6df71..f7d6831 100644
---- a/apps/server/src/net/portalHome.ts
++++ b/apps/server/src/net/portalHome.ts
@@@ -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) => string
href: 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>
<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. */
ddiff --git a/apps/server/src/net/shortHome.test.ts b/apps/server/src/net/shortHome.test.ts
nnew file mode 100644
iindex 0000000..622c6c2
---- /dev/null
++++ b/apps/server/src/net/shortHome.test.ts
@@@ -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; \}/)
++ })
++})
ddiff --git a/apps/server/src/net/shortHome.ts b/apps/server/src/net/shortHome.ts
nnew file mode 100644
iindex 0000000..3e12e2d
---- /dev/null
++++ b/apps/server/src/net/shortHome.ts
@@@ -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>`
++}
ddiff --git a/test/apps__server__src__net__shortHome.test.js b/test/apps__server__src__net__shortHome.test.js
nnew file mode 100644
iindex 0000000..030a5a9
---- /dev/null
++++ b/test/apps__server__src__net__shortHome.test.js
@@@ -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.