From f74e70221f719423e95cb15f1a8d0e207d3bcab9 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 11 Nov 2026 11:54:44 +0100 Subject: [PATCH] web: system and setup pages paint glyphs Co-Authored-By: Claude Opus 5.5 --- web/src/sections/setup.test.ts | 12 ++- web/src/sections/setup.ts | 77 +++++++++++++++---- web/src/sections/system/backupsCard.test.ts | 4 +- web/src/sections/system/backupsCard.ts | 35 ++++++--- web/src/sections/system/daemonCard.test.ts | 7 +- web/src/sections/system/daemonCard.ts | 46 ++++++++--- web/src/sections/system/serverCard.ts | 15 ++-- web/src/sections/system/setupCard.ts | 10 +-- .../sections/system/textgate.guard.test.ts | 66 ++++++++++++++++ 9 files changed, 215 insertions(+), 57 deletions(-) create mode 100644 web/src/sections/system/textgate.guard.test.ts diff --git a/web/src/sections/setup.test.ts b/web/src/sections/setup.test.ts index 1c148c4..f2ad2a5 100644 --- a/web/src/sections/setup.test.ts +++ b/web/src/sections/setup.test.ts @@ -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) diff --git a/web/src/sections/setup.ts b/web/src/sections/setup.ts index 6422f4e..239e96a 100644 --- a/web/src/sections/setup.ts +++ b/web/src/sections/setup.ts @@ -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