web: command buttons paint glyphs only

Labels, descriptions and lock reasons are spoken only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-11 11:36:41 +01:00
parent 582ce6b2b6
commit 13df1dd14d
7 changed files with 127 additions and 36 deletions

View File

@ -44,6 +44,36 @@ export const COMMAND_HOME = {
'restore-backup': '/system',
} as const satisfies Record<CommandName, SectionPath>
/** The glyph a command's button paints (textgate: its label is spoken only). */
export const COMMAND_ICON = {
status: '📊',
list: '📋',
stats: '📈',
installed: '💾',
'gaming-status': '⏱️',
check: '🔄',
scan: '🔎',
done: '🏆',
pick: '🎲',
'pick-manual': '📌',
'abandon-pick': '❌',
install: '⬇️',
uninstall: '🗑️',
hide: '🙈',
unhide: '👁️',
'buy-dlc': '💳',
unblock: '🔓',
'add-exception': '🎫',
'block-gaming': '⛔',
reset: '♻️',
setup: '🔧',
enforce: '🛡️',
'gaming-reset': '⏪',
'gaming-unblock': '🔌',
serve: '🌐',
'restore-backup': '🗄️',
} as const satisfies Record<CommandName, string>
/** Title-case label for buttons and the palette (`pick-manual` → `Pick manual`). */
export function commandLabel(name: CommandName): string {
const s = name.replace(/-/g, ' ')

View File

@ -26,7 +26,7 @@ describe('<command-button>', () => {
loaded([makeSpec('scan')])
const el = mount(`<command-button name="scan" preset='{"app_id":4}'></command-button>`)
await vi.waitFor(() => {
expect(el.querySelector('button')).toHaveTextContent('Run scan')
expect(el.querySelector('button')).toHaveAttribute('aria-label', 'Run scan')
})
const btn = must(el.querySelector('button'))
expect(btn).toHaveAttribute('aria-disabled', 'false')
@ -39,7 +39,7 @@ describe('<command-button>', () => {
loaded([])
const el = mount('<command-button></command-button>')
const btn = must(el.querySelector('button'))
expect(btn).toHaveTextContent('Open Status')
expect(btn).toHaveAttribute('aria-label', 'Open Status')
expect(btn).toHaveAttribute('aria-disabled', 'true')
btn.click()
await vi.waitFor(() => {
@ -52,10 +52,12 @@ describe('<command-button>', () => {
loaded([makeSpec('pick', { locked_reason: 'Locked by a manual pick' })])
const el = mount('<command-button name="pick" label="Pick it" tone="danger"></command-button>')
const btn = must(el.querySelector('button'))
expect(btn).toHaveTextContent('Pick it')
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('.cmd-lock')).not.toBeNull()
expect(el.querySelector('button')).toHaveAttribute('aria-disabled', 'true')
expect(el.querySelector('button')).toHaveClass('btn-danger')
el.querySelector('button')?.click()
@ -69,7 +71,7 @@ describe('<command-button>', () => {
expect(el.querySelector('a')).not.toBeNull()
})
const link = must(el.querySelector('a'))
expect(link).toHaveTextContent('Open Status')
expect(link).toHaveAttribute('aria-label', 'Open Status')
expect(link).toHaveAttribute('href', '/')
expect(link).toHaveClass('btn-primary')
})
@ -78,7 +80,7 @@ describe('<command-button>', () => {
loaded([makeSpec('scan')])
const el = mount('<command-button name="scan"></command-button>')
await vi.waitFor(() => {
expect(el.querySelector('button')).toHaveTextContent('Run scan')
expect(el.querySelector('button')).toHaveAttribute('aria-label', 'Run scan')
})
el.querySelector('button')?.click()
expect(requests).toEqual([{ name: 'scan' }])
@ -109,9 +111,9 @@ describe('<command-card>', () => {
loaded([makeSpec('gaming-reset', { privileged: true, friction: { phrase_template: 'x', countdown_seconds: 300 } })])
const el = mount('<command-card name="gaming-reset"></command-card>')
await vi.waitFor(() => {
expect(el).toHaveTextContent('root')
expect(el.querySelector('[aria-label="root"]')).not.toBeNull()
})
expect(el).toHaveTextContent('countdown')
expect(el.querySelector('[aria-label="countdown"]')).not.toBeNull()
})
it('shows no countdown pill for a friction command without one', async () => {

View File

@ -9,8 +9,7 @@ import { q } from '../api/queries'
import { define, h, jsonAttr } from '../lib/dom'
import { LiveElement } from '../lib/element'
import { pageHref, runner, type Preset } from '../lib/runner'
import { pill } from '../ui/build'
import { COMMAND_HOME, commandLabel, findCommand } from './catalog'
import { COMMAND_HOME, COMMAND_ICON, commandLabel, findCommand } from './catalog'
export interface ButtonProps {
name: CommandName
@ -34,23 +33,34 @@ function props(el: Element): ButtonProps {
export function commandButton({ name, preset, label, tone = 'ghost' }: ButtonProps): HTMLElement {
const spec = findCommand(q.commands.state.data, name)
const locked = spec?.locked_reason ?? null
const text = label ?? (spec?.kind === 'job' ? `Run ${name}` : `Open ${commandLabel(name)}`)
if (spec && spec.kind !== 'job') return h('a', { href: pageHref(COMMAND_HOME[name]), class: `btn btn-${tone}` }, text)
// Textgate: the glyph is painted, the label is spoken only.
const spoken = label ?? (spec?.kind === 'job' ? `Run ${name}` : `Open ${commandLabel(name)}`)
const glyph = h('span', { class: 'cmd-icon', 'aria-hidden': 'true' }, COMMAND_ICON[name])
if (spec && spec.kind !== 'job') {
return h('a', { href: pageHref(COMMAND_HOME[name]), class: `btn btn-${tone}`, 'aria-label': spoken }, glyph)
}
return h(
'button',
{
type: 'button',
class: `btn btn-${tone}`,
'aria-label': spoken,
'aria-disabled': String(locked !== null || !spec),
title: locked,
onclick: () => {
if (spec && !locked) runner.run(name, preset)
},
},
text,
glyph,
locked === null ? null : h('span', { class: 'cmd-lock', 'aria-hidden': 'true' }, '🔒'),
)
}
/** A card flag (root, countdown): glyph painted, its word spoken. */
function flag(glyph: string, spoken: string): HTMLSpanElement {
return h('span', { class: 'pill pill-neutral', 'aria-label': spoken, role: 'img' }, glyph)
}
export class CommandButtonElement extends LiveElement {
protected override setup(): void {
this.watch(q.commands)
@ -77,11 +87,11 @@ export class CommandCardElement extends LiveElement {
h(
'h3',
{ class: 'cmd-name' },
commandLabel(p.name),
spec?.privileged ? pill('neutral', 'root') : null,
spec?.friction?.countdown_seconds ? pill('warning', 'countdown') : null,
h('span', { class: 'sr-only' }, commandLabel(p.name)),
spec?.privileged ? flag('🔑', 'root') : null,
spec?.friction?.countdown_seconds ? flag('⏳', 'countdown') : null,
),
h('p', { class: 'cmd-desc' }, locked ?? spec?.description ?? '…'),
h('p', { class: 'cmd-desc sr-only' }, locked ?? spec?.description ?? '…'),
commandButton(p),
),
)

View File

@ -145,3 +145,5 @@
.countdown-clock { display: flex; align-items: baseline; gap: var(--space-3); }
.countdown-num { font-size: var(--text-display); letter-spacing: var(--tracking-display); font-weight: 650; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.row-link > .muted:not(.grow) { white-space: nowrap; }
.cmd-icon { font-size: var(--text-subtitle); line-height: 1; }
.cmd-lock { margin-left: var(--space-1); font-size: var(--text-label); }

View File

@ -0,0 +1,40 @@
// Guard test (textgate-rework R6): command buttons and cards paint a glyph,
// never their label, description or lock reason; those are spoken only.
import { describe, expect, it, vi } from 'vitest'
import { mount, stubApi } from './harness'
import { makeSpec } from './fixtures'
import { visibleWords } from './visibleWords'
import '../commands/commandButton'
describe('textgate guard: commands', () => {
it('a labelled command button paints no words', async () => {
stubApi({ 'GET /api/commands': [makeSpec('done')] })
const el = mount('<command-button name="done" label="I got an achievement"></command-button>')
await vi.waitFor(() => {
expect(el.querySelector('button')).toHaveAttribute('aria-disabled', 'false')
})
expect(visibleWords(el)).toEqual([])
})
it('a view command link paints no words', async () => {
stubApi({ 'GET /api/commands': [makeSpec('status', { kind: 'view' })] })
const el = mount('<command-button name="status"></command-button>')
await vi.waitFor(() => {
expect(el.querySelector('a')).not.toBeNull()
})
expect(visibleWords(el)).toEqual([])
})
it('a locked, privileged countdown card paints no words', async () => {
const friction = { phrase_template: 'x', countdown_seconds: 300 }
stubApi({
'GET /api/commands': [makeSpec('gaming-reset', { privileged: true, friction, locked_reason: 'Locked by a pick' })],
})
const el = mount('<command-card name="gaming-reset" label="Reset today…"></command-card>')
await vi.waitFor(() => {
expect(el.querySelector('article')).toHaveClass('locked')
})
expect(visibleWords(el)).toEqual([])
})
})

View File

@ -5,32 +5,13 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { installEventSource } from './harness'
import { mountJob } from './jobHelpers'
import { visibleWords } from './visibleWords'
import { ApiFailure } from '../api/client'
import { timelineChart } from '../lib/charts/timeline'
import { errorNotice, loading } from '../ui/build'
import type { EstimateResult } from '../estimate'
import type { Filters } from '../types'
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 instanceof HTMLElement && cur.hidden)) return true
}
return false
}
/** Visible text nodes holding a word of 2+ letters, minus `allowed` content. */
function visibleWords(root: Element, allowed: readonly string[] = []): string[] {
const out: string[] = []
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
const t = n.textContent?.trim() ?? ''
if (!WORD_RE.test(t) || spokenOnly(n.parentElement) || allowed.includes(t)) continue
out.push(t)
}
return out
}
beforeEach(installEventSource)

View File

@ -0,0 +1,26 @@
// Textgate guard helper (textgate-rework R6): the words an element paints.
// Spoken-only text (.sr-only, [hidden]) and content the caller passes in
// `allowed` (game names, server messages) do not count. aria-hidden text
// still paints, so it counts.
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')) return true
if (cur instanceof HTMLElement && cur.hidden) return true
}
return false
}
/** Visible text nodes holding a word of 2+ letters, minus `allowed` content. */
export function visibleWords(root: Element, allowed: readonly string[] = []): string[] {
const out: string[] = []
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
const t = n.textContent?.trim() ?? ''
if (!WORD_RE.test(t) || spokenOnly(n.parentElement) || allowed.includes(t)) continue
out.push(t)
}
return out
}