openvibers/openvibe.games Commits

Games: Vibe Heroes passes the browser check (no prohibited aria-labels; the game described without JavaScript) (#60)

OP OpenVibers committed 30 days ago · a8ea17895d · 8 files, +64 −12

The browser check (`ovhost browser-watch --sites games`) failed two checks on the new `/play/vibe-heroes` page (Games#58).

**axe `aria-prohibited-attr` (serious)**
- `aria-label` sat on plain spans and divs. Those are now an image or a progress bar, or name themselves in hidden text:
  - the stars, the shard bars, the chapter track and the empty team slot have `role="img"`;
  - quest progress is a `progressbar` with its value and maximum;
  - the wallet and energy chips and the tabs' red dots carry visually hidden text (`.sr`) instead of a label a div cannot have.

**no-JS check (fewer than 200 characters of visible text)**
- The page's splash is now an `<h1>` with the game's name.
- Its `<noscript>` holds what the game is, how it plays and a link to the other games.
- The server fills both in for the requested game (`<!--casual:name-->`, `<!--casual:about-->`), as it already does for the share card. A crawler reads the same text.

**Tests**
- `casual` checks the `<h1>` and more than 200 characters inside `<noscript>`.
- The browser harness (openvibe-shared) on a local build passes axe and no-JS on `/play/vibe-heroes` and `/`. The only errors left come from the local origin (CORS to openvibe.network).

Changes

ddiff --git a/apps/client/casual.html b/apps/client/casual.html
iindex 717eed0..587521c 100644
---- a/apps/client/casual.html
++++ b/apps/client/casual.html
@@@ -47,8 +47,13 @@
margin: auto;
text-align: center;
padding: 24px;
++ max-width: 420px;
++ }
++ .splash a {
++ color: #ffd25a;
}
.splash-logo {
++ margin: 0;
font-size: 40px;
font-weight: 900;
letter-spacing: 0.02em;
@@@ -83,10 +88,10 @@
<div id="navbar-mount"></div>
<main id="cg" class="cg">
<div class="splash">
-- <div class="splash-logo">Vibe Heroes</div>
++ <h1 class="splash-logo"><!--casual:name--></h1>
<div class="splash-ring" aria-hidden="true"></div>
<p>Summoning your heroes…</p>
-- <noscript><p>Vibe Heroes needs JavaScript to play.</p></noscript>
++ <noscript><!--casual:about--></noscript>
</div>
</main>
<footer id="ov-footer" class="ovf"></footer>
ddiff --git a/apps/client/src/casual/app.ts b/apps/client/src/casual/app.ts
iindex 4456e4e..08e2361 100644
---- a/apps/client/src/casual/app.ts
++++ b/apps/client/src/casual/app.ts
@@@ -114,11 +114,16 @@ export class App {
private renderTop(): void {
const s = this.state
const p = this.pack
++ // Each chip names its currency for screen readers in hidden text (a label on a plain div is not read).
const chip = (name: string, value: Node | string, label: string, cls = '') =>
h(
'div',
-- { class: `chip ${cls}`, attrs: { 'aria-label': label }, title: label },
++ { class: `chip ${cls}`, title: label },
icon(name),
++ h('span', {
++ class: 'sr',
++ text: `${name === 'gems' ? 'Gems' : name === 'gold' ? 'Gold' : 'Sigils'}: `,
++ }),
h('b', null, value),
)
const energy = h('b')
@@@ -142,10 +147,10 @@ export class App {
'div',
{
class: 'chip chip-energy',
-- attrs: { 'aria-label': 'Energy' },
title: 'Energy: one point every five minutes',
},
icon('energy'),
++ h('span', { class: 'sr', text: 'Energy: ' }),
h('span', { class: 'chip-stack' }, energy, energyTimer),
),
button(icon('menu'), () => this.menu(this), 'icon-btn top-menu', { 'aria-label': 'Menu' }),
@@@ -168,7 +173,7 @@ export class App {
icon(t.icon),
h('span', { text: t.label }),
dots[t.id]
-- ? h('i', { class: 'dot', attrs: { 'aria-label': 'Something is ready' } })
++ ? h('i', { class: 'dot' }, h('span', { class: 'sr', text: ' (something is ready)' }))
: null,
],
() => this.go(t.id),
ddiff --git a/apps/client/src/casual/casual.css b/apps/client/src/casual/casual.css
iindex b11cf6f..903d9b4 100644
---- a/apps/client/src/casual/casual.css
++++ b/apps/client/src/casual/casual.css
@@@ -67,6 +67,14 @@
.cg.is-busy {
cursor: progress;
}
++.cg .sr {
++ position: absolute;
++ width: 1px;
++ height: 1px;
++ overflow: hidden;
++ clip-path: inset(50%);
++ white-space: nowrap;
++}
.boot-msg {
margin: auto;
padding: 24px;
ddiff --git a/apps/client/src/casual/views/home.ts b/apps/client/src/casual/views/home.ts
iindex b5e718d..6be0e5a 100644
---- a/apps/client/src/casual/views/home.ts
++++ b/apps/client/src/casual/views/home.ts
@@@ -86,7 +86,7 @@ export function homeView(app: App): HTMLElement {
'div',
{
class: 'chapter-track',
-- attrs: { 'aria-label': `Stage ${next} of ${pack.stages.length}` },
++ attrs: { role: 'img', 'aria-label': `Stage ${next} of ${pack.stages.length}` },
},
...Array.from({ length: 10 }, (_, i) =>
h('i', {
@@@ -132,7 +132,11 @@ export function homeView(app: App): HTMLElement {
strip.append(
def && hs
? heroCard(pack, def, hs, { small: true })
-- : h('div', { class: 'hero-card empty small', attrs: { 'aria-label': 'Empty slot' } }, '+'),
++ : h(
++ 'div',
++ { class: 'hero-card empty small', attrs: { role: 'img', 'aria-label': 'Empty slot' } },
++ '+',
++ ),
)
}
view.append(
ddiff --git a/apps/client/src/casual/views/parts.ts b/apps/client/src/casual/views/parts.ts
iindex 5ab0f03..0e673e5 100644
---- a/apps/client/src/casual/views/parts.ts
++++ b/apps/client/src/casual/views/parts.ts
@@@ -12,7 +12,7 @@ import { h, num } from '../dom.js'
/** A row of stars, filled up to `n` of `max`. */
export function stars(n: number, max = n): HTMLElement {
-- const row = h('span', { class: 'stars', attrs: { 'aria-label': `${n} stars` } })
++ const row = h('span', { class: 'stars', attrs: { role: 'img', 'aria-label': `${n} stars` } })
for (let i = 0; i < max; i++) row.append(icon('star', i < n ? 'ico star on' : 'ico star'))
return row
}
@@@ -37,7 +37,7 @@ export function heroCard(
card.append(
h(
'span',
-- { class: 'shard-bar', attrs: { 'aria-label': `${have} of ${need} shards` } },
++ { class: 'shard-bar', attrs: { role: 'img', 'aria-label': `${have} of ${need} shards` } },
h('i', { style: { width: `${Math.min(100, (have / need) * 100)}%` } }),
h('b', { text: `${have}/${need}` }),
),
ddiff --git a/apps/client/src/casual/views/quests.ts b/apps/client/src/casual/views/quests.ts
iindex 911b676..6a51d67 100644
---- a/apps/client/src/casual/views/quests.ts
++++ b/apps/client/src/casual/views/quests.ts
@@@ -96,7 +96,16 @@ function questRow(app: App, q: QuestDef): HTMLElement {
h('b', { text: q.text }),
h(
'span',
-- { class: 'progress', attrs: { 'aria-label': `${Math.min(p.n, q.target)} of ${q.target}` } },
++ {
++ class: 'progress',
++ attrs: {
++ role: 'progressbar',
++ 'aria-label': q.text,
++ 'aria-valuemin': '0',
++ 'aria-valuemax': String(q.target),
++ 'aria-valuenow': String(Math.min(p.n, q.target)),
++ },
++ },
h('i', { style: { width: `${Math.min(100, (p.n / q.target) * 100)}%` } }),
),
rewardChips(app.pack, q.reward),
ddiff --git a/apps/server/src/casual/casual.test.ts b/apps/server/src/casual/casual.test.ts
iindex 3628c56..73b42f0 100644
---- a/apps/server/src/casual/casual.test.ts
++++ b/apps/server/src/casual/casual.test.ts
@@@ -41,7 +41,7 @@ beforeAll(async () => {
mkdirSync(dist)
writeFileSync(
join(dist, 'casual.html'),
-- '<!doctype html><html><head><!--casual:head--></head><body><main id="app"></main></body></html>',
++ '<!doctype html><html><head><!--casual:head--></head><body><main id="app"><h1><!--casual:name--></h1><noscript><!--casual:about--></noscript></main></body></html>',
)
writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal</title>')
db = await openTestDb()
@@@ -274,6 +274,10 @@ describe('the casual page', () => {
expect(html).toContain('<meta name="twitter:card" content="summary_large_image" />')
expect(html).toContain('https://openvibe.games/casual/vibe-heroes/card.png')
expect(html).toContain('"@type":"WebApplication"')
++ // Without JavaScript the page still says what the game is (the browser check wants 200 characters).
++ expect(html).toContain('<h1>Vibe Heroes</h1>')
++ const about = /<noscript>([\s\S]*?)<\/noscript>/.exec(html)?.[1] ?? ''
++ expect(about.replace(/<[^>]+>/g, '').length).toBeGreaterThan(200)
const slash = await call('/play/vibe-heroes/?src=x&c=t1')
expect(slash.status).toBe(301)
expect(slash.body.location).toBe('/play/vibe-heroes?src=x&c=t1')
ddiff --git a/apps/server/src/casual/page.ts b/apps/server/src/casual/page.ts
iindex 1e112f8..b2f6899 100644
---- a/apps/server/src/casual/page.ts
++++ b/apps/server/src/casual/page.ts
@@@ -45,6 +45,19 @@ export function casualHead(id: string): string {
].join('\n ')
}
++/** What the page says without JavaScript (and what a crawler reads): the game, how it plays, and where to go. */
++export function casualAbout(id: string): string {
++ const pack = CASUAL_GAMES[id]
++ if (!pack) return ''
++ const name = esc(pack.name)
++ return [
++ `<p>${esc(pack.tagline)}</p>`,
++ `<p>${name} plays in your phone's browser with nothing to install, as a guest or with your OpenVibe account,`,
++ `and your team keeps earning while you are away. Turn on JavaScript to play.</p>`,
++ `<p><a href="/">More free browser games on OpenVibe.Games</a></p>`,
++ ].join(' ')
++}
++
let memo: { path: string; mtimeMs: number; raw: string } | null = null
/** The built page for one game (the file is read once per build). */
@@@ -53,5 +66,9 @@ export function casualPageHtml(filePath: string, id: string): string {
if (!memo || memo.path !== filePath || memo.mtimeMs !== mtimeMs) {
memo = { path: filePath, mtimeMs, raw: readFileSync(filePath, 'utf8') }
}
-- return memo.raw.replace(MARK, casualHead(id)).replace('<body>', `<body data-game="${esc(id)}">`)
++ return memo.raw
++ .replace(MARK, casualHead(id))
++ .replace('<body>', `<body data-game="${esc(id)}">`)
++ .replaceAll('<!--casual:name-->', esc(CASUAL_GAMES[id]?.name ?? ''))
++ .replace('<!--casual:about-->', casualAbout(id))
}