From 13df1dd14dedd95a8f8cc34375bcfbd0329b05d0 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 11 Nov 2026 11:36:41 +0100 Subject: [PATCH] web: command buttons paint glyphs only Labels, descriptions and lock reasons are spoken only. Co-Authored-By: Claude Opus 5.5 --- web/src/commands/catalog.ts | 30 +++++++++++++++ web/src/commands/commandButton.test.ts | 16 ++++---- web/src/commands/commandButton.ts | 28 +++++++++----- web/src/styles/screens.css | 2 + web/src/test/textgate.commands.guard.test.ts | 40 ++++++++++++++++++++ web/src/test/textgate.guard.test.ts | 21 +--------- web/src/test/visibleWords.ts | 26 +++++++++++++ 7 files changed, 127 insertions(+), 36 deletions(-) create mode 100644 web/src/test/textgate.commands.guard.test.ts create mode 100644 web/src/test/visibleWords.ts diff --git a/web/src/commands/catalog.ts b/web/src/commands/catalog.ts index cc4b803..fbc2eba 100644 --- a/web/src/commands/catalog.ts +++ b/web/src/commands/catalog.ts @@ -44,6 +44,36 @@ export const COMMAND_HOME = { 'restore-backup': '/system', } as const satisfies Record +/** 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 + /** Title-case label for buttons and the palette (`pick-manual` β†’ `Pick manual`). */ export function commandLabel(name: CommandName): string { const s = name.replace(/-/g, ' ') diff --git a/web/src/commands/commandButton.test.ts b/web/src/commands/commandButton.test.ts index d64e61a..6bce6e8 100644 --- a/web/src/commands/commandButton.test.ts +++ b/web/src/commands/commandButton.test.ts @@ -26,7 +26,7 @@ describe('', () => { loaded([makeSpec('scan')]) const el = mount(``) 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('', () => { loaded([]) const el = mount('') 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('', () => { loaded([makeSpec('pick', { locked_reason: 'Locked by a manual pick' })]) const el = mount('') 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('', () => { 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('', () => { loaded([makeSpec('scan')]) const el = mount('') 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('', () => { loaded([makeSpec('gaming-reset', { privileged: true, friction: { phrase_template: 'x', countdown_seconds: 300 } })]) const el = mount('') 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 () => { diff --git a/web/src/commands/commandButton.ts b/web/src/commands/commandButton.ts index 5611494..19a5682 100644 --- a/web/src/commands/commandButton.ts +++ b/web/src/commands/commandButton.ts @@ -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), ), ) diff --git a/web/src/styles/screens.css b/web/src/styles/screens.css index 232aa5f..1ed9e4c 100644 --- a/web/src/styles/screens.css +++ b/web/src/styles/screens.css @@ -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); } diff --git a/web/src/test/textgate.commands.guard.test.ts b/web/src/test/textgate.commands.guard.test.ts new file mode 100644 index 0000000..a11ef06 --- /dev/null +++ b/web/src/test/textgate.commands.guard.test.ts @@ -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('') + 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('') + 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('') + await vi.waitFor(() => { + expect(el.querySelector('article')).toHaveClass('locked') + }) + expect(visibleWords(el)).toEqual([]) + }) +}) diff --git a/web/src/test/textgate.guard.test.ts b/web/src/test/textgate.guard.test.ts index 3db126d..37131a7 100644 --- a/web/src/test/textgate.guard.test.ts +++ b/web/src/test/textgate.guard.test.ts @@ -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) diff --git a/web/src/test/visibleWords.ts b/web/src/test/visibleWords.ts new file mode 100644 index 0000000..9385279 --- /dev/null +++ b/web/src/test/visibleWords.ts @@ -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 +}