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 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-11 02:20:43 +01:00
parent a80652e91a
commit f5f921ebf4
18 changed files with 210 additions and 147 deletions

View File

@ -45,14 +45,14 @@ describe('<runner-dialog> 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 () => {

View File

@ -11,7 +11,8 @@ describe('<job-progress> 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('<job-progress> 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('<job-progress> 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')
})
})

View File

@ -27,10 +27,11 @@ describe('eta', () => {
describe('<job-progress>', () => {
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('<job-progress>', () => {
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('<job-progress>', () => {
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('<job-progress>', () => {
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 = '<job-progress></job-progress>'
expect(document.body.firstElementChild?.querySelector('h3')).toHaveTextContent('Job')
expect(document.body.firstElementChild?.querySelector('section')).toHaveAttribute('aria-label', 'Job ')
})
})
@ -101,8 +101,9 @@ describe('<job-progress> 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('<job-progress> 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('<job-progress> 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('🚫')
})
})

View File

@ -26,12 +26,12 @@ export function eta(p: Progress, live: LiveJob, now: number): number | null {
}
const DONE: ReadonlySet<JobState> = 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<JobProgressElement['frame']>, 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
}

View File

@ -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()
})
})

View File

@ -3,13 +3,13 @@
import type { JobState } from '../api/jobContract'
import { h } from '../lib/dom'
const STATE_LABEL: Record<JobState, string> = {
queued: 'Queued',
running: 'Running',
waiting_input: 'Needs your input',
succeeded: 'Succeeded',
failed: 'Failed',
cancelled: 'Cancelled',
const STATE_ICON: Record<JobState, string> = {
queued: '⏸️',
running: '▶️',
waiting_input: '⚠️',
succeeded: '✅',
failed: '⛔',
cancelled: '🚫',
}
const STATE_TONE: Record<JobState, string> = {
@ -22,7 +22,7 @@ const STATE_TONE: Record<JobState, string> = {
}
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'

View File

@ -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', () => {

View File

@ -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 = '<span class="empty-icon" aria-hidden="true">📭</span><span class="sr-only">Not enough games in scope to draw a timeline.</span>'
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
}

View File

@ -28,7 +28,6 @@ describe('<game-table> 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('<game-table> 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('<game-table> 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'])
})

View File

@ -11,14 +11,14 @@ afterEach(() => {
describe('<game-table> 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('<game-table> 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('<game-table> 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()
})
})

View File

@ -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<ColumnKey, string> = {
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<string, HTMLInputElement | HTMLSelectElement>()
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<number, RowEls>()
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')
}

View File

@ -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()
})

View File

@ -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 () => {

View File

@ -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
}

View File

@ -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
}

View File

@ -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('📋')
})
})

View File

@ -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')
})
})

View File

@ -7,36 +7,46 @@ import { h, type Child } from '../lib/dom'
export type Tone = 'info' | 'success' | 'warning' | 'danger'
const TONE_ICON: Record<Tone, string> = {
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<string, string> = {
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 {