web: runner, palette and countdown paint glyphs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-11 11:54:44 +01:00
parent 291a5ed26f
commit 30416da9b8
14 changed files with 189 additions and 65 deletions

View File

@ -55,7 +55,7 @@ describe('<command-button>', () => {
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')

View File

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

View File

@ -28,7 +28,9 @@ function open(specs = SPECS) {
const box = () => must(document.querySelector<HTMLInputElement>('[role=combobox]'))
const options = () => [...document.querySelectorAll<HTMLElement>('[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())
})

View File

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

View File

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

View File

@ -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<string, string> = {
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)))
}

View File

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

View File

@ -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<HTMLElement>('[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)] : []))
}

View File

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

View File

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

View File

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

View File

@ -24,8 +24,14 @@ describe('<runner-dialog> 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('<runner-dialog> 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('<runner-dialog> 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()

View File

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

View File

@ -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<Record<CommandName, string>> = {
}
/** ⏳ 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<void> {