mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 19:13:49 +01:00
web: runner, palette and countdown paint glyphs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
291a5ed26f
commit
30416da9b8
@ -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')
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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())
|
||||
})
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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)] : []))
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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' }, '⚙️'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@ -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> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user