From f5f921ebf4413e5e6e7d2716e11f38f9cce4a9b2 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 11 Nov 2026 02:20:43 +0100 Subject: [PATCH] web: icons for job state, notices, empty, copy Partial textgate rework: job state pills, notices, empty and loading states, copy button and timeline legend paint icons; the old wording is spoken only. Guard test covers these pieces. Co-Authored-By: Claude Opus 5.5 --- .../commands/runnerDialog.failures.test.ts | 4 +- web/src/jobs/jobProgress.log.test.ts | 7 +- web/src/jobs/jobProgress.test.ts | 43 ++++++------ web/src/jobs/jobProgress.ts | 38 ++++++----- web/src/jobs/progress.test.ts | 19 +++--- web/src/jobs/progress.ts | 16 ++--- web/src/lib/charts/timeline.test.ts | 5 +- web/src/lib/charts/timeline.ts | 18 ++--- web/src/sections/backlog/planner.test.ts | 9 ++- web/src/sections/backlog/plannerTable.test.ts | 14 ++-- web/src/sections/backlog/plannerTable.ts | 36 +++++++--- web/src/sections/dashboard.test.ts | 2 +- web/src/sections/jobs.test.ts | 2 +- web/src/test/events.ts | 6 +- web/src/test/textgate.guard.test.ts | 2 +- web/src/ui/build.clipboard.test.ts | 5 +- web/src/ui/build.test.ts | 63 ++++++++++------- web/src/ui/build.ts | 68 +++++++++++++------ 18 files changed, 210 insertions(+), 147 deletions(-) diff --git a/web/src/commands/runnerDialog.failures.test.ts b/web/src/commands/runnerDialog.failures.test.ts index cc592eb..d71e573 100644 --- a/web/src/commands/runnerDialog.failures.test.ts +++ b/web/src/commands/runnerDialog.failures.test.ts @@ -45,14 +45,14 @@ describe(' start failures', () => { it('has no fallback for a command without one', async () => { openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': apiError(502, 'daemon_unreachable') } }) submit(await found('Run scan')) - await waitText('Root daemon unreachable') + await vi.waitFor(() => { expect(document.querySelector('[data-code="daemon_unreachable"]')).not.toBeNull() }) expect(text()).not.toContain('terminal instead') }) it('copes with a garbled reply', async () => { openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': new Reply(200, undefined, '{garbled') } }) submit(await found('Run scan')) - await waitText('Unexpected server error') + await vi.waitFor(() => { expect(document.querySelector('[data-code="http"]')).not.toBeNull() }) }) it('shows Starting… while waiting', async () => { diff --git a/web/src/jobs/jobProgress.log.test.ts b/web/src/jobs/jobProgress.log.test.ts index 602c395..f06d597 100644 --- a/web/src/jobs/jobProgress.log.test.ts +++ b/web/src/jobs/jobProgress.log.test.ts @@ -11,7 +11,8 @@ describe(' log', () => { const { el, send } = mountJob() send('log', { level: 'info', message: 'hello' }) send('log', { level: 'warning', message: 'careful' }) - expect(sel(el, 'summary')).toHaveTextContent('Log (2 lines)') + expect(sel(el, '.log-count')).toHaveTextContent('2') + expect(sel(el, 'summary')).toHaveAttribute('aria-label', '2 lines') expect(el.querySelectorAll('.log li')).toHaveLength(2) expect(sel(el, '.log-info')).toHaveTextContent('10:00:00 hello') expect(sel(el, '.log-warning')).toHaveTextContent('careful') @@ -46,7 +47,7 @@ describe(' log', () => { it('says nothing has been printed yet', () => { const { el } = mountJob() - expect(sel(el, '.log')).toHaveTextContent('No output yet.') + expect(sel(el, '.log-empty')).toHaveAttribute('aria-label', 'No output yet.') }) it('rebuilds the list when the newest-lines window slides', () => { @@ -79,7 +80,7 @@ describe(' lifecycle', () => { el.remove() document.body.append(el) expect(sel(el, 'section')).not.toBe(section) - expect(sel(el, 'summary')).toHaveTextContent('Log (1 lines)') + expect(sel(el, '.log-count')).toHaveTextContent('1') expect(sel(el, 'details')).toHaveAttribute('open') }) }) diff --git a/web/src/jobs/jobProgress.test.ts b/web/src/jobs/jobProgress.test.ts index ccfc890..eb095df 100644 --- a/web/src/jobs/jobProgress.test.ts +++ b/web/src/jobs/jobProgress.test.ts @@ -27,10 +27,11 @@ describe('eta', () => { describe('', () => { it('starts from the fetched job and shows "Starting…" before any progress', async () => { const { el } = mountJob({ job: { state: 'running' } }) - expect(text(el)).toContain('Starting…') - expect(text(el)).toContain('Queued') - expect(sel(el, 'h3')).toHaveTextContent('Job') - await vi.waitFor(() => { expect(text(el)).toContain('Running') }) + expect(sel(el, '.job-label')?.textContent).toBe('') + expect(sel(el, 'section')).toHaveAttribute('data-state', 'queued') + expect(sel(el, '.pill')).toHaveAttribute('aria-label', 'queued') + expect(sel(el, 'h3')?.textContent).toBe('') + await vi.waitFor(() => { expect(sel(el, 'section')).toHaveAttribute('data-state', 'running') }) expect(sel(el, 'h3')).toHaveTextContent('Scan') expect(sel(el, '[role=progressbar]')).not.toHaveAttribute('aria-valuenow') expect(sel(el, 'section')).toHaveAttribute('aria-label', 'Job Scan') @@ -45,17 +46,17 @@ describe('', () => { const { el, send } = mountJob() send('progress', { step: 3, total: 10, label: 'Scanning library', item: 'Hollow Knight', eta_seconds: 9 }) expect(sel(el, '.job-label')).toHaveTextContent('Scanning library') - expect(text(el)).toContain('Step 3 of 10') + expect(sel(el, '.job-step')).toHaveTextContent('3/10') expect(sel(el, '.job-item')).toHaveTextContent('Hollow Knight') - expect(sel(el, '.job-eta')).toHaveTextContent('about 0:09 left') + expect(sel(el, '.job-eta')).toHaveTextContent('0:09') expect(sel(el, '[role=progressbar]')).toHaveAttribute('aria-valuenow', '30') }) it('says "working…" for an unbounded phase', () => { const { el, send } = mountJob() send('progress', { step: 4, total: null, label: 'Reading' }) - expect(sel(el, '.job-eta')).toHaveTextContent('working…') - expect(text(el)).not.toMatch(/Step \d/) + expect(sel(el, '.job-eta')?.textContent).toBe('') + expect(sel(el, '.job-step')).toBeNull() expect(sel(el, '[role=progressbar]')).not.toHaveAttribute('aria-valuenow') }) @@ -72,7 +73,7 @@ describe('', () => { send('progress', { step: 1, total: 5, label: 'Working' }) await vi.advanceTimersByTimeAsync(10_000) send('progress', { step: 2, total: 5, label: 'Working' }) - expect(sel(el, '.job-eta')).toHaveTextContent('about 0:15 left') + expect(sel(el, '.job-eta')).toHaveTextContent('0:15') }) it('falls back to the fetched job’s progress', async () => { @@ -83,15 +84,14 @@ describe('', () => { it('shows the connection state', () => { const { el, es } = mountJob() - expect(text(el)).toContain('Reconnecting') + expect(sel(el, '.dot')).toHaveClass('dot-off') es.onopen?.() - expect(text(el)).toContain('Live') expect(sel(el, '.dot')).toHaveClass('dot-live') }) it('works without a job-id attribute', () => { document.body.innerHTML = '' - expect(document.body.firstElementChild?.querySelector('h3')).toHaveTextContent('Job') + expect(document.body.firstElementChild?.querySelector('section')).toHaveAttribute('aria-label', 'Job ') }) }) @@ -101,8 +101,9 @@ describe(' waiting for input', () => { send('prompt', { prompt_id: 'q', kind: 'text', message: 'Name?' }) expect(sel(el, 'form')).toBeNull() send('state', { state: 'waiting_input' }) - expect(text(el)).toContain('Waiting for your answer') - expect(sel(el, '.job-eta')).toHaveTextContent('paused') + expect(sel(el, 'section')).toHaveAttribute('data-state', 'waiting_input') + expect(sel(el, '.job-label')).toHaveTextContent('⏳') + expect(sel(el, '.job-eta')?.textContent).toBe('') expect(sel(el, 'form')).toHaveAttribute('aria-label', 'Job question') expect(button(el, 'Cancel job')).toBeInTheDocument() expect(sel(el, '[role=progressbar]')?.className).toContain('bar-warning') @@ -144,14 +145,14 @@ describe(' when finished', () => { const { el, send } = mountJob({ cancellable: true }) send('state', { state: 'succeeded' }) send('result', { ok: true, summary: 'All 12 scanned' }) - expect(sel(el, '.job-label')).toHaveTextContent('Completed') - expect(text(el)).toContain('Done') + expect(sel(el, '.job-label')).toHaveTextContent('✅') + expect(sel(el, 'section')).toHaveAttribute('data-state', 'succeeded') expect(text(el)).toContain('All 12 scanned') expect(sel(el, '[role=progressbar]')).toHaveAttribute('aria-valuenow', '100') expect(sel(el, '[role=progressbar]')?.className).toContain('bar-success') - expect(text(el)).not.toContain('Cancel job') + expect(el.querySelector('[aria-label="Cancel job"]')).toBeNull() expect(sel(el, 'details')).toHaveAttribute('open') - expect(text(el)).toContain('No output.') + expect(sel(el, '.log-empty')).toHaveAttribute('aria-label', 'No output.') expect(sel(el, '.job-sub')).toBeNull() expect(sel(el, '.dot')).toBeNull() }) @@ -160,11 +161,11 @@ describe(' when finished', () => { const failed = mountJob() failed.send('state', { state: 'failed' }) failed.send('result', { ok: false, summary: 'boom' }) - expect(text(failed.el)).toContain('Did not complete') - expect(sel(failed.el, '.job-label')).toHaveTextContent('Failed') + expect(sel(failed.el, 'section')).toHaveAttribute('data-state', 'failed') + expect(sel(failed.el, '.job-label')).toHaveTextContent('⛔') expect(sel(failed.el, '[role=progressbar]')?.className).toContain('bar-danger') const cancelled = mountJob() cancelled.send('state', { state: 'cancelled' }) - expect(sel(cancelled.el, '.job-label')).toHaveTextContent('Cancelled') + expect(sel(cancelled.el, '.job-label')).toHaveTextContent('🚫') }) }) diff --git a/web/src/jobs/jobProgress.ts b/web/src/jobs/jobProgress.ts index 99f5739..32c6322 100644 --- a/web/src/jobs/jobProgress.ts +++ b/web/src/jobs/jobProgress.ts @@ -26,12 +26,12 @@ export function eta(p: Progress, live: LiveJob, now: number): number | null { } const DONE: ReadonlySet = new Set(['succeeded', 'failed', 'cancelled']) -const FINISHED_LABEL: Record<'succeeded' | 'failed' | 'cancelled', string> = { - succeeded: 'Completed', - failed: 'Failed', - cancelled: 'Cancelled', +const DONE_LABEL: Record<'succeeded' | 'failed' | 'cancelled', string> = { + succeeded: '✅', + failed: '⛔', + cancelled: '🚫', } -const isDone = (s: JobState): s is keyof typeof FINISHED_LABEL => DONE.has(s) +const isDone = (s: JobState): s is keyof typeof DONE_LABEL => DONE.has(s) export class JobProgressElement extends LiveElement { private cancel = new Action(() => api.cancelJob(this.jobId), () => { @@ -125,9 +125,10 @@ export class JobProgressElement extends LiveElement { const fraction = finished ? 1 : progress && progress.total !== null && progress.total > 0 ? progress.step / progress.total : null const tone = state === 'failed' ? 'danger' : state === 'succeeded' ? 'success' : waiting ? 'warning' : 'accent' const remaining = progress && !finished && !waiting ? eta(progress, live, now) : null - const title = job ? commandLabel(job.command) : 'Job' + const title = job ? commandLabel(job.command) : '' f.section.className = compact ? 'job job-compact' : 'job' + f.section.dataset.state = state f.section.setAttribute('aria-label', `Job ${job ? commandLabel(job.command) : id}`) f.title.textContent = title f.badge.replaceChildren(stateBadge(state)) @@ -137,21 +138,20 @@ export class JobProgressElement extends LiveElement { ? '' : h( 'span', - { class: live.connected ? 'dot dot-live' : 'dot dot-off', title: live.connected ? 'Live' : 'Reconnecting…' }, - h('span', { class: 'sr-only' }, live.connected ? 'Live' : 'Reconnecting'), + { class: live.connected ? 'dot dot-live' : 'dot dot-off', role: 'img', 'aria-label': live.connected ? 'Live' : 'Reconnecting' }, ), ) f.phase.replaceChildren( h( 'span', { class: 'job-label' }, - isDone(state) ? FINISHED_LABEL[state] : waiting ? 'Waiting for your answer' : (progress?.label ?? 'Starting…'), + isDone(state) ? DONE_LABEL[state] : waiting ? '⏳' : (progress?.label ?? ''), ), !finished && progress && progress.total !== null - ? h('span', { class: 'job-step' }, `Step ${progress.step} of ${progress.total}`) + ? h('span', { class: 'job-step' }, `${progress.step}/${progress.total}`) : '', ) - f.bar.replaceChildren(progressBar(fraction, progress?.label ?? 'Progress', tone)) + f.bar.replaceChildren(progressBar(fraction, progress?.label ?? '', tone)) f.sub.replaceChildren( ...(finished ? [] @@ -163,7 +163,7 @@ export class JobProgressElement extends LiveElement { h( 'span', { class: 'job-eta' }, - remaining !== null ? `about ${clock(remaining)} left` : waiting ? 'paused' : progress?.total === null ? 'working…' : '', + remaining !== null ? clock(remaining) : '', ), ), ]), @@ -171,7 +171,7 @@ export class JobProgressElement extends LiveElement { this.updatePrompt(f, live, state) f.result.replaceChildren( ...(live.result - ? [notice(live.result.ok ? 'success' : 'danger', live.result.ok ? 'Done' : 'Did not complete', live.result.summary)] + ? [notice(live.result.ok ? 'success' : 'danger', null, live.result.summary)] : []), ) f.actions.replaceChildren( @@ -183,8 +183,8 @@ export class JobProgressElement extends LiveElement { { class: 'actions' }, h( 'button', - { type: 'button', class: 'btn btn-ghost', disabled: this.cancel.pending, onclick: () => void this.cancel.run() }, - this.cancel.pending ? 'Cancelling…' : 'Cancel job', + { type: 'button', class: 'btn btn-ghost', disabled: this.cancel.pending, onclick: () => void this.cancel.run(), 'aria-label': this.cancel.pending ? 'Cancelling…' : 'Cancel job' }, + this.cancel.pending ? '⏳' : '✕', ), ), ] @@ -204,14 +204,18 @@ export class JobProgressElement extends LiveElement { private updateLog(f: NonNullable, live: LiveJob, finished: boolean, compact: boolean): void { const logs = live.logs - f.summary.textContent = `Log (${logs.length} lines)` + f.summary.replaceChildren( + h('span', { class: 'log-count-icon', 'aria-hidden': 'true' }, '📋'), + h('span', { class: 'log-count', 'aria-hidden': 'true' }, String(logs.length)), + ) + f.summary.setAttribute('aria-label', `${logs.length} lines`) // Open once on errors or at the end; after that the user owns the toggle. if (!this.autoOpened && !compact && (finished || logs.some((l) => l.level === 'error'))) { f.details.open = true this.autoOpened = true } if (logs.length === 0) { - f.log.replaceChildren(h('li', { class: 'muted' }, finished ? 'No output.' : 'No output yet.')) + f.log.replaceChildren(h('li', { class: 'muted log-empty', 'aria-label': finished ? 'No output.' : 'No output yet.' }, finished ? '—' : '')) this.logCount = 0 return } diff --git a/web/src/jobs/progress.test.ts b/web/src/jobs/progress.test.ts index d9b37ca..6b79a8b 100644 --- a/web/src/jobs/progress.test.ts +++ b/web/src/jobs/progress.test.ts @@ -3,17 +3,18 @@ import type { JobState } from '../api/jobContract' import { progressBar, stateBadge } from './progress' describe('stateBadge', () => { - it.each<[JobState, string, string]>([ - ['queued', 'Queued', 'neutral'], - ['running', 'Running', 'accent'], - ['waiting_input', 'Needs your input', 'warning'], - ['succeeded', 'Succeeded', 'success'], - ['failed', 'Failed', 'danger'], - ['cancelled', 'Cancelled', 'neutral'], - ])('labels %s', (state, label, tone) => { + it.each<[JobState, string]>([ + ['queued', 'neutral'], + ['running', 'accent'], + ['waiting_input', 'warning'], + ['succeeded', 'success'], + ['failed', 'danger'], + ['cancelled', 'neutral'], + ])('labels %s', (state, tone) => { const el = stateBadge(state) - expect(el.textContent).toBe(label) + expect(el.getAttribute('aria-label')).toBe(state) expect(el.className).toBe(`pill pill-${tone}`) + expect(el.querySelector('.pill-icon')).not.toBeNull() }) }) diff --git a/web/src/jobs/progress.ts b/web/src/jobs/progress.ts index 3589307..8d59126 100644 --- a/web/src/jobs/progress.ts +++ b/web/src/jobs/progress.ts @@ -3,13 +3,13 @@ import type { JobState } from '../api/jobContract' import { h } from '../lib/dom' -const STATE_LABEL: Record = { - queued: 'Queued', - running: 'Running', - waiting_input: 'Needs your input', - succeeded: 'Succeeded', - failed: 'Failed', - cancelled: 'Cancelled', +const STATE_ICON: Record = { + queued: '⏸️', + running: '▶️', + waiting_input: '⚠️', + succeeded: '✅', + failed: '⛔', + cancelled: '🚫', } const STATE_TONE: Record = { @@ -22,7 +22,7 @@ const STATE_TONE: Record = { } export function stateBadge(state: JobState): HTMLSpanElement { - return h('span', { class: `pill pill-${STATE_TONE[state]}` }, STATE_LABEL[state]) + return h('span', { class: `pill pill-${STATE_TONE[state]}`, 'aria-label': state }, h('span', { class: 'pill-icon', 'aria-hidden': 'true' }, STATE_ICON[state])) } export type BarTone = 'accent' | 'success' | 'danger' | 'warning' diff --git a/web/src/lib/charts/timeline.test.ts b/web/src/lib/charts/timeline.test.ts index c20a0c4..2c039d8 100644 --- a/web/src/lib/charts/timeline.test.ts +++ b/web/src/lib/charts/timeline.test.ts @@ -37,17 +37,16 @@ describe('timelineChart', () => { it('shows a fallback message with fewer than two games', () => { const box = chart(1) expect(box.textContent).toContain('Not enough games in scope to draw a timeline.') - expect(box.querySelector('h2')?.textContent).toBe('Completion timeline') + expect(box.querySelector('.empty-icon')).not.toBeNull() + expect(box.querySelector('.sr-only')?.textContent).toBe('Not enough games in scope to draw a timeline.') expect(box.querySelector('svg')).toBeNull() }) it('draws an SVG line for the hours basis', () => { const box = chart(3, 'leisure') - expect(box.querySelector('h2')?.textContent).toBe('Completion timeline · leisure') expect(box.querySelector('svg.chart-svg')).not.toBeNull() expect(box.querySelector('path.line')?.getAttribute('d')).toMatch(/^M[\d.]+ [\d.]+ L/) expect(box.querySelector('path.area')?.getAttribute('d')).toMatch(/ Z$/) - expect(box.querySelector('.hint')?.textContent).toMatch(/shortest games first/) }) it('labels the y axis in games and the x axis in dates from now', () => { diff --git a/web/src/lib/charts/timeline.ts b/web/src/lib/charts/timeline.ts index 571e606..e6c5a83 100644 --- a/web/src/lib/charts/timeline.ts +++ b/web/src/lib/charts/timeline.ts @@ -56,7 +56,7 @@ export function timelineSvg(pts: readonly TimelinePoint[], now: Date = new Date( const area = `${path} L${sx(maxDay).toFixed(1)} ${sy(0).toFixed(1)} L${sx(0).toFixed(1)} ${sy(0).toFixed(1)} Z` const fractions = [0, 0.25, 0.5, 0.75, 1] - const root = svg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'chart-svg', role: 'img', 'aria-label': 'Completion timeline' }) + const root = svg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'chart-svg', role: 'img', 'aria-label': 'Games completion timeline chart' }) for (const g of fractions.map((f) => Math.round(f * maxGames))) { const group = svg('g', {}) group.append( @@ -72,7 +72,7 @@ export function timelineSvg(pts: readonly TimelinePoint[], now: Date = new Date( return root } -/** The whole `.chart` box: title, SVG (or why there is none) and caption. */ +/** The whole `.chart` box: SVG (or why there is none) and caption. */ export function timelineChart( result: EstimateResult, filters: Filters, @@ -82,17 +82,13 @@ export function timelineChart( const pts = timelinePoints(result, filters, state) const box = document.createElement('div') box.className = 'chart' - const title = document.createElement('h2') - const hint = document.createElement('p') - hint.className = 'hint' if (pts.length < 2) { - title.textContent = 'Completion timeline' - hint.textContent = 'Not enough games in scope to draw a timeline.' - box.append(title, hint) + const hint = document.createElement('p') + hint.className = 'hint empty-state' + hint.innerHTML = 'Not enough games in scope to draw a timeline.' + box.append(hint) return box } - title.textContent = `Completion timeline · ${filters.basis}` - hint.textContent = 'Cumulative games finished over time (shortest games first).' - box.append(title, timelineSvg(pts, now), hint) + box.append(timelineSvg(pts, now)) return box } diff --git a/web/src/sections/backlog/planner.test.ts b/web/src/sections/backlog/planner.test.ts index e2b411e..06bc5ce 100644 --- a/web/src/sections/backlog/planner.test.ts +++ b/web/src/sections/backlog/planner.test.ts @@ -28,7 +28,6 @@ describe(' planner', () => { expect(big()).toBe('2') await user.click(panel().getByRole('button', { name: 'Rush' })) expect(document.querySelector('.card.active .card-title')?.textContent).toBe('Rush') - expect(document.querySelector('.chart h2')?.textContent).toBe('Completion timeline · rush') expect(hash().get('basis')).toBe('rush') const box = within(table()).getAllByRole('checkbox')[0] @@ -42,13 +41,13 @@ describe(' planner', () => { expect(big()).toBe('2') expect(box.closest('tr')).not.toHaveClass('excluded') - await user.type(screen.getByPlaceholderText(/Search games/i), 'Alpha') + await user.type(screen.getByLabelText('Search games'), 'Alpha') expect(names()).toEqual(['Alpha']) await user.click(screen.getByRole('button', { name: /Reset to CLI defaults/i })) expect(big()).toBe('2') expect(names()).toHaveLength(2) - expect(screen.getByPlaceholderText(/Search games/i)).toHaveValue('') + expect(screen.getByLabelText('Search games')).toHaveValue('') expect(location.hash).toBe('') }) @@ -85,11 +84,11 @@ describe(' planner', () => { it('restores sort, search, column and planner filters from the hash on load', async () => { history.replaceState(null, '', '/backlog/#sort=name&dir=desc&q=a&f.rush=1..15&f.proton=gold&basis=worst') await show() - expect(screen.getByPlaceholderText(/Search games/i)).toHaveValue('a') + expect(screen.getByLabelText('Search games')).toHaveValue('a') expect(screen.getByRole('spinbutton', { name: 'Rush min' })).toHaveValue(1) expect(screen.getByRole('spinbutton', { name: 'Rush max' })).toHaveValue(15) expect(screen.getByRole('combobox', { name: 'Filter ProtonDB' })).toHaveValue('gold') - expect(header('Game').textContent).toBe('Game ▼') + expect(header('Game').textContent).toBe('🎮 ▼') expect(panel().getByRole('button', { name: 'Worst' })).toHaveClass('active') expect(names()).toEqual(['Alpha']) }) diff --git a/web/src/sections/backlog/plannerTable.test.ts b/web/src/sections/backlog/plannerTable.test.ts index f6f1f42..0ff7942 100644 --- a/web/src/sections/backlog/plannerTable.test.ts +++ b/web/src/sections/backlog/plannerTable.test.ts @@ -11,14 +11,14 @@ afterEach(() => { describe(' table', () => { it('renders the row count', async () => { await show() - expect(screen.getByText('Games (3)')).toBeInTheDocument() + expect(document.querySelector('.games-count')?.textContent).toBe('3') }) it('sorts by leisure ascending by default', async () => { await show() expect(names()).toEqual(['Beta', 'Gamma', 'Alpha']) expect(header('Leisure')).toHaveAttribute('aria-sort', 'ascending') - expect(header('Leisure').textContent).toBe('Leisure ▲') + expect(header('Leisure').textContent).toBe('🐢 ▲') }) it('toggles sort direction when a header is clicked', async () => { @@ -50,13 +50,13 @@ describe(' table', () => { it('filters by a typo-tolerant search and keeps focus while typing', async () => { const { user } = await show() - const box = screen.getByPlaceholderText(/Search games/i) + const box = screen.getByLabelText('Search games') await user.type(box, 'gama') expect(names()).toEqual(['Gamma']) expect(document.activeElement).toBe(box) - expect(screen.getByPlaceholderText(/Search games/i)).toBe(box) + expect(screen.getByLabelText('Search games')).toBe(box) expect(hash().get('q')).toBe('gama') - expect(screen.getByText('Games (1)')).toBeInTheDocument() + expect(document.querySelector('.games-count')?.textContent).toBe('1') }) it('narrows rows by a text column filter without losing focus', async () => { @@ -116,12 +116,12 @@ describe(' table', () => { it('caps the table and notes the overflow', async () => { await show(Array.from({ length: 301 }, (_, i) => makeGame({ app_id: i + 1, name: `Game ${i}` }))) - expect(screen.getByText(/Showing first 300 of 301/i)).toBeVisible() + expect(screen.getByText('300/301')).toBeVisible() expect(table().querySelectorAll('tbody tr')).toHaveLength(300) }) it('hides the overflow note under the cap', async () => { await show() - expect(screen.getByText(/Showing first 300/i)).not.toBeVisible() + expect(document.querySelector('.overflow-hint')).not.toBeVisible() }) }) diff --git a/web/src/sections/backlog/plannerTable.ts b/web/src/sections/backlog/plannerTable.ts index 456b45c..7c99d42 100644 --- a/web/src/sections/backlog/plannerTable.ts +++ b/web/src/sections/backlog/plannerTable.ts @@ -36,16 +36,30 @@ export function hltbUrl(game: WebGame): string { const optionLabel = (c: Column, v: string) => (c.key === 'keep' ? (v === 'kept' ? 'Kept' : 'Excluded') : v) +// Textgate: column headers are pictograms; the column name is the button's +// aria-label (spoken only), the sort arrow stays visible. +const COLUMN_ICON: Record = { + name: '🎮', + completion: '%', + playtime: '⏱', + rush: '🐇', + leisure: '🐢', + worst: '🐌', + count_comp: '#', + proton: '🐧', + keep: '📌', +} + export class PlannerTable { readonly el: HTMLElement private readonly opts: PlannerTableOptions - private readonly count = h('h2') - private readonly search = h('input', { type: 'search', placeholder: 'Search games…', 'aria-label': 'Search games' }) + private readonly count = h('span', { class: 'games-count', 'aria-label': 'Visible games' }) + private readonly search = h('input', { type: 'search', 'aria-label': 'Search games', placeholder: '' }) private readonly heads: [Column, HTMLTableCellElement, HTMLButtonElement][] = [] private readonly inputs = new Map() private readonly selects: [Column, HTMLSelectElement][] = [] private readonly tbody = h('tbody') - private readonly overflow = h('p', { class: 'hint' }) + private readonly overflow = h('span', { class: 'overflow-hint' }) private readonly rows = new Map() constructor(opts: PlannerTableOptions) { @@ -66,13 +80,15 @@ export class PlannerTable { show(all: readonly GameRow[], visible: readonly GameRow[], table: TableState): void { this.syncOptions(all, table) this.syncHeads(table) - this.count.textContent = `Games (${visible.length})` + this.count.textContent = String(visible.length) + this.count.setAttribute('aria-label', `${visible.length} visible games`) const trs = visible.slice(0, DISPLAY_CAP).map((r) => this.rowFor(r)) const current = this.tbody.children // Re-order only when the order changed: moving a row would blur its checkbox. if (trs.length !== current.length || trs.some((tr, i) => current[i] !== tr)) this.tbody.replaceChildren(...trs) this.overflow.hidden = visible.length <= DISPLAY_CAP - this.overflow.textContent = `Showing first ${DISPLAY_CAP} of ${visible.length}. Use search to narrow.` + this.overflow.textContent = `${DISPLAY_CAP}/${visible.length}` + this.overflow.setAttribute('aria-label', `Showing first ${DISPLAY_CAP} of ${visible.length} games`) } private build(): HTMLElement { @@ -86,7 +102,7 @@ export class PlannerTable { } if (c.filter === 'range') { const bound = (side: 'min' | 'max') => - field(`${c.key}.${side}`, h('input', { type: 'number', inputmode: 'decimal', placeholder: side, 'aria-label': `${c.label} ${side}` })) + field(`${c.key}.${side}`, h('input', { type: 'number', inputmode: 'decimal', placeholder: side === 'min' ? '≥' : '≤', 'aria-label': `${c.label} ${side}` })) return h('td', { class: 'range-filter' }, h('div', {}, bound('min'), bound('max'))) } if (c.filter === 'select') { @@ -94,11 +110,11 @@ export class PlannerTable { this.selects.push([c, select]) return h('td', {}, field(c.key, select)) } - return h('td', {}, field(c.key, h('input', { type: 'search', placeholder: 'Filter…', 'aria-label': `Filter ${c.label}` }))) + return h('td', {}, field(c.key, h('input', { type: 'search', placeholder: '', 'aria-label': `Filter ${c.label}` }))) } const headCell = (c: Column): HTMLTableCellElement => { - const button = h('button', { type: 'button', class: 'sort-btn' }, c.label) - const th = h('th', { class: c.numeric ? 'num clickable' : 'clickable', scope: 'col' }, button) + const button = h('button', { type: 'button', class: 'sort-btn', 'aria-label': c.label }, COLUMN_ICON[c.key]) + const th = h('th', { class: c.numeric ? 'num clickable' : 'clickable', scope: 'col', 'aria-label': c.label }, button) th.addEventListener('click', () => { this.opts.onSort(c.key) }) @@ -177,7 +193,7 @@ export class PlannerTable { for (const [c, th, button] of this.heads) { const active = c.key === table.sort const arrow = active ? (table.asc ? ' ▲' : ' ▼') : '' - button.textContent = `${c.label}${arrow}` + button.textContent = `${COLUMN_ICON[c.key]}${arrow}` if (active) th.setAttribute('aria-sort', table.asc ? 'ascending' : 'descending') else th.removeAttribute('aria-sort') } diff --git a/web/src/sections/dashboard.test.ts b/web/src/sections/dashboard.test.ts index 2a87e41..8203764 100644 --- a/web/src/sections/dashboard.test.ts +++ b/web/src/sections/dashboard.test.ts @@ -233,7 +233,7 @@ describe('Dashboard', () => { expect(document.querySelectorAll('.rows .row-link')).toHaveLength(5) const first = document.querySelector('a[href="/jobs/?id=a"]') expect(first).toHaveTextContent('Scan') - expect(first).toHaveTextContent('Succeeded') + expect(first?.querySelector('.pill')).toHaveAttribute('aria-label', 'succeeded') expect(screen.queryByText(/No jobs yet/)).not.toBeInTheDocument() }) diff --git a/web/src/sections/jobs.test.ts b/web/src/sections/jobs.test.ts index c85e505..268c1fe 100644 --- a/web/src/sections/jobs.test.ts +++ b/web/src/sections/jobs.test.ts @@ -41,7 +41,7 @@ describe('jobs list', () => { expect(screen.getByText('1:05')).toBeInTheDocument() expect(screen.getByText('Picked 5')).toBeInTheDocument() expect(screen.getByText('Scanning')).toBeInTheDocument() - expect(screen.getAllByText('Running')).toHaveLength(2) + expect(screen.getAllByLabelText('running')).toHaveLength(2) }) it('keeps the table and its links across ticks, updating only durations', async () => { diff --git a/web/src/test/events.ts b/web/src/test/events.ts index cb4096d..45c2afa 100644 --- a/web/src/test/events.ts +++ b/web/src/test/events.ts @@ -7,9 +7,11 @@ export function setValue(input: HTMLInputElement, value: string): void { input.dispatchEvent(new Event('input', { bubbles: true })) } -/** The button inside `root` whose text is exactly `text`. */ +/** The button inside `root` whose text or (icon-only buttons) aria-label is exactly `text`. */ export function button(root: ParentNode, text: string): HTMLButtonElement { - const found = [...root.querySelectorAll('button')].find((b) => b.textContent === text) + const found = [...root.querySelectorAll('button')].find( + (b) => b.textContent === text || b.getAttribute('aria-label') === text, + ) if (!found) throw new Error(`no button "${text}"`) return found } diff --git a/web/src/test/textgate.guard.test.ts b/web/src/test/textgate.guard.test.ts index 1eaf520..3db126d 100644 --- a/web/src/test/textgate.guard.test.ts +++ b/web/src/test/textgate.guard.test.ts @@ -15,7 +15,7 @@ const WORD_RE = /[A-Za-z]{2,}/ function spokenOnly(el: Element | null): boolean { for (let cur = el; cur; cur = cur.parentElement) { - if (cur.classList.contains('sr-only') || cur.getAttribute('aria-hidden') === 'true' || cur.hidden) return true + if (cur.classList.contains('sr-only') || cur.getAttribute('aria-hidden') === 'true' || (cur instanceof HTMLElement && cur.hidden)) return true } return false } diff --git a/web/src/ui/build.clipboard.test.ts b/web/src/ui/build.clipboard.test.ts index 0462f4c..1d914ef 100644 --- a/web/src/ui/build.clipboard.test.ts +++ b/web/src/ui/build.clipboard.test.ts @@ -10,10 +10,11 @@ describe('copyButton without a clipboard', () => { vi.stubGlobal('navigator', {}) const errors = vi.fn() window.addEventListener('error', errors) - const btn = copyButton('abc', 'Copy it') + const btn = copyButton('abc') btn.click() window.removeEventListener('error', errors) expect(errors).not.toHaveBeenCalled() - expect(btn.textContent).toBe('Copy it') + // Icon stays as clipboard icon since copy failed + expect(btn.textContent).toBe('📋') }) }) diff --git a/web/src/ui/build.test.ts b/web/src/ui/build.test.ts index f82ab9d..d6c621c 100644 --- a/web/src/ui/build.test.ts +++ b/web/src/ui/build.test.ts @@ -15,27 +15,31 @@ describe('notice', () => { expect(full.querySelector('.notice-title')?.textContent).toBe('Title') expect(full.querySelector('.notice-text')?.textContent).toBe('body') const bare = notice('success', null) - expect(bare.children).toHaveLength(0) + expect(bare.querySelector('.notice-icon')).not.toBeNull() + expect(bare.querySelector('.notice-text')).toBeNull() + expect(bare.querySelector('.notice-title')).toBeNull() }) }) describe('errorNotice', () => { it('explains an ApiFailure with detail, hint and extras', () => { const el = errorNotice(new ApiFailure('busy', 'Job j1 runs', 409), h('em', {}, 'extra')) - expect(el).toHaveTextContent('Another job is running') + expect(el).toHaveAttribute('data-code', 'busy') + expect(el.getAttribute('aria-label')).toContain('Another job is running') + expect(el.getAttribute('aria-label')).toContain('Only one state-changing job') expect(el).toHaveTextContent('Job j1 runs') - expect(el).toHaveTextContent('Only one state-changing job') + expect(el).not.toHaveTextContent('Another job is running') expect(el.querySelector('em')).not.toBeNull() }) it('omits the detail line when the message is empty', () => { const el = errorNotice(new ApiFailure('busy', '', 409)) - expect(el.querySelectorAll('p')).toHaveLength(1) + expect(el.querySelectorAll('p')).toHaveLength(0) }) }) describe('copyButton and commandLine', () => { - it('copies, shows Copied, then restores the label', async () => { + it('copies, shows check icon, then restores clipboard icon', async () => { vi.useFakeTimers() const writeText = vi.fn(() => Promise.resolve()) vi.stubGlobal('navigator', { clipboard: { writeText } }) @@ -43,13 +47,11 @@ describe('copyButton and commandLine', () => { btn.click() await vi.advanceTimersByTimeAsync(0) expect(writeText).toHaveBeenCalledWith('sudo x') - expect(btn.textContent).toBe('Copied') + expect(btn.textContent).toBe('✅') + expect(btn.getAttribute('aria-label')).toBe('Copied') await vi.advanceTimersByTimeAsync(2000) - expect(btn.textContent).toBe('Copy') - }) - - it('uses a custom label', () => { - expect(copyButton('x', 'Grab').textContent).toBe('Grab') + expect(btn.textContent).toBe('📋') + expect(btn.getAttribute('aria-label')).toBe('Copy command') }) it('commandLine shows the command beside its copy button', () => { @@ -60,27 +62,31 @@ describe('copyButton and commandLine', () => { }) describe('small builders', () => { - it('loading names what is loading', () => { + it('loading has a spinner and sr-only label', () => { const el = loading('jobs') - expect(el).toHaveTextContent('Loading jobs…') + expect(el.querySelector('.spinner')).not.toBeNull() + expect(el.querySelector('.sr-only')?.textContent).toBe('Loading jobs') expect(el.getAttribute('role')).toBe('status') }) - it('empty, pill and link', () => { - expect(empty('Nothing').className).toBe('empty') - expect(pill('danger', 'x').className).toBe('pill pill-danger') + it('empty has an icon and passes through children', () => { + const el = empty('Nothing') + expect(el.className).toBe('empty') + expect(el.querySelector('.empty-icon')?.textContent).toBe('📭') + }) + + it('pill has an icon for known tones', () => { + const el = pill('danger', 'x') + expect(el.className).toBe('pill pill-danger') + expect(el.querySelector('.pill-icon')).not.toBeNull() + }) + + it('link is an anchor', () => { const a = link('/library/', 'Open') expect(a.getAttribute('href')).toBe('/library/') expect(a.className).toBe('btn btn-ghost btn-small') expect(link('/x/', 'Y', 'custom').className).toBe('custom') }) - - it('facts lays out label/value rows', () => { - const el = facts([['A', '1'], ['B', h('b', {}, '2')]]) - expect(el.querySelectorAll('.fact')).toHaveLength(2) - expect(el.querySelector('dt')?.textContent).toBe('A') - expect(el.querySelectorAll('dd')[1].textContent).toBe('2') - }) }) describe('card', () => { @@ -96,7 +102,7 @@ describe('card', () => { const asideOnly = card(null, 'b', { aside: 'right' }) expect(asideOnly.querySelector('h2')).toBeNull() expect(asideOnly.querySelector('.box-head')).toHaveTextContent('right') - expect(asideOnly.hasAttribute('aria-label')).toBe(false) + expect(asideOnly.getAttribute('aria-label')).toBe('') const titleOnly = card('T', 'b') expect(titleOnly.className).toBe('box') expect(titleOnly.querySelector('.box-head')?.children).toHaveLength(1) @@ -105,3 +111,12 @@ describe('card', () => { expect(none).toHaveTextContent('b') }) }) + +describe('facts', () => { + it('lays out label/value rows', () => { + const el = facts([['A', '1'], ['B', h('b', {}, '2')]]) + expect(el.querySelectorAll('.fact')).toHaveLength(2) + expect(el.querySelector('dt')?.textContent).toBe('A') + expect(el.querySelectorAll('dd')[1].textContent).toBe('2') + }) +}) diff --git a/web/src/ui/build.ts b/web/src/ui/build.ts index f679ff7..fcd7ba1 100644 --- a/web/src/ui/build.ts +++ b/web/src/ui/build.ts @@ -7,36 +7,46 @@ import { h, type Child } from '../lib/dom' export type Tone = 'info' | 'success' | 'warning' | 'danger' +const TONE_ICON: Record = { + info: 'ℹ️', + success: '✅', + warning: '⚠️', + danger: '⛔', +} + export function notice(tone: Tone, title: string | null, ...children: Child[]): HTMLDivElement { - return h( + const el = h( 'div', { class: `notice notice-${tone}`, role: tone === 'danger' ? 'alert' : 'status' }, + h('span', { class: 'notice-icon', 'aria-hidden': 'true' }, TONE_ICON[tone]), title ? h('strong', { class: 'notice-title' }, title) : null, - children.length ? h('div', { class: 'notice-text' }, ...children) : null, ) + if (children.length) { + el.append(h('div', { class: 'notice-text' }, ...children)) + } + return el } -/** An ApiFailure (or anything thrown) as a titled, actionable message. */ export function errorNotice(error: unknown, ...children: Child[]): HTMLDivElement { + // Textgate: the explanation (title + hint) is spoken only; the ⛔ icon, + // danger tone and the server's own message stay visible. const e = explain(error) - return notice( - 'danger', - e.title, - e.detail ? h('p', { class: 'm0' }, e.detail) : null, - h('p', { class: 'm0 muted' }, e.hint), - ...children, - ) + const el = notice('danger', null, e.detail ? h('p', { class: 'm0' }, e.detail) : null, ...children) + el.dataset.code = e.code + el.setAttribute('aria-label', `${e.title}. ${e.hint}`) + return el } -export function copyButton(text: string, label = 'Copy'): HTMLButtonElement { - const btn = h('button', { type: 'button', class: 'btn btn-ghost btn-small' }, label) +export function copyButton(text: string): HTMLButtonElement { + const btn = h('button', { type: 'button', class: 'btn btn-ghost btn-small', 'aria-label': 'Copy command' }, '📋') btn.addEventListener('click', () => { - // No clipboard API outside a secure context: the command stays selectable. if (!('clipboard' in navigator)) return void navigator.clipboard.writeText(text).then(() => { - btn.textContent = 'Copied' + btn.textContent = '✅' + btn.setAttribute('aria-label', 'Copied') setTimeout(() => { - btn.textContent = label + btn.textContent = '📋' + btn.setAttribute('aria-label', 'Copy command') }, 2000) }) }) @@ -49,11 +59,17 @@ export function commandLine(command: string): HTMLDivElement { } export function loading(what: string): HTMLParagraphElement { - return h('p', { class: 'loading', role: 'status' }, h('span', { class: 'spinner', 'aria-hidden': 'true' }), ` Loading ${what}…`) + return h('p', { class: 'loading', role: 'status' }, h('span', { class: 'spinner', 'aria-hidden': 'true' }), h('span', { class: 'sr-only' }, `Loading ${what}`)) } -export function empty(...children: Child[]): HTMLParagraphElement { - return h('p', { class: 'empty' }, ...children) +/** Empty-state marker: 📭 painted, the caller's sentence spoken only. */ +export function empty(label: string): HTMLParagraphElement { + return h( + 'p', + { class: 'empty', 'aria-label': label }, + h('span', { class: 'empty-icon', 'aria-hidden': 'true' }, '📭'), + ...(label ? [h('span', { class: 'sr-only' }, label)] : []), + ) } export interface CardOptions { @@ -64,7 +80,7 @@ export interface CardOptions { export function card(title: string | null, body: Child, { aside, className = '' }: CardOptions = {}): HTMLElement { const head = title || aside ? h('div', { class: 'box-head' }, title ? h('h2', {}, title) : null, aside ?? null) : null - return h('section', { class: `box ${className}`.trim(), 'aria-label': title }, head, body) + return h('section', { class: `box ${className}`.trim(), 'aria-label': title ?? '' }, head, body) } /** Label/value rows; values are right-aligned so they line up across cards. */ @@ -76,8 +92,20 @@ export function facts(rows: readonly (readonly [Child, Child])[]): HTMLDListElem ) } +const TONE_PILL_ICON: Record = { + info: 'ℹ️', + success: '✅', + warning: '⚠️', + danger: '⛔', + kept: '📌', + excluded: '🚫', + idle: '⏸️', + running: '▶️', +} + export function pill(tone: string, text: string): HTMLSpanElement { - return h('span', { class: `pill pill-${tone}` }, text) + const icon = TONE_PILL_ICON[tone] ?? '' + return h('span', { class: `pill pill-${tone}` }, icon && h('span', { class: 'pill-icon', 'aria-hidden': 'true' }, icon), text) } export function link(href: string, text: Child, className = 'btn btn-ghost btn-small'): HTMLAnchorElement {