web: system and setup pages 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 30416da9b8
commit f74e70221f
9 changed files with 215 additions and 57 deletions

View File

@ -29,19 +29,23 @@ describe('setup-form', () => {
expect(keyBox()).toHaveValue('')
expect(keyBox()).toHaveFocus()
await waitFor(() => {
expect(keyBox()).toHaveAttribute('placeholder', expect.stringContaining('A key is stored'))
expect(keyBox()).toHaveAttribute('data-stored', 'true')
})
expect(keyBox().placeholder).toMatch(/^🔒 •{32}$/)
expect(idBox()).toHaveValue(ID)
expect(screen.queryByText('Not configured yet')).toBeNull()
expect(screen.getByRole('link', { name: 'steamcommunity.com/dev/apikey' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'steamcommunity.com/dev/apikey' })).toHaveAttribute('href', 'https://steamcommunity.com/dev/apikey')
expect(screen.getByRole('link', { name: 'steamid.io' })).toHaveAttribute('href', 'https://steamid.io')
})
it('warns on a first run, with a hint for the empty form', async () => {
stubApi({ 'GET /api/setup': status({ configured: false, has_api_key: false, steam_id: null }) })
show()
expect(await screen.findByText('Not configured yet')).toBeInTheDocument()
expect(keyBox()).toHaveAttribute('placeholder', '32 hex characters')
expect(keyBox()).toHaveAttribute('data-stored', 'false')
expect(keyBox().placeholder).toHaveLength(32)
expect(idBox()).toHaveValue('')
expect(idBox().placeholder).toMatch(/^7656·{13}$/)
})
it('shows loading', async () => {
@ -68,6 +72,8 @@ describe('setup-form', () => {
expect(screen.getByText('A SteamID64 is 17 digits and starts with 7656.')).toBeInTheDocument()
expect(keyBox()).toHaveAttribute('aria-invalid', 'true')
expect(keyBox()).toHaveClass('input-bad')
expect(keyBox().nextElementSibling).toHaveTextContent('⚠️ 6/32')
expect(idBox().nextElementSibling).toHaveTextContent('⚠️ 3/17')
expect(bodiesOf(mock, 'POST /api/setup')).toEqual([])
await userEvent.clear(keyBox())
await userEvent.type(keyBox(), KEY)

View File

@ -10,7 +10,11 @@ import { Action } from '../lib/action'
import { append, define, h, type Child } from '../lib/dom'
import { LiveElement } from '../lib/element'
import { invalidateAll } from '../lib/query'
import { card, errorNotice, loading, notice } from '../ui/build'
import { card, errorNotice, glyph, loading, notice } from '../ui/build'
/** A notice's title and sentence, spoken only (textgate); its tone icon is painted. */
const spoken = (title: string, text: string): HTMLSpanElement =>
h('span', { class: 'sr-only' }, h('strong', {}, title), ` ${text}`)
const KEY_RE = /^[0-9A-Fa-f]{32}$/
const ID_RE = /^7656\d{13}$/
@ -23,27 +27,44 @@ export function idError(id: string): string | null {
return ID_RE.test(id.trim()) ? null : 'A SteamID64 is 17 digits and starts with 7656.'
}
const KEY_HELP = (): Child[] => [
'Get one at ',
h('a', { href: 'https://steamcommunity.com/dev/apikey', target: '_blank', rel: 'noreferrer' }, 'steamcommunity.com/dev/apikey'),
'. It is never shown again.',
// Textgate: help is a 🔗 to where the value comes from; its sentence is spoken only.
const helpLink = (href: string, spoken: string, sentence: string): Child[] => [
h('a', { href, target: '_blank', rel: 'noreferrer', class: 'setup-link', 'aria-label': spoken }, '🔗'),
h('span', { class: 'sr-only' }, sentence),
]
const ID_HELP = (): Child[] => ['Your profile URL or steamid.io shows it.']
const KEY_HELP = (): Child[] =>
helpLink('https://steamcommunity.com/dev/apikey', 'steamcommunity.com/dev/apikey', 'Get one at steamcommunity.com/dev/apikey. It is never shown again.')
const ID_HELP = (): Child[] => helpLink('https://steamid.io', 'steamid.io', 'Your profile URL or steamid.io shows it.')
/** Placeholders paint the expected shape: one dot per character, the fixed 7656 prefix. */
const KEY_SHAPE = '·'.repeat(32)
const KEY_STORED = `🔒 ${'•'.repeat(32)}`
const ID_SHAPE = `7656${'·'.repeat(13)}`
interface Field {
input: HTMLInputElement
help: HTMLParagraphElement
hint: () => Child[]
baseClass: string
/** Characters a valid value has; a bad value paints `⚠️ typed/want`. */
want: number
}
function field(id: string, label: string, input: HTMLInputElement, hint: () => Child[]): { el: HTMLDivElement; f: Field } {
function field(id: string, label: string, icon: string, input: HTMLInputElement, hint: () => Child[], want: number): { el: HTMLDivElement; f: Field } {
const help = h('p', { id: `${id}-help`, class: 'field-help' }, hint())
input.id = id
input.setAttribute('aria-describedby', `${id}-help`)
return {
el: h('div', { class: 'form-field' }, h('label', { for: id, class: 'field-label' }, label), input, help),
f: { input, help, hint, baseClass: input.className },
el: h(
'div',
{ class: 'form-field' },
// The glyph is painted beside a spoken-only <label>, so the label's text stays exactly `label`.
h('span', { class: 'field-label', 'aria-hidden': 'true' }, icon),
h('label', { for: id, class: 'sr-only' }, label),
input,
help,
),
f: { input, help, hint, baseClass: input.className, want },
}
}
@ -71,10 +92,19 @@ export class SetupFormElement extends LiveElement {
const key = field(
'setup-key',
'Steam Web API key',
'🔑',
h('input', { class: 'input mono', type: 'password', autocomplete: 'off', spellcheck: 'false', autofocus: true }),
KEY_HELP,
32,
)
const id = field(
'setup-id',
'SteamID64',
'🆔',
h('input', { class: 'input mono input-num', inputmode: 'numeric', placeholder: ID_SHAPE }),
ID_HELP,
17,
)
const id = field('setup-id', 'SteamID64', h('input', { class: 'input mono input-num', inputmode: 'numeric' }), ID_HELP)
this.keyField = key.f
this.idField = id.f
key.f.input.addEventListener('input', () => {
@ -116,10 +146,12 @@ export class SetupFormElement extends LiveElement {
...(error ? [errorNotice(error)] : []),
...(!data && !error ? [loading('setup')] : []),
...(data && !data.configured
? [notice('warning', 'Not configured yet', 'Every other screen needs these two values first.')]
? [notice('warning', null, spoken('Not configured yet', 'Every other screen needs these two values first.'))]
: []),
)
this.keyField.input.placeholder = data?.has_api_key ? 'A key is stored — type a new one to replace it' : '32 hex characters'
// 🔒 + filled dots: a key is stored (typing replaces it); hollow dots: none yet.
this.keyField.input.placeholder = data?.has_api_key ? KEY_STORED : KEY_SHAPE
this.keyField.input.dataset.stored = String(data?.has_api_key ?? false)
if (!this.idEdited) this.idField.input.value = data?.steam_id ?? ''
this.render()
}
@ -130,24 +162,35 @@ export class SetupFormElement extends LiveElement {
this.mark(this.idField, idError(this.idField.input.value))
this.saved.replaceChildren(
...(this.success
? [notice('success', 'Saved', 'Steam accepted the key. ', h('a', { href: '/' }, 'Go to the dashboard'), '.')]
? [
notice(
'success',
null,
spoken('Saved', 'Steam accepted the key.'),
h('a', { href: '/', class: 'btn btn-ghost btn-small', 'aria-label': 'Go to the dashboard' }, '🏠'),
),
]
: []),
)
this.failure.replaceChildren(...(this.save.error ? [errorNotice(this.save.error)] : []))
this.submit.disabled = this.save.pending
this.submit.textContent = this.save.pending ? 'Checking with Steam…' : 'Save and verify'
// ⏳ while Steam checks, ✔️ to save; the words are spoken only.
this.submit.replaceChildren(this.save.pending ? glyph('⏳', 'Checking with Steam…') : glyph('✔️', 'Save and verify'))
}
private mark(f: Field, problem: string | null): void {
const bad = this.touched && problem !== null
f.input.className = bad ? `${f.baseClass} input-bad` : f.baseClass
f.input.setAttribute('aria-invalid', String(bad))
const shown = f.help.dataset.bad === String(bad) && (!bad || f.help.textContent === problem)
if (shown) return
// A bad value paints ⚠️ and how many characters it has of the `want` it needs.
const count = `⚠️ ${f.input.value.trim().length}/${f.want}`
const state = bad ? `${problem}|${count}` : 'ok'
if (f.help.dataset.state === state) return
f.help.dataset.state = state
f.help.dataset.bad = String(bad)
f.help.className = bad ? 'field-help text-danger' : 'field-help'
f.help.replaceChildren()
append(f.help, bad ? problem : f.hint())
append(f.help, bad ? [h('span', { 'aria-hidden': 'true', class: 'mono' }, count), h('span', { class: 'sr-only' }, problem)] : f.hint())
}
private async store(): Promise<void> {

View File

@ -25,7 +25,7 @@ const openDialog = async () => {
await userEvent.click(await screen.findByRole('button', { name: 'Restore…' }))
return screen.findByRole('dialog', { name: `Restore backup ${BACKUP.id}` })
}
const phraseBox = () => screen.getByLabelText('Type the phrase to confirm')
const phraseBox = () => screen.getByLabelText(/Type the phrase to confirm/)
describe('restorePhrase', () => {
it('mirrors the server friction phrase', () => {
@ -40,7 +40,7 @@ describe('backups-card', () => {
show()
expect(await screen.findByText(BACKUP.id)).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'State backups' })).toBeInTheDocument()
expect(screen.getByText(/before reset · 2.0 KB/)).toBeInTheDocument()
expect(document.querySelector('[data-reason="before reset"]')).toHaveTextContent(/♻️.*before reset · 2.0 KB/)
expect(await screen.findByRole('button', { name: 'Reset all state…' })).toBeInTheDocument()
})

View File

@ -15,7 +15,7 @@ import '../../jobs/jobProgress'
import { Action } from '../../lib/action'
import { define, h, hx } from '../../lib/dom'
import { LiveElement } from '../../lib/element'
import { card, empty, errorNotice, loading } from '../../ui/build'
import { card, empty, errorNotice, glyph, loading } from '../../ui/build'
import { Modal } from '../../ui/dialog'
import { bytes, dateTime } from '../../ui/format'
@ -23,6 +23,10 @@ import { bytes, dateTime } from '../../ui/format'
// know it. Mirrors FRICTION["restore-backup"] in _friction.py.
export const restorePhrase = (id: string): string => `restore backup ${id}`
/** Why a backup was taken, as a glyph (reason words spoken only). */
const REASON_ICON: Record<string, string> = { 'before reset': '♻️', nightly: '🌙' }
const reasonGlyph = (reason: string): HTMLSpanElement => glyph(REASON_ICON[reason] ?? '🗄️', reason)
/** One restore: phrase form, then the job it started. */
export class RestoreDialog {
readonly modal: Modal
@ -57,14 +61,21 @@ export class RestoreDialog {
if (this.matches() && !this.restore.pending) void this.run(onClose)
},
},
h('p', { class: 'm0' }, `Replaces the current state with the backup from ${dateTime(backup.created_at)} (${backup.reason}).`),
// Painted: the backup's id, date and reason glyph; the sentence is spoken only.
h(
'p',
{ class: 'm0 row' },
h('span', { class: 'sr-only' }, `Replaces the current state with the backup from ${dateTime(backup.created_at)} (${backup.reason}).`),
h('span', { 'aria-hidden': 'true', class: 'mono' }, `🗄️ ${backup.id} · ${dateTime(backup.created_at)}`),
h('span', { 'aria-hidden': 'true' }, REASON_ICON[backup.reason] ?? '🗄️'),
),
this.field.el,
this.failure,
h(
'div',
{ class: 'actions' },
this.submit,
h('button', { type: 'button', class: 'btn btn-ghost', onclick: onClose }, 'Cancel'),
h('button', { type: 'button', class: 'btn btn-ghost', onclick: onClose }, glyph('✖️', 'Cancel')),
),
)
this.modal.render(form)
@ -77,7 +88,7 @@ export class RestoreDialog {
private sync(): void {
this.submit.disabled = !this.matches() || this.restore.pending
this.submit.textContent = this.restore.pending ? 'Restoring…' : 'Restore'
this.submit.replaceChildren(this.restore.pending ? glyph('⏳', 'Restoring…') : glyph('⏮️', 'Restore'))
this.failure.replaceChildren(...(this.restore.error ? [errorNotice(this.restore.error)] : []))
}
@ -90,7 +101,7 @@ export class RestoreDialog {
'div',
{ class: 'stack' },
hx('job-progress', { 'job-id': out.value.id }),
h('div', { class: 'actions' }, h('button', { type: 'button', class: 'btn btn-ghost', onclick: onClose }, 'Close')),
h('div', { class: 'actions' }, h('button', { type: 'button', class: 'btn btn-ghost', onclick: onClose }, glyph('✖️', 'Close'))),
),
)
this.modal.focusFirst()
@ -102,8 +113,8 @@ export class BackupsCardElement extends LiveElement {
private readonly list = h('ul', { class: 'rows' })
private readonly root = card(
'State backups',
[h('p', { class: 'field-help m0' }, 'Reset takes a timestamped backup first, so it can be undone here.'), this.status, this.list],
{ aside: hx('command-button', { name: 'reset', label: 'Reset all state…' }) },
[h('p', { class: 'sr-only' }, 'Reset takes a timestamped backup first, so it can be undone here.'), this.status, this.list],
{ aside: hx('command-button', { name: 'reset', label: 'Reset all state…' }), icon: '🗄️' },
)
private dialog: RestoreDialog | null = null
private shownKey = ''
@ -134,7 +145,13 @@ export class BackupsCardElement extends LiveElement {
'li',
{ class: 'row' },
h('span', { class: 'mono' }, b.id),
h('span', { class: 'muted grow' }, `${dateTime(b.created_at)} · ${b.reason} · ${bytes(b.size_bytes)}`),
h(
'span',
{ class: 'muted grow', 'data-reason': b.reason },
`${dateTime(b.created_at)} · `,
reasonGlyph(b.reason),
` · ${bytes(b.size_bytes)}`,
),
h(
'button',
{
@ -144,7 +161,7 @@ export class BackupsCardElement extends LiveElement {
this.open(b)
},
},
'Restore…',
glyph('⏮️', 'Restore…'),
),
),
),

View File

@ -44,7 +44,8 @@ describe('daemon-card', () => {
expect(await screen.findByText('4242')).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'Enforcer daemon' })).toBeInTheDocument()
expect(screen.getByText('running')).toBeInTheDocument()
expect(screen.getByText(/ago/)).toBeInTheDocument()
expect(screen.getByLabelText(/ago/)).toBeInTheDocument()
expect(screen.getByText('running').closest('[data-state]')).toHaveAttribute('data-state', 'running')
expect(journal()).toHaveTextContent(/line one\s+line two/)
expect(screen.queryByText('Daemon unreachable')).toBeNull()
})
@ -77,8 +78,8 @@ describe('daemon-card', () => {
expect(button).toHaveAttribute('aria-disabled', 'true')
await userEvent.click(button)
expect(run).not.toHaveBeenCalled()
expect(screen.getByText('No journal lines.')).toBeInTheDocument()
expect(screen.getAllByText('—')).toHaveLength(2)
expect(journal()).toHaveTextContent(/^—$/)
expect(screen.getAllByText('—')).toHaveLength(3)
})
it('offers the demo run', async () => {

View File

@ -8,19 +8,36 @@ import { q } from '../../api/queries'
import { define, h } from '../../lib/dom'
import { LiveElement } from '../../lib/element'
import { runner } from '../../lib/runner'
import { card, commandLine, errorNotice, facts, loading, notice } from '../../ui/build'
import type { DaemonState } from '../../api/contract'
import { card, commandLine, errorNotice, facts, glyph, loading, notice } from '../../ui/build'
import { ago, clock } from '../../ui/format'
/** "3 h ago" painted as "3 h" (a number + unit), the full phrase spoken. */
function since(iso: string): HTMLSpanElement {
const full = ago(iso)
const painted = full === 'just now' ? '0 min' : full.replace(/ ago$/, '')
return h('span', { 'aria-label': full }, h('span', { 'aria-hidden': 'true' }, painted))
}
/** Milliseconds left on the restart rate limit; 0 when free. */
export function restartWaitMs(availableAt: string | null, now: number = Date.now()): number {
return availableAt ? Math.max(0, Date.parse(availableAt) - now) : 0
}
/** Daemon state: glyph painted, the state word spoken. */
const STATE_ICON: Record<DaemonState, string> = { running: '🟢', restarting: '🔄', unreachable: '🔴' }
function unreachableNotice(): HTMLDivElement {
// Textgate: ⛔ + the command to run are painted; the explanation is spoken only.
return notice(
'danger',
'Daemon unreachable',
'Privileged actions (store unblock, gaming reset/block/unblock, restart) are unavailable. Check it from a terminal:',
null,
h(
'span',
{ class: 'sr-only' },
h('strong', {}, 'Daemon unreachable'),
' Privileged actions (store unblock, gaming reset/block/unblock, restart) are unavailable. Check it from a terminal:',
),
commandLine('systemctl status steam-backlog-enforcer'),
)
}
@ -49,16 +66,16 @@ export class DaemonCardElement extends LiveElement {
runner.run('enforce', { demo: 1 })
},
},
'Demo run (enforce --demo)…',
glyph('🧪', 'Demo run (enforce --demo)…'),
)
private readonly parts = [
this.alert,
this.factsSlot,
h('div', { class: 'actions' }, this.restart, this.demo),
h('h3', { class: 'h3' }, 'Journal'),
h('h3', { class: 'h3' }, glyph('📜', 'Journal')),
this.journal,
]
private readonly root = card('Enforcer daemon', [this.status, this.body])
private readonly root = card('Enforcer daemon', [this.status, this.body], { icon: '🛡️' })
private shown = false
private canRestart = false
private unreachable = false
@ -87,20 +104,27 @@ export class DaemonCardElement extends LiveElement {
}
this.factsSlot.replaceChildren(
facts([
['State', data.state],
['PID', data.pid ?? '—'],
['Up since', data.started_at ? ago(data.started_at) : '—'],
[glyph('📡', 'State'), h('span', { 'data-state': data.state }, glyph(STATE_ICON[data.state], data.state))],
[glyph('#️⃣', 'PID'), data.pid ?? '—'],
[glyph('⏱️', 'Up since'), data.started_at ? since(data.started_at) : '—'],
]),
)
this.canRestart = waitMs === 0 && data.state === 'running'
this.restart.setAttribute('aria-disabled', String(!this.canRestart))
this.restart.textContent = waitMs > 0 ? `Restart available in ${clock(waitMs / 1000)}` : 'Restart daemon…'
// Rate limited: ⏳ + the clock counting down; free: 🔄. Words spoken only.
const wait = clock(waitMs / 1000)
this.restart.replaceChildren(
waitMs > 0
? h('span', { 'aria-label': `Restart available in ${wait}` }, h('span', { 'aria-hidden': 'true' }, `⏳ ${wait}`))
: glyph('🔄', 'Restart daemon…'),
)
this.fillJournal(data.journal_tail)
}
/** New text only when it changed; follow the tail unless scrolled up. */
private fillJournal(lines: string[]): void {
const text = lines.length ? lines.join('\n') : 'No journal lines.'
// The journal lines are the daemon's own log (content); empty paints a dash.
const text = lines.length ? lines.join('\n') : '—'
const j = this.journal
if (j.textContent === text) return
const following = j.scrollHeight - j.scrollTop - j.clientHeight < 8

View File

@ -6,7 +6,7 @@ import { q } from '../../api/queries'
import { define, h } from '../../lib/dom'
import { LiveElement } from '../../lib/element'
import { ServerRestart } from '../../shell/serverRestart'
import { card, errorNotice, facts, loading, notice } from '../../ui/build'
import { card, errorNotice, facts, glyph, loading, notice } from '../../ui/build'
import { ago, dateTime } from '../../ui/format'
export class ServerCardElement extends LiveElement {
@ -19,7 +19,7 @@ export class ServerCardElement extends LiveElement {
void this.restart.action.run()
},
})
private readonly root = card('Web server', [this.info, this.failure, h('div', { class: 'actions' }, this.button)])
private readonly root = card('Web server', [this.info, this.failure, h('div', { class: 'actions' }, this.button)], { icon: '🌐' })
private readonly restart = new ServerRestart(() => {
this.update()
})
@ -37,13 +37,13 @@ export class ServerCardElement extends LiveElement {
this.info.replaceChildren(
...(error ? [errorNotice(error)] : []),
...(!data && !error ? [loading('server health')] : []),
...(data?.stale ? [notice('warning', 'Stale', 'The code on disk is newer than this process.')] : []),
...(data?.stale ? [notice('warning', null, h('span', { class: 'sr-only' }, h('strong', {}, 'Stale'), ' The code on disk is newer than this process.'))] : []),
...(data
? [
facts([
['Version', h('span', { class: 'mono' }, data.version)],
['Started', `${dateTime(data.started_at)} (${ago(data.started_at)})`],
['Code', data.stale ? 'stale' : 'current'],
[glyph('🏷️', 'Version'), h('span', { class: 'mono' }, data.version)],
[glyph('🕒', 'Started'), h('span', { 'aria-label': `${dateTime(data.started_at)} (${ago(data.started_at)})` }, dateTime(data.started_at))],
[glyph('💾', 'Code'), data.stale ? glyph('⚠️', 'stale') : glyph('✅', 'current')],
]),
]
: []),
@ -51,7 +51,8 @@ export class ServerCardElement extends LiveElement {
const { action, restarting } = this.restart
this.failure.replaceChildren(...(action.error ? [errorNotice(action.error)] : []))
this.button.disabled = restarting || action.pending
this.button.textContent = restarting ? 'Restarting… the page reloads when it is back' : 'Restart server'
this.button.dataset.restarting = String(restarting)
this.button.replaceChildren(restarting ? glyph('⏳', 'Restarting… the page reloads when it is back') : glyph('🔄', 'Restart server'))
}
}

View File

@ -5,7 +5,7 @@ import { q } from '../../api/queries'
import { define, h } from '../../lib/dom'
import { LiveElement } from '../../lib/element'
import { pageHref } from '../../lib/runner'
import { card, facts, link } from '../../ui/build'
import { card, facts, glyph, link } from '../../ui/build'
export class SetupCardElement extends LiveElement {
protected override setup(): void {
@ -18,11 +18,11 @@ export class SetupCardElement extends LiveElement {
card(
'Setup',
facts([
['Configured', data ? (data.configured ? 'Yes' : 'No') : '…'],
['Steam API key', data ? (data.has_api_key ? 'Stored (never shown)' : 'Missing') : '…'],
['Steam ID', h('span', { class: 'mono' }, data?.steam_id ?? '—')],
[glyph('⚙️', 'Configured'), data ? (data.configured ? glyph('✅', 'Yes') : glyph('❌', 'No')) : '…'],
[glyph('🔑', 'Steam API key'), data ? (data.has_api_key ? glyph('🔒', 'Stored (never shown)') : glyph('❌', 'Missing')) : '…'],
[glyph('🆔', 'Steam ID'), h('span', { class: 'mono' }, data?.steam_id ?? '—')],
]),
{ aside: link(pageHref('/setup'), 'Open setup') },
{ aside: link(pageHref('/setup'), glyph('✏️', 'Open setup')), icon: '🔧' },
),
)
}

View File

@ -0,0 +1,66 @@
// Guard test (textgate-rework R6): the System and Setup pages paint glyphs,
// numbers, ids, dates and the daemon's own journal, never chrome words.
import { screen, waitFor } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it } from 'vitest'
import { makeSpec } from '../../test/fixtures'
import { installEventSource, mount, stubApi } from '../../test/harness'
import { visibleWords } from '../../test/visibleWords'
import '../setup'
import './backupsCard'
import './daemonCard'
import './serverCard'
import './setupCard'
/** Content inside a painted string: dates as dateTime() paints them, sizes, ids. */
const CONTENT = [/[A-Z][a-z]{2} \d{1,2}, \d{1,2}:\d{2}( [AP]M)?/g, /\d[\d.,]* (B|KB|MB|GB)\b/g, /\d{8}-\d{6}/g]
const chrome = (root: Element, allowed: string[] = []): string[] =>
visibleWords(root, allowed).filter((w) => /[A-Za-z]{2,}/.test(CONTENT.reduce((s, re) => s.replace(re, ''), w)))
beforeEach(installEventSource)
describe('textgate guard: system', () => {
it('server, setup, daemon and backup cards paint no words, stale and unreachable included', async () => {
stubApi({
'GET /api/server': { stale: true, started_at: '2026-10-10T10:00:00Z', version: '9.9.9' },
'GET /api/setup': { configured: false, has_api_key: false, steam_id: null },
'GET /api/daemon': { state: 'unreachable', started_at: null, pid: null, journal_tail: [], restart_available_at: null },
'GET /api/backups': [{ id: '20261010-100000', created_at: '2026-10-10T10:00:00Z', reason: 'nightly', size_bytes: 2048 }],
'GET /api/commands': [makeSpec('reset')],
'GET /api/jobs': [],
})
const el = mount('<server-card></server-card><setup-card></setup-card><daemon-card></daemon-card><backups-card></backups-card>')
await screen.findByText('20261010-100000')
await screen.findByText('Daemon unreachable')
await screen.findByText('Stale')
// The recovery command is content kuhy runs.
expect(chrome(el, ['systemctl status steam-backlog-enforcer'])).toEqual([])
})
it('the restore dialog paints no words', async () => {
stubApi({
'GET /api/backups': [{ id: '20261010-100000', created_at: '2026-10-10T10:00:00Z', reason: 'before reset', size_bytes: 2048 }],
'GET /api/commands': [makeSpec('reset')],
'GET /api/jobs': [],
})
const el = mount('<backups-card></backups-card>')
await userEvent.click(await screen.findByRole('button', { name: 'Restore…' }))
const dialog = await screen.findByRole('dialog')
// The phrase is the friction text kuhy types (settled decision 2).
expect(chrome(el, ['restore backup 20261010-100000'])).toEqual([])
expect(dialog).toBeInTheDocument()
})
it('the setup form paints no words, invalid values included', async () => {
stubApi({ 'GET /api/setup': { configured: false, has_api_key: false, steam_id: null } })
const el = mount('<setup-form></setup-form>')
await screen.findByText('Not configured yet')
await userEvent.type(screen.getByLabelText('Steam Web API key'), 'nothex')
await userEvent.click(screen.getByRole('button', { name: 'Save and verify' }))
await waitFor(() => {
expect(screen.getByLabelText('Steam Web API key')).toHaveAttribute('aria-invalid', 'true')
})
expect(chrome(el)).toEqual([])
})
})