diff --git a/web/src/commands/commandButton.test.ts b/web/src/commands/commandButton.test.ts index 6bce6e8..3b3c2aa 100644 --- a/web/src/commands/commandButton.test.ts +++ b/web/src/commands/commandButton.test.ts @@ -55,7 +55,7 @@ describe('', () => { expect(btn).toHaveAttribute('aria-label', 'Pick it') expect(btn.querySelector('.cmd-lock')).toBeNull() await vi.waitFor(() => { - expect(el.querySelector('button')).toHaveAttribute('title', 'Locked by a manual pick') + expect(el.querySelector('button')).toHaveAttribute('aria-description', 'Locked by a manual pick') }) expect(el.querySelector('.cmd-lock')).not.toBeNull() expect(el.querySelector('button')).toHaveAttribute('aria-disabled', 'true') diff --git a/web/src/commands/commandButton.ts b/web/src/commands/commandButton.ts index 19a5682..25eb09d 100644 --- a/web/src/commands/commandButton.ts +++ b/web/src/commands/commandButton.ts @@ -46,7 +46,8 @@ export function commandButton({ name, preset, label, tone = 'ghost' }: ButtonPro class: `btn btn-${tone}`, 'aria-label': spoken, 'aria-disabled': String(locked !== null || !spec), - title: locked, + // The lock reason is spoken (no hover tooltip: textgate R7); 🔒 paints it. + 'aria-description': locked, onclick: () => { if (spec && !locked) runner.run(name, preset) }, diff --git a/web/src/commands/palette.test.ts b/web/src/commands/palette.test.ts index 5672c2b..a1c8123 100644 --- a/web/src/commands/palette.test.ts +++ b/web/src/commands/palette.test.ts @@ -28,7 +28,9 @@ function open(specs = SPECS) { const box = () => must(document.querySelector('[role=combobox]')) const options = () => [...document.querySelectorAll('[role=option]')] const active = () => options().findIndex((o) => o.getAttribute('aria-selected') === 'true') -const opt = (title: string) => must(options().find((o) => o.querySelector('.palette-title')?.firstChild?.textContent === title)) +/** The option whose spoken title (the glyph's sr-only label) is `title`. */ +const opt = (title: string) => must(options().find((o) => o.querySelector('.palette-title .glyph .sr-only')?.textContent === title)) +const hint = (o: HTMLElement) => must(o.querySelector('.palette-hint')) const key = (k: string) => box().dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })) const loaded = () => vi.waitFor(() => { expect(options().length).toBeGreaterThan(0) @@ -51,12 +53,13 @@ describe('CommandPaletteElement', () => { await loaded() expect(el.isOpen).toBe(true) expect(options()).toHaveLength(TOTAL) - expect(opt('Scan')).toHaveTextContent('run') - expect(opt('Unblock')).toHaveTextContent('run · confirm') - expect(opt('Status')).toHaveTextContent('open /') - expect(opt('Pick')).toHaveTextContent('locked') - expect(opt('Pick')).toHaveTextContent('Locked by a pick') + expect(hint(opt('Scan'))).toHaveTextContent('▶️run') + expect(hint(opt('Unblock'))).toHaveTextContent('▶️⌨️run · confirm') + expect(hint(opt('Status'))).toHaveTextContent('↗️open /') + expect(opt('Pick').querySelector('.pill')).toHaveTextContent('🔒locked') + expect(opt('Pick').querySelector('.palette-detail')).toHaveTextContent('Locked by a pick') expect(opt('Pick')).not.toHaveTextContent('Pick next.') + expect(opt('Scan').querySelector('.palette-detail')).toHaveTextContent('Scan achievements.') expect(document.body).toHaveTextContent('Go to Dashboard') expect(document.activeElement).toBe(box()) }) diff --git a/web/src/commands/palette.ts b/web/src/commands/palette.ts index 790f5d4..8b56c4a 100644 --- a/web/src/commands/palette.ts +++ b/web/src/commands/palette.ts @@ -8,15 +8,19 @@ import { define, h } from '../lib/dom' import { uid } from '../lib/ids' import { pageHref, runner } from '../lib/runner' import { NAV } from '../shell/nav' -import { errorNotice, loading, pill } from '../ui/build' +import { empty, errorNotice, glyph, loading, pill } from '../ui/build' import { Modal } from '../ui/dialog' -import { COMMAND_HOME, commandLabel } from './catalog' +import { COMMAND_HOME, COMMAND_ICON, commandLabel } from './catalog' interface Entry { key: string title: string detail: string hint: string + /** Painted in place of the title (textgate). */ + icon: string + /** Painted in place of the hint: ▶️ run, ↗️ open, 🧭 navigate. */ + hintIcon: string locked: string | null act: () => void } @@ -45,7 +49,7 @@ export class CommandPaletteElement extends HTMLElement { 'aria-expanded': 'true', 'aria-controls': `${this.listId}-list`, 'aria-label': 'Search commands', - placeholder: 'Type a command or section…', + placeholder: '🔍', autofocus: true, }) this.list = h('ul', { id: `${this.listId}-list`, role: 'listbox', class: 'palette-list', 'aria-label': 'Commands' }) @@ -56,7 +60,12 @@ export class CommandPaletteElement extends HTMLElement { this.input.addEventListener('keydown', (e) => { this.onKey(e) }) - this.modal.render(this.input, this.status, this.list, h('p', { class: 'field-help' }, '↑↓ to move · Enter to run · Esc to close')) + this.modal.render(this.input, this.status, this.list, h( + 'p', + { class: 'field-help' }, + h('span', { 'aria-hidden': 'true' }, '↑↓ · ⏎ · ⎋'), + h('span', { class: 'sr-only' }, '↑↓ to move · Enter to run · Esc to close'), + )) } get isOpen(): boolean { @@ -108,6 +117,8 @@ export class CommandPaletteElement extends HTMLElement { title: commandLabel(c.name), detail: c.description, hint: c.kind === 'job' ? (c.friction ? 'run · confirm' : 'run') : `open ${COMMAND_HOME[c.name]}`, + icon: COMMAND_ICON[c.name], + hintIcon: c.kind === 'job' ? (c.friction ? '▶️⌨️' : '▶️') : '↗️', locked: c.locked_reason, act: () => { if (c.kind === 'job') runner.run(c.name) @@ -122,6 +133,8 @@ export class CommandPaletteElement extends HTMLElement { title: `Go to ${n.label}`, detail: n.blurb, hint: 'navigate', + icon: n.icon, + hintIcon: '🧭', locked: null, act: () => { this.close() @@ -143,9 +156,10 @@ export class CommandPaletteElement extends HTMLElement { const li = h( 'li', { id: `${this.listId}-${i}`, role: 'option', 'aria-selected': String(i === this.active), class: i === this.active ? 'palette-item active' : 'palette-item' }, - h('span', { class: 'palette-title' }, e.title, e.locked ? pill('warning', 'locked') : null), - h('span', { class: 'palette-detail' }, e.locked ?? e.detail), - h('span', { class: 'palette-hint' }, e.hint), + // Textgate: glyphs painted; title, detail and hint spoken only. + h('span', { class: 'palette-title' }, glyph(e.icon, e.title), e.locked ? pill('warning', 'locked', '🔒') : null), + h('span', { class: 'palette-detail sr-only' }, e.locked ?? e.detail), + h('span', { class: 'palette-hint' }, glyph(e.hintIcon, e.hint)), ) li.addEventListener('mousemove', () => { if (this.active !== i) this.setActive(i) @@ -154,7 +168,7 @@ export class CommandPaletteElement extends HTMLElement { return li }), ) - if (this.shown.length === 0) this.list.append(h('li', { class: 'empty' }, `No command matches “${this.input.value}”.`)) + if (this.shown.length === 0) this.list.append(h('li', {}, empty(`No command matches “${this.input.value}”.`))) this.syncActive() } diff --git a/web/src/commands/paramsForm.test.ts b/web/src/commands/paramsForm.test.ts index b1d8bba..d5c779d 100644 --- a/web/src/commands/paramsForm.test.ts +++ b/web/src/commands/paramsForm.test.ts @@ -26,7 +26,9 @@ function make(over: { specs?: ParamSpec[]; values?: ParamValues; disabled?: bool } function labelFor(text: RegExp | string): string { - const label = [...document.querySelectorAll('label')].find((l) => (typeof text === 'string' ? l.textContent === text : text.test(l.textContent))) + // The spoken label sits in an sr-only span; the painted part is a glyph. + const spoken = (l: HTMLLabelElement) => l.querySelector('.sr-only')?.textContent ?? '' + const label = [...document.querySelectorAll('label')].find((l) => (typeof text === 'string' ? spoken(l) === text : text.test(spoken(l)))) return label?.getAttribute('for') ?? '' } @@ -37,7 +39,9 @@ describe('ParamsForm', () => { expect(input(/Game/)).toHaveAttribute('inputmode', 'numeric') expect(input(/Game/)).toHaveAttribute('autofocus') expect(input(/Game/)).toBeRequired() - expect(document.body).toHaveTextContent('Game *') + expect(document.querySelector(`label[for="${input(/Game/).id}"]`)).toHaveTextContent('Game *') + expect(document.querySelector(`label[for="${input(/Game/).id}"] [aria-hidden]`)).toHaveTextContent('🎮') + expect(document.querySelector(`label[for="${input('Days').id}"]`)).not.toHaveTextContent('*') expect(input('Days')).toHaveAttribute('type', 'number') expect(input('Days')).toHaveAttribute('min', '1') expect(input('Days')).toHaveAttribute('max', '9') @@ -129,6 +133,6 @@ describe('ParamsForm', () => { const box: ParamSpec[] = [{ name: 'on', label: 'On', type: 'bool', required: false }] const { input } = make({ specs: box, values: {} }) expect(input('On')).not.toBeChecked() - expect(document.querySelector('.field-help')?.textContent).toBe(' ') + expect(document.querySelector('.field-help')?.textContent).toBe('') }) }) diff --git a/web/src/commands/paramsForm.ts b/web/src/commands/paramsForm.ts index 7acde9c..9734ddf 100644 --- a/web/src/commands/paramsForm.ts +++ b/web/src/commands/paramsForm.ts @@ -7,6 +7,34 @@ import { h } from '../lib/dom' import { uid } from '../lib/ids' import { paramError, type GameOption, type ParamValues } from './params' +/** Painted glyph per parameter (textgate); the catalog label is spoken only. */ +const PARAM_ICON: Record = { + app_id: '🎮', + backup_id: '🗄️', + days: '📅', + minutes: '⏱️', + reason: '💬', + replace: '🔁', + demo: '🧪', +} + +function paramGlyph(spec: ParamSpec): HTMLSpanElement { + return h( + 'span', + {}, + h('span', { 'aria-hidden': 'true' }, PARAM_ICON[spec.name] ?? '✏️'), + h('span', { class: 'sr-only' }, spec.label), + ) +} + +/** Field help: optional painted mark, sentence spoken only. */ +function spokenHelp(el: HTMLElement, mark: string | null, sentence: string): void { + el.replaceChildren( + mark ? h('span', { 'aria-hidden': 'true' }, mark) : '', + sentence ? h('span', { class: 'sr-only' }, sentence) : '', + ) +} + interface Field { wrap: HTMLDivElement spec: ParamSpec @@ -64,7 +92,7 @@ export class ParamsForm { input.addEventListener('change', () => { emit(input.checked ? '1' : '0') }) - const wrap = h('div', { class: 'form-field' }, h('label', { class: 'check', for: id }, input, spec.label), help) + const wrap = h('div', { class: 'form-field' }, h('label', { class: 'check', for: id }, input, paramGlyph(spec)), help) return { wrap, spec, input, help, datalist: null } } const numeric = spec.type !== 'string' @@ -89,7 +117,7 @@ export class ParamsForm { const wrap = h( 'div', { class: 'form-field' }, - h('label', { for: id, class: 'field-label' }, spec.label, spec.required ? h('span', { 'aria-hidden': 'true' }, ' *') : null), + h('label', { for: id, class: 'field-label' }, paramGlyph(spec), spec.required ? h('span', { 'aria-hidden': 'true' }, ' *') : null), input, datalist, help, @@ -99,7 +127,7 @@ export class ParamsForm { private refreshField({ spec, input, help, datalist }: Field): void { if (spec.type === 'bool') { - help.textContent = spec.help ?? ' ' + spokenHelp(help, null, spec.help ?? '') return } const raw = this.values[spec.name] ?? '' @@ -109,11 +137,11 @@ export class ParamsForm { input.setAttribute('aria-invalid', String(bad)) help.className = bad ? 'field-help text-danger' : 'field-help' const game = spec.type === 'app_id' ? this.games.find((g) => g.app_id === Number(raw)) : undefined - help.textContent = bad - ? err - : game - ? game.name - : (spec.help ?? (spec.type === 'app_id' ? 'Type an app id or pick a game from the list.' : ' ')) + // Textgate: an error paints ⚠️, a matched game paints its name (content); + // the error sentence and the field help are spoken only. + if (bad) spokenHelp(help, '⚠️', err) + else if (game) help.replaceChildren(game.name) + else spokenHelp(help, null, spec.help ?? (spec.type === 'app_id' ? 'Type an app id or pick a game from the list.' : '')) if (datalist && datalist.childElementCount !== this.games.length) { datalist.replaceChildren(...this.games.map((g) => h('option', { value: g.app_id }, g.name))) } diff --git a/web/src/commands/pendingCountdown.test.ts b/web/src/commands/pendingCountdown.test.ts index 8efe9cc..91b77f5 100644 --- a/web/src/commands/pendingCountdown.test.ts +++ b/web/src/commands/pendingCountdown.test.ts @@ -38,7 +38,10 @@ describe('PendingCountdown', () => { await tick(10_000) expect(text()).toContain('0:21') expect(el.querySelector('[role=progressbar]')).toHaveAttribute('aria-valuenow', '32') - expect(text()).toContain('Closing this dialog') + expect(el.querySelector('.notice-warning')).toHaveAttribute( + 'aria-label', + 'Closing this dialog, navigating away or closing the tab cancels the countdown.', + ) expect(button(el, 'Cancel countdown')).toHaveAttribute('autofocus') }) diff --git a/web/src/commands/pendingCountdown.ts b/web/src/commands/pendingCountdown.ts index 552f6ab..41fdc2e 100644 --- a/web/src/commands/pendingCountdown.ts +++ b/web/src/commands/pendingCountdown.ts @@ -21,6 +21,13 @@ import { PhraseField } from './phraseField' */ export const CLOCK_SLACK_MS = 1000 +/** ⚠️ 🚪: leaving cancels the countdown; the sentence is spoken only. */ +function stayNotice(): HTMLDivElement { + const el = notice('warning', null, h('span', { 'aria-hidden': 'true' }, '🚪✖️')) + el.setAttribute('aria-label', 'Closing this dialog, navigating away or closing the tab cancels the countdown.') + return el +} + export interface CountdownCallbacks { onCommitted: (job: Job) => void onCancel: () => void @@ -102,7 +109,7 @@ export class PendingCountdown { this.el.className = 'stack' this.el.replaceChildren( errorNotice(this.beatError), - h('div', { class: 'actions' }, h('button', { type: 'button', class: 'btn btn-primary', autofocus: true, onclick: this.cb.onRearm }, 'Arm again')), + h('div', { class: 'actions' }, h('button', { type: 'button', class: 'btn btn-primary', autofocus: true, 'aria-label': 'Arm again', onclick: this.cb.onRearm }, '🔁')), ) this.el.querySelector('[autofocus]')?.focus() return @@ -113,11 +120,23 @@ export class PendingCountdown { const left = Math.max(0, (ready - now) / 1000) const isReady = left <= 0 this.parts.num.textContent = clock(left) - this.parts.caption.textContent = isReady ? 'Ready to commit' : 'until commit unlocks' + // Textgate: 🔓/🔒 painted beside the clock; 💓 + seconds since the last + // heartbeat (red 💔 when failing). Sentences are spoken only. + this.parts.caption.replaceChildren( + h('span', { 'aria-hidden': 'true' }, isReady ? '🔓' : '🔒'), + h('span', { class: 'sr-only' }, isReady ? 'Ready to commit' : 'until commit unlocks'), + ) this.parts.bar.replaceChildren(progressBar(Math.min(1, (now - armed) / (ready - armed)), 'Countdown', isReady ? 'success' : 'warning')) + const sinceBeat = Math.round((now - this.lastBeat) / 1000) + const failing = this.beatError !== null this.parts.beat.replaceChildren( - `Keep this window open. Heartbeat every ${Math.round(this.pending.heartbeat_interval_seconds / 2)} s · last ${Math.round((now - this.lastBeat) / 1000)} s ago`, - this.beatError !== null ? h('span', { class: 'text-danger' }, ' · heartbeat failing, retrying') : '', + h('span', { 'aria-hidden': 'true', class: failing ? 'text-danger' : '' }, `${failing ? '💔' : '💓'} ${sinceBeat}s`), + h( + 'span', + { class: 'sr-only' }, + `Keep this window open. Heartbeat every ${Math.round(this.pending.heartbeat_interval_seconds / 2)} s · last ${sinceBeat} s ago`, + failing ? ' · heartbeat failing, retrying' : '', + ), ) this.parts.live.textContent = isReady ? 'Countdown finished. Retype the phrase to commit.' : '' if (isReady !== this.shownReady) this.renderBody(isReady) @@ -128,11 +147,15 @@ export class PendingCountdown { this.shownReady = isReady if (!isReady) { this.parts.body.replaceChildren( - notice('warning', null, 'Closing this dialog, navigating away or closing the tab cancels the countdown.'), + stayNotice(), h( 'div', { class: 'actions' }, - h('button', { type: 'button', class: 'btn btn-ghost', autofocus: true, onclick: () => void this.doCancel() }, this.cancel.pending ? 'Cancelling…' : 'Cancel countdown'), + h( + 'button', + { type: 'button', class: 'btn btn-ghost', autofocus: true, 'aria-label': this.cancel.pending ? 'Cancelling…' : 'Cancel countdown', onclick: () => void this.doCancel() }, + this.cancel.pending ? '⏳' : '✖️', + ), ), ) return @@ -153,7 +176,7 @@ export class PendingCountdown { { class: 'stack' }, this.phrase.el, errorSlot, - h('div', { class: 'actions' }, submitBtn, h('button', { type: 'button', class: 'btn btn-ghost', onclick: () => void this.doCancel() }, 'Cancel')), + h('div', { class: 'actions' }, submitBtn, h('button', { type: 'button', class: 'btn btn-ghost', 'aria-label': 'Cancel', onclick: () => void this.doCancel() }, '✖️')), ) form.addEventListener('submit', (e) => { e.preventDefault() @@ -165,7 +188,8 @@ export class PendingCountdown { } const { submit, errors } = this.commitForm as { submit: HTMLButtonElement; errors: HTMLDivElement } submit.disabled = !phraseMatches(this.pending.phrase, this.phrase.value) || this.commit.pending - submit.textContent = this.commit.pending ? 'Committing…' : 'Commit' + submit.textContent = this.commit.pending ? '⏳' : '✔️' + submit.setAttribute('aria-label', this.commit.pending ? 'Committing…' : 'Commit') errors.replaceChildren(...(this.commit.error ? [errorNotice(this.commit.error)] : [])) } diff --git a/web/src/commands/phraseField.test.ts b/web/src/commands/phraseField.test.ts index 7f0dd19..53d8c14 100644 --- a/web/src/commands/phraseField.test.ts +++ b/web/src/commands/phraseField.test.ts @@ -57,7 +57,9 @@ describe('PhraseField', () => { const { field, input } = make({ autofocus: true }) expect(field.el.querySelector('label')).toHaveTextContent('Type the phrase to confirm') expect(field.el.querySelector('label')).toHaveAttribute('for', input.id) - expect(field.el.querySelector('.sr-only')).toHaveTextContent('Phrase: reset it') + expect(field.el.querySelector('label .sr-only')).toHaveTextContent('Type the phrase to confirm') + expect(field.el.querySelector('label [aria-hidden]')).toHaveTextContent('⌨️') + expect(field.el.querySelector('p.sr-only')).toHaveTextContent('Phrase: reset it') expect(input).toHaveAttribute('autofocus') expect(make({ label: 'Again' }).field.el.querySelector('label')).toHaveTextContent('Again') expect(make().input).not.toHaveAttribute('autofocus') diff --git a/web/src/commands/phraseField.ts b/web/src/commands/phraseField.ts index cc20697..7409b98 100644 --- a/web/src/commands/phraseField.ts +++ b/web/src/commands/phraseField.ts @@ -48,7 +48,7 @@ export class PhraseField { this.el = h( 'div', { class: 'phrase' }, - h('label', { for: id, class: 'field-label' }, label), + h('label', { for: id, class: 'field-label' }, h('span', { 'aria-hidden': 'true' }, '⌨️'), h('span', { class: 'sr-only' }, label)), this.target, this.input, h('p', { class: 'sr-only' }, `Phrase: ${phrase}`), @@ -71,10 +71,12 @@ export class PhraseField { this.input.className = `input mono ${ok ? 'input-ok' : prefixOk ? '' : 'input-bad'}`.trim() this.input.setAttribute('aria-invalid', String(!prefixOk)) this.status.className = `field-help ${ok ? 'text-success' : prefixOk ? '' : 'text-danger'}`.trim() - this.status.textContent = ok - ? 'Matches.' + // Textgate: ✅ / count / ❌ painted; the sentence is spoken only. + const [mark, spoken] = ok + ? ['✅', 'Matches.'] : prefixOk - ? `${typed.length} of ${this.phrase.length} characters` - : 'Does not match — check case and spacing.' + ? [`${typed.length}/${this.phrase.length}`, `${typed.length} of ${this.phrase.length} characters`] + : ['❌', 'Does not match — check case and spacing.'] + this.status.replaceChildren(h('span', { 'aria-hidden': 'true' }, mark), h('span', { class: 'sr-only' }, spoken)) } } diff --git a/web/src/commands/runnerDialog.failures.test.ts b/web/src/commands/runnerDialog.failures.test.ts index d71e573..6a99e17 100644 --- a/web/src/commands/runnerDialog.failures.test.ts +++ b/web/src/commands/runnerDialog.failures.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { q } from '../api/queries' import { makeSpec } from '../test/fixtures' -import { submit } from '../test/events' +import { must, submit } from '../test/events' import { apiError, Reply } from '../test/harness' import { field, found, openRunner, text } from '../test/runnerHelpers' @@ -18,8 +18,11 @@ describe(' locked', () => { }) openRunner([spec], 'pick') const run = await found('Run pick') - expect(text()).toContain('Manual pick active') - expect(field('N')).toBeDisabled() + // The reason is spoken only; 🔒 is painted in the notice. + const lock = must(document.querySelector('.notice-warning')) + expect(lock).toHaveAttribute('aria-label', 'Locked: Manual pick active') + expect(lock).toHaveTextContent('🔒') + expect(field(/N$/)).toBeDisabled() expect(document.querySelector('.phrase')).toBeNull() expect(run).toBeDisabled() submit(run) @@ -38,7 +41,10 @@ describe(' start failures', () => { it('offers the sudo fallback when the daemon is down', async () => { openRunner([makeSpec('gaming-unblock')], 'gaming-unblock', { extra: { 'POST /api/jobs': apiError(502, 'daemon_unreachable') } }) submit(await found('Run gaming-unblock')) - await waitText('Run it from a terminal instead:') + await vi.waitFor(() => { + expect(document.querySelector('[data-code="daemon_unreachable"] p')).toHaveAttribute('aria-label', 'Run it from a terminal instead:') + }) + expect(document.querySelector('[data-code="daemon_unreachable"] p')).toHaveTextContent('💻') expect(text()).toContain('sudo ./run.sh gaming-unblock') }) diff --git a/web/src/commands/runnerDialog.test.ts b/web/src/commands/runnerDialog.test.ts index 1242af6..fd0e0fe 100644 --- a/web/src/commands/runnerDialog.test.ts +++ b/web/src/commands/runnerDialog.test.ts @@ -24,8 +24,14 @@ describe(' catalog states', () => { it('says so when the server does not offer the command', async () => { openRunner([makeSpec('done')], 'scan') - await waitText('Not in the server catalog') - expect(text()).toContain('does not offer "scan"') + // Spoken only; the command's glyph is painted beside ⛔. + await vi.waitFor(() => { + expect(document.querySelector('.notice-danger')).toHaveAttribute( + 'aria-label', + 'Not in the server catalog: the server does not offer "scan".', + ) + }) + expect(document.querySelector('.notice-danger')).toHaveTextContent('🔎') }) it('titles the dialog with the command label', async () => { @@ -136,7 +142,7 @@ describe(' parameters', () => { await found('Close (job keeps running)') q.dataset.invalidate() await vi.waitFor(() => { expect(q.dataset.state.loading).toBe(false) }) - expect(text()).toContain('Open in Jobs') + expect(document.querySelector('a[aria-label="Open in Jobs"]')).toHaveAttribute('href', '/jobs/?id=j1') }) }) @@ -149,7 +155,10 @@ describe(' friction', () => { it('shows the exact phrase once its fields are known, and sends it', async () => { const { mock } = openRunner([spec], 'uninstall', { extra: { 'POST /api/jobs': makeJob({ id: 'j1' }) } }) const run = await found('Run uninstall') - expect(text()).toMatch(/Fill in the fields above to see the exact phrase \(game_name\)/) + expect(document.querySelector('.notice-info')).toHaveAttribute( + 'aria-label', + 'Fill in the fields above to see the exact phrase (game_name).', + ) setValue(field(/Game/), '7') await vi.waitFor(() => { expect(phraseInput()).toBeInTheDocument() }) expect(run).toBeDisabled() diff --git a/web/src/commands/runnerDialog.ts b/web/src/commands/runnerDialog.ts index 4d146ed..f760111 100644 --- a/web/src/commands/runnerDialog.ts +++ b/web/src/commands/runnerDialog.ts @@ -9,7 +9,7 @@ import { define, h, hx } from '../lib/dom' import { runner, jobHref, type RunRequest } from '../lib/runner' import { errorNotice, loading, notice } from '../ui/build' import { Modal } from '../ui/dialog' -import { commandLabel, findCommand } from './catalog' +import { COMMAND_ICON, commandLabel, findCommand } from './catalog' import type { PendingCountdown } from './pendingCountdown' import { Runner, type NextPhase } from './runnerForm' @@ -94,7 +94,12 @@ export class RunnerDialogElement extends HTMLElement { return } if (error) this.modal.render(errorNotice(error)) - else if (commands) this.modal.render(notice('danger', 'Not in the server catalog', `The server does not offer "${req.name}".`)) + else if (commands) { + // ⛔ + the command's glyph; the sentence is spoken only. + const el = notice('danger', null, h('span', { 'aria-hidden': 'true' }, COMMAND_ICON[req.name])) + el.setAttribute('aria-label', `Not in the server catalog: the server does not offer "${req.name}".`) + this.modal.render(el) + } else this.modal.render(loading('commands')) } @@ -111,8 +116,8 @@ export class RunnerDialogElement extends HTMLElement { h( 'div', { class: 'actions' }, - h('button', { type: 'button', class: 'btn btn-ghost', onclick: () => { this.close() } }, 'Close (job keeps running)'), - h('a', { href: jobHref(next.id), class: 'btn btn-ghost' }, 'Open in Jobs'), + h('button', { type: 'button', class: 'btn btn-ghost', 'aria-label': 'Close (job keeps running)', onclick: () => { this.close() } }, '✖️'), + h('a', { href: jobHref(next.id), class: 'btn btn-ghost', 'aria-label': 'Open in Jobs' }, '⚙️'), ), ), ) diff --git a/web/src/commands/runnerForm.ts b/web/src/commands/runnerForm.ts index 3600e1b..d6ec0b7 100644 --- a/web/src/commands/runnerForm.ts +++ b/web/src/commands/runnerForm.ts @@ -9,7 +9,7 @@ import { Action } from '../lib/action' import { h } from '../lib/dom' import type { Preset } from '../lib/runner' import { commandLine, errorNotice, notice, pill } from '../ui/build' -import { fillPhrase, phraseMatches } from './catalog' +import { COMMAND_ICON, fillPhrase, phraseMatches } from './catalog' import { enforcePreset } from './enforcePreset' import { gameLookup } from './gameLookup' import { initialValues, paramError, toParams, type ParamValues } from './params' @@ -23,6 +23,22 @@ const FALLBACK: Partial> = { } +/** ⏳ plus the minutes (a number, so painted); the word is spoken only. */ +function countdownPill(seconds: number): HTMLSpanElement { + const minutes = Math.round(seconds / 60) + const el = pill('warning', `${minutes} min countdown`, '⏳') + el.append(h('span', { class: 'pill-num', 'aria-hidden': 'true' }, `${minutes}′`)) + return el +} + +/** 🔒 warning notice; the server's lock reason is spoken only. */ +function lockedNotice(reason: string): HTMLDivElement { + const el = notice('warning', null) + el.querySelector('.notice-icon')?.replaceChildren('🔒') + el.setAttribute('aria-label', `Locked: ${reason}`) + return el +} + export type NextPhase = { k: 'pending'; countdown: PendingCountdown } | { k: 'job'; id: string } | { k: 'form'; runner: Runner } export interface RunnerCallbacks { @@ -77,16 +93,18 @@ export class Runner { this.el = h( 'form', { class: 'stack', novalidate: true }, - h('p', { class: 'm0' }, spec.description), + // Textgate: the description and every tag are spoken only; each tag paints its own glyph. + h('p', { class: 'm0 sr-only' }, spec.description), h( 'div', { class: 'tags' }, - spec.privileged ? pill('neutral', 'Runs as root via the daemon') : null, - pill('neutral', spec.mutating ? 'Changes state' : 'Read-only'), - spec.cancellable ? pill('neutral', 'Cancellable') : null, - countdown ? pill('warning', `${Math.round(countdown / 60)} min countdown`) : null, + h('span', { class: 'runner-glyph', 'aria-hidden': 'true' }, COMMAND_ICON[spec.name]), + spec.privileged ? pill('neutral', 'Runs as root via the daemon', '🔑') : null, + spec.mutating ? pill('neutral', 'Changes state', '✏️') : pill('neutral', 'Read-only', '👁️'), + spec.cancellable ? pill('neutral', 'Cancellable', '⏹️') : null, + countdown ? countdownPill(countdown) : null, ), - spec.locked_reason !== null ? notice('warning', 'Locked', spec.locked_reason) : null, + spec.locked_reason !== null ? lockedNotice(spec.locked_reason) : null, this.form.nodes, this.phraseSlot, this.errorSlot, @@ -94,7 +112,7 @@ export class Runner { 'div', { class: 'actions' }, this.submitBtn, - h('button', { type: 'button', class: 'btn btn-ghost', onclick: () => { cb.onClose() } }, 'Cancel'), + h('button', { type: 'button', class: 'btn btn-ghost', 'aria-label': 'Cancel', onclick: () => { cb.onClose() } }, '✖️'), ), ) this.el.addEventListener('submit', (e) => { @@ -140,7 +158,10 @@ export class Runner { if (key !== this.phraseKey) { this.phraseKey = key if (phrase && phrase.missing.length > 0) { - this.phraseSlot.replaceChildren(notice('info', null, `Fill in the fields above to see the exact phrase (${phrase.missing.join(', ')}).`)) + // ℹ️ ⬆️: fill the fields above first; the sentence is spoken only. + const hint = notice('info', null, h('span', { 'aria-hidden': 'true' }, '⬆️')) + hint.setAttribute('aria-label', `Fill in the fields above to see the exact phrase (${phrase.missing.join(', ')}).`) + this.phraseSlot.replaceChildren(hint) } else if (phrase && !locked) { this.typed = '' const field = new PhraseField({ @@ -165,7 +186,7 @@ export class Runner { errorNotice( err, failure?.code === 'daemon_unreachable' && fallback - ? [h('p', { class: 'm0' }, 'Run it from a terminal instead:'), commandLine(fallback)] + ? [h('p', { class: 'm0', 'aria-label': 'Run it from a terminal instead:' }, h('span', { 'aria-hidden': 'true' }, '💻')), commandLine(fallback)] : null, ), ] @@ -174,7 +195,9 @@ export class Runner { const countdown = this.spec.friction?.countdown_seconds this.submitBtn.className = phrase ? 'btn btn-danger' : 'btn btn-primary' this.submitBtn.disabled = !this.canSubmit() || this.start.pending - this.submitBtn.textContent = this.start.pending ? 'Starting…' : countdown ? 'Arm countdown' : `Run ${this.spec.name}` + // Glyph painted, label spoken: ⏳ starting, ⏲️ arm countdown, ▶️ run. + this.submitBtn.textContent = this.start.pending ? '⏳' : countdown ? '⏲️' : '▶️' + this.submitBtn.setAttribute('aria-label', this.start.pending ? 'Starting…' : countdown ? 'Arm countdown' : `Run ${this.spec.name}`) } private async submit(): Promise {