openvibers/openvibe.games Commits

Home: every board shows on a phone; the chips are links, not a picker (#251)

OP OpenVibers committed 11 days ago · 77a25cb537 · 4 files, +55 −98

Changes

ddiff --git a/apps/client/index.html b/apps/client/index.html
iindex e903f65..c436afd 100644
---- a/apps/client/index.html
++++ b/apps/client/index.html
@@@ -397,52 +397,41 @@
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. */
++ /* ── the home on a phone ──
++ Pick: every board is shown, stacked; on a phone the chips are a row of links that jump to one (the owner's
++ rule, 2026-10-31: never hide content to shorten). The section has its own heading, so the pick's stays off. */
.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; }
++ @media (max-width: 640px) {
++ .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-chip {
++ 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;
++ text-decoration: none;
++ }
++ .pick-chip:hover, .pick-chip:focus-visible { color: var(--text); border-color: var(--accent-light); }
++ .pick-chip:focus-visible { outline: 2px solid var(--accent-light); outline-offset: 2px; }
++ .pick .pick-pane { scroll-margin-top: 72px; }
++ .pick .pick-pane + .pick-pane { margin-top: 18px; }
}
/* 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; }
ddiff --git a/apps/server/src/net/portalHome.ts b/apps/server/src/net/portalHome.ts
iindex 6f46e8f..2d88c5e 100644
---- a/apps/server/src/net/portalHome.ts
++++ b/apps/server/src/net/portalHome.ts
@@@ -4,7 +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 { foldBlock, pickBlock } from './shortHome.js'
import type { SteamGame } from '../steam/steamCatalog.js'
/**
@@@ -266,15 +266,10 @@ function boardsSection(d: HomeData): string {
}),
].filter((c): c is { label: string; html: string } => c !== null)
if (!cards.length) return ''
-- // 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.
++ // The boards are blocks of the same kind: every board shown, and on a phone a row of chips jumps to one (the owner,
++ // 2026-10-31: never hide content to shorten).
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">
++ return `<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>
${pickBlock({ key: 'boards', label: 'The boards', head: 'Top players', panes })}
ddiff --git a/apps/server/src/net/shortHome.test.ts b/apps/server/src/net/shortHome.test.ts
iindex 622c6c2..5b5fd28 100644
---- a/apps/server/src/net/shortHome.test.ts
++++ b/apps/server/src/net/shortHome.test.ts
@@@ -2,40 +2,31 @@ 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'
++import { foldBlock, pickBlock } 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.
++ * The home on a phone: the blocks of the same kind are one pick block, every block shown, and a row of chips jumps to
++ * one (the owner, 2026-10-31: never hide content to shorten); the parts a reader rarely needs are folds. 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', () => {
++ it('the boards are one pick block: every board shown, a chip per board that jumps to it', () => {
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).toContain('<nav class="pick-row" aria-label="The boards">')
++ const chips = [...html.matchAll(/<a class="pick-chip" href="#(pp-boards-[a-z0-9-]+)">([^<]+)<\/a>/g)]
++ expect(chips.map((m) => m[1])).toEqual(['pp-boards-board-0', 'pp-boards-board-1'])
++ for (const [, id] of chips) expect(html).toContain(`<div class="pick-pane" id="${id}">`)
++ expect(html).not.toMatch(/type="radio"/)
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', () => {
@@@ -53,12 +44,11 @@ describe('the home on a phone', () => {
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', () => {
++ it('the CSS: no chips where there is room, chip links on a phone, and no board is ever hidden', () => {
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; \}/)
++ expect(css).toMatch(/\.pick-chip \{[\s\S]*?min-height: 44px/)
++ expect(css).not.toMatch(/\.pick-pane \{ display: none; \}/)
})
})
ddiff --git a/apps/server/src/net/shortHome.ts b/apps/server/src/net/shortHome.ts
iindex 3e12e2d..4e5705c 100644
---- a/apps/server/src/net/shortHome.ts
++++ b/apps/server/src/net/shortHome.ts
@@@ -1,15 +1,13 @@
/**
* 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.
++ * pickBlock() several blocks of the same kind, all shown, each with its own heading; on a phone the chips are a
++ * row of links that jump to them (the owner, 2026-10-31: never hide content to shorten).
* 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.
++ * The CSS for both lives in apps/client/index.html.
*/
import { escapeHtml } from './notFound.js'
@@@ -22,8 +20,8 @@ export interface PickPane {
}
/**
-- * 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.
++ * A pick block. The panes are the page's own blocks, every one shown; on a phone the chips jump to them, and on a wide
++ * screen there are no chips.
*/
export function pickBlock(o: {
key: string
@@@ -34,30 +32,15 @@ export function pickBlock(o: {
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>`,
-- )
++ .map((p) => `<a class="pick-chip" href="#pp-${esc(o.key)}-${esc(p.id)}">${esc(p.label)}</a>`)
.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>`
++ return `<div class="pick" id="pick-${esc(o.key)}"><div class="pick-bar"><div class="pick-head">${heading}</div><nav class="pick-row" aria-label="${esc(o.label)}">${chips}</nav></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.