From 138abc6ce9d36c7b5aeea3dfbcf4d77fe24509b0 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 11 Nov 2026 11:45:30 +0100 Subject: [PATCH] web: pills, card and dialog titles spoken only Co-Authored-By: Claude Opus 5.5 --- web/src/ui/build.ts | 24 ++++++++++++++++++------ web/src/ui/dialog.ts | 3 ++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/web/src/ui/build.ts b/web/src/ui/build.ts index 5fd26d4..59e4b1b 100644 --- a/web/src/ui/build.ts +++ b/web/src/ui/build.ts @@ -75,11 +75,18 @@ export function empty(label: string): HTMLParagraphElement { export interface CardOptions { aside?: Child className?: string + icon?: string } -export function card(title: string | null, body: Child, { aside, className = '' }: CardOptions = {}): HTMLElement { - const head = - title || aside ? h('div', { class: 'box-head' }, title ? h('h2', {}, title) : null, aside ?? null) : null +/** A painted glyph whose meaning is spoken only (textgate: no visible words). */ +export function glyph(icon: string, spoken: string, className = 'glyph'): HTMLSpanElement { + return h('span', { class: className }, h('span', { 'aria-hidden': 'true' }, icon), h('span', { class: 'sr-only' }, spoken)) +} + +/** A card; its title is spoken only, `icon` (if any) is painted in its place. */ +export function card(title: string | null, body: Child, { aside, className = '', icon }: CardOptions = {}): HTMLElement { + const heading = title ? h('h2', {}, icon ? glyph(icon, title) : h('span', { class: 'sr-only' }, title)) : null + const head = title || aside ? h('div', { class: 'box-head' }, heading, aside ?? null) : null return h('section', { class: `box ${className}`.trim(), 'aria-label': title ?? '' }, head, body) } @@ -103,9 +110,14 @@ const TONE_PILL_ICON: Record = { running: '▶️', } -export function pill(tone: string, text: string): HTMLSpanElement { - const icon = TONE_PILL_ICON[tone] ?? '' - return h('span', { class: `pill pill-${tone}` }, icon && h('span', { class: 'pill-icon', 'aria-hidden': 'true' }, icon), text) +/** A tone badge: `icon` (else the tone's glyph) painted, `text` spoken only. */ +export function pill(tone: string, text: string, icon = TONE_PILL_ICON[tone] ?? '•'): HTMLSpanElement { + return h( + 'span', + { class: `pill pill-${tone}` }, + h('span', { class: 'pill-icon', 'aria-hidden': 'true' }, icon), + h('span', { class: 'sr-only' }, text), + ) } export function link(href: string, text: Child, className = 'btn btn-ghost btn-small'): HTMLAnchorElement { diff --git a/web/src/ui/dialog.ts b/web/src/ui/dialog.ts index 92cc014..5de62e7 100644 --- a/web/src/ui/dialog.ts +++ b/web/src/ui/dialog.ts @@ -18,7 +18,8 @@ export class Modal { private opener: Element | null = null constructor({ title, wide = false, onClose }: ModalOptions) { - this.heading = h('h2', {}, title) + // Textgate: the title is spoken only; the dialog's content says what it is. + this.heading = h('h2', { class: 'sr-only' }, title) const close = h( 'button', { type: 'button', class: 'btn btn-ghost btn-icon dialog-close', 'aria-label': 'Close', onclick: onClose },