openvibers/openvibe.games Commits
Games: Vibe Heroes passes the browser check (no prohibited aria-labels; the game described without JavaScript) (#60)
Changes
@@@ -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>@@@ -114,11 +114,16 @@ export class App {private renderTop(): void {const s = this.stateconst 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),@@@ -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;@@@ -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(@@@ -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}` }),),@@@ -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),@@@ -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')@@@ -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))}
@@ -47,8 +47,13 @@
@@ -47,8 +47,13 @@
margin: auto;
margin: auto;
text-align: center;
text-align: center;
padding: 24px;
padding: 24px;
+ max-width: 420px;
+ }
+ .splash a {
+ color: #ffd25a;
}
}
.splash-logo {
.splash-logo {
+ margin: 0;
font-size: 40px;
font-size: 40px;
font-weight: 900;
font-weight: 900;
letter-spacing: 0.02em;
letter-spacing: 0.02em;
@@ -83,10 +88,10 @@
@@ -83,10 +88,10 @@
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
<main id="cg" class="cg">
<main id="cg" class="cg">
<div class="splash">
<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>
<div class="splash-ring" aria-hidden="true"></div>
<p>Summoning your heroes…</p>
<p>Summoning your heroes…</p>
- <noscript><p>Vibe Heroes needs JavaScript to play.</p></noscript>
+ <noscript><!--casual:about--></noscript>
</div>
</div>
</main>
</main>
<footer id="ov-footer" class="ovf"></footer>
<footer id="ov-footer" class="ovf"></footer>
@@ -114,11 +114,16 @@ export class App {
@@ -114,11 +114,16 @@ export class App {
private renderTop(): void {
private renderTop(): void {
const s = this.state
const s = this.state
const p = this.pack
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 = '') =>
const chip = (name: string, value: Node | string, label: string, cls = '') =>
h(
h(
'div',
'div',
- { class: `chip ${cls}`, attrs: { 'aria-label': label }, title: label },
+ { class: `chip ${cls}`, title: label },
icon(name),
icon(name),
+ h('span', {
+ class: 'sr',
+ text: `${name === 'gems' ? 'Gems' : name === 'gold' ? 'Gold' : 'Sigils'}: `,
+ }),
h('b', null, value),
h('b', null, value),
)
)
const energy = h('b')
const energy = h('b')
@@ -142,10 +147,10 @@ export class App {
@@ -142,10 +147,10 @@ export class App {
'div',
'div',
{
{
class: 'chip chip-energy',
class: 'chip chip-energy',
- attrs: { 'aria-label': 'Energy' },
title: 'Energy: one point every five minutes',
title: 'Energy: one point every five minutes',
},
},
icon('energy'),
icon('energy'),
+ h('span', { class: 'sr', text: 'Energy: ' }),
h('span', { class: 'chip-stack' }, energy, energyTimer),
h('span', { class: 'chip-stack' }, energy, energyTimer),
),
),
button(icon('menu'), () => this.menu(this), 'icon-btn top-menu', { 'aria-label': 'Menu' }),
button(icon('menu'), () => this.menu(this), 'icon-btn top-menu', { 'aria-label': 'Menu' }),
@@ -168,7 +173,7 @@ export class App {
@@ -168,7 +173,7 @@ export class App {
icon(t.icon),
icon(t.icon),
h('span', { text: t.label }),
h('span', { text: t.label }),
dots[t.id]
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,
: null,
],
],
() => this.go(t.id),
() => this.go(t.id),
@@ -67,6 +67,14 @@
@@ -67,6 +67,14 @@
.cg.is-busy {
.cg.is-busy {
cursor: progress;
cursor: progress;
}
}
+.cg .sr {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
.boot-msg {
.boot-msg {
margin: auto;
margin: auto;
padding: 24px;
padding: 24px;
@@ -86,7 +86,7 @@ export function homeView(app: App): HTMLElement {
@@ -86,7 +86,7 @@ export function homeView(app: App): HTMLElement {
'div',
'div',
{
{
class: 'chapter-track',
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) =>
...Array.from({ length: 10 }, (_, i) =>
h('i', {
h('i', {
@@ -132,7 +132,11 @@ export function homeView(app: App): HTMLElement {
@@ -132,7 +132,11 @@ export function homeView(app: App): HTMLElement {
strip.append(
strip.append(
def && hs
def && hs
? heroCard(pack, def, hs, { small: true })
? 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(
view.append(
@@ -12,7 +12,7 @@ import { h, num } from '../dom.js'
@@ -12,7 +12,7 @@ import { h, num } from '../dom.js'
/** A row of stars, filled up to `n` of `max`. */
/** A row of stars, filled up to `n` of `max`. */
export function stars(n: number, max = n): HTMLElement {
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'))
for (let i = 0; i < max; i++) row.append(icon('star', i < n ? 'ico star on' : 'ico star'))
return row
return row
}
}
@@ -37,7 +37,7 @@ export function heroCard(
@@ -37,7 +37,7 @@ export function heroCard(
card.append(
card.append(
h(
h(
'span',
'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('i', { style: { width: `${Math.min(100, (have / need) * 100)}%` } }),
h('b', { text: `${have}/${need}` }),
h('b', { text: `${have}/${need}` }),
),
),
@@ -96,7 +96,16 @@ function questRow(app: App, q: QuestDef): HTMLElement {
@@ -96,7 +96,16 @@ function questRow(app: App, q: QuestDef): HTMLElement {
h('b', { text: q.text }),
h('b', { text: q.text }),
h(
h(
'span',
'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)}%` } }),
h('i', { style: { width: `${Math.min(100, (p.n / q.target) * 100)}%` } }),
),
),
rewardChips(app.pack, q.reward),
rewardChips(app.pack, q.reward),
@@ -41,7 +41,7 @@ beforeAll(async () => {
@@ -41,7 +41,7 @@ beforeAll(async () => {
mkdirSync(dist)
mkdirSync(dist)
writeFileSync(
writeFileSync(
join(dist, 'casual.html'),
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>')
writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal</title>')
db = await openTestDb()
db = await openTestDb()
@@ -274,6 +274,10 @@ describe('the casual page', () => {
@@ -274,6 +274,10 @@ describe('the casual page', () => {
expect(html).toContain('<meta name="twitter:card" content="summary_large_image" />')
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('https://openvibe.games/casual/vibe-heroes/card.png')
expect(html).toContain('"@type":"WebApplication"')
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')
const slash = await call('/play/vibe-heroes/?src=x&c=t1')
expect(slash.status).toBe(301)
expect(slash.status).toBe(301)
expect(slash.body.location).toBe('/play/vibe-heroes?src=x&c=t1')
expect(slash.body.location).toBe('/play/vibe-heroes?src=x&c=t1')
@@ -45,6 +45,19 @@ export function casualHead(id: string): string {
@@ -45,6 +45,19 @@ export function casualHead(id: string): string {
].join('\n ')
].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
let memo: { path: string; mtimeMs: number; raw: string } | null = null
/** The built page for one game (the file is read once per build). */
/** 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 {
@@ -53,5 +66,9 @@ export function casualPageHtml(filePath: string, id: string): string {
if (!memo || memo.path !== filePath || memo.mtimeMs !== mtimeMs) {
if (!memo || memo.path !== filePath || memo.mtimeMs !== mtimeMs) {
memo = { path: filePath, mtimeMs, raw: readFileSync(filePath, 'utf8') }
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))
}
}