mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 18:53:48 +01:00
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:
parent
582ce6b2b6
commit
13df1dd14d
@ -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, ' ')
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@ -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); }
|
||||
|
||||
40
web/src/test/textgate.commands.guard.test.ts
Normal file
40
web/src/test/textgate.commands.guard.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
|
||||
26
web/src/test/visibleWords.ts
Normal file
26
web/src/test/visibleWords.ts
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user