From 69268a6f76addff608db5ae326428ad7063f2f51 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:40:59 -0400 Subject: [PATCH 1/5] Fix accessibility defects across settings, widgets and the dashboard Name the settings toggles from the row label they sit beside, rather than from an aria-label on the wrapping label element, where ARIA discards it. Link each inline-edit field to its row label, and resolve an inline-edit placeholder on use so a translated one is read after the catalog loads. Raise the type pills and the unselected filter chip above 4.5:1 on both the pane and a card in both themes, and the digital clock's date line above it in both of its modes. Teach the contrast test to resolve color-mix() and to composite a translucent fill over a backdrop, and pin those pairs. Wrap the settings content in a main landmark and make the dashboard pager a navigation landmark. Number repeated widget frame titles. Build the books shelf as a real list, inset its hover panel from the tile edge, and raise the panel's type. Translate the accessible names in the GitHub, Disk Health, Now Playing, Connections and Dashboard Switch widgets. --- CHANGELOG.md | 19 +++ ui/admin/index.html | 20 ++-- ui/css/admin.css | 26 ++-- ui/index.html | 4 +- ui/js/admin-app-form.js | 4 +- ui/js/admin-auth.js | 2 +- ui/js/admin-color-control.js | 2 +- ui/js/admin-settings.js | 2 +- ui/js/admin-shared.js | 19 ++- ui/js/admin-widget-form.js | 4 +- ui/js/admin.js | 14 +-- ui/js/dashboard.js | 10 +- ui/js/ui.js | 6 +- ui/js/widget-config-form.js | 4 +- ui/js/widget-types.js | 10 ++ ui/test/books-list-semantics.test.mjs | 71 +++++++++++ ui/test/contrast.test.mjs | 98 +++++++++++++++- ui/test/control-names.test.mjs | 111 ++++++++++++++++++ ui/test/landmarks.test.mjs | 37 ++++++ ui/test/pagination.test.mjs | 9 +- ui/widgets/books/index.html | 38 +++--- ui/widgets/clock/digital.html | 4 +- ui/widgets/connections/connections-map.html | 2 +- ui/widgets/connections/i18n/de.json | 3 +- ui/widgets/connections/i18n/en.json | 3 +- ui/widgets/connections/i18n/es.json | 3 +- ui/widgets/connections/i18n/fa.json | 3 +- ui/widgets/connections/i18n/fr.json | 3 +- ui/widgets/connections/i18n/zh-Hans.json | 3 +- ui/widgets/dashboard-switch/i18n/de.json | 3 +- ui/widgets/dashboard-switch/i18n/en.json | 3 +- ui/widgets/dashboard-switch/i18n/es.json | 3 +- ui/widgets/dashboard-switch/i18n/fa.json | 3 +- ui/widgets/dashboard-switch/i18n/fr.json | 3 +- ui/widgets/dashboard-switch/i18n/zh-Hans.json | 3 +- ui/widgets/dashboard-switch/index.html | 2 +- ui/widgets/disk-health/i18n/de.json | 4 +- ui/widgets/disk-health/i18n/en.json | 4 +- ui/widgets/disk-health/i18n/es.json | 4 +- ui/widgets/disk-health/i18n/fa.json | 4 +- ui/widgets/disk-health/i18n/fr.json | 4 +- ui/widgets/disk-health/i18n/zh-Hans.json | 4 +- ui/widgets/disk-health/index.html | 4 +- ui/widgets/github/contributions.html | 3 +- ui/widgets/github/i18n/de.json | 4 +- ui/widgets/github/i18n/en.json | 4 +- ui/widgets/github/i18n/es.json | 4 +- ui/widgets/github/i18n/fa.json | 4 +- ui/widgets/github/i18n/fr.json | 4 +- ui/widgets/github/i18n/zh-Hans.json | 4 +- ui/widgets/github/pullrequests.html | 4 +- ui/widgets/nowplaying/i18n/de.json | 3 +- ui/widgets/nowplaying/i18n/en.json | 3 +- ui/widgets/nowplaying/i18n/es.json | 3 +- ui/widgets/nowplaying/i18n/fa.json | 3 +- ui/widgets/nowplaying/i18n/fr.json | 3 +- ui/widgets/nowplaying/i18n/zh-Hans.json | 3 +- ui/widgets/nowplaying/index.html | 2 +- 58 files changed, 528 insertions(+), 105 deletions(-) create mode 100644 ui/test/books-list-semantics.test.mjs create mode 100644 ui/test/control-names.test.mjs create mode 100644 ui/test/landmarks.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 228a3302..da27d5c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Settings content is now a main landmark, and the dashboard pager is a + navigation landmark. + +- Repeated widgets of one type now get distinct frame names. + +### Fixed + +- Toggles and inline-edit fields in Settings now carry an accessible name. + +- Type pills, filter chips and the digital clock's date line now meet the WCAG + contrast minimum in both themes. + +- The password field's placeholder now renders its translation. + +- Accessible names in the GitHub, Disk Health, Now Playing, Connections and + Dashboard Switch widgets are now translated. + ## [1.8.0] - 2026-08-23 ### Added diff --git a/ui/admin/index.html b/ui/admin/index.html index 4ede3b57..7f3cbf7b 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -11,7 +11,7 @@ - + @@ -48,7 +48,7 @@ -
+
-
Docker Container Health Checks
-
Hide Healthy Badge
+
Docker Container Health Checks
+
Hide Healthy Badge
Socket URLhttp://socket-proxy:2375

Errors shows warnings and errors only. Security events are always logged.

@@ -100,7 +100,7 @@
Security
-
Password Protection
+
Password Protection
PasswordNot set
Sign out all devices
@@ -124,8 +124,8 @@
App Title
-
Show App Title On Desktop
-
Show App Title On Mobile
+
Show App Title On Desktop
+
Show App Title On Mobile
Display
@@ -141,7 +141,7 @@
-
Keep Screen Awake
+
Keep Screen Awake

Sets the appearance of the Settings pages. It is stored in this browser, applies to this device only, and takes effect at once.

Stops the screen dimming or locking while the dashboard is open. Browsers only allow this over HTTPS, so it has no effect on a plain HTTP address.

@@ -260,7 +260,7 @@

General

- + @@ -321,6 +321,6 @@

General

- + diff --git a/ui/css/admin.css b/ui/css/admin.css index f3703a40..22cf398d 100644 --- a/ui/css/admin.css +++ b/ui/css/admin.css @@ -25,19 +25,26 @@ --slider-dir:90deg; /* Derived from the hue, never written out, so a palette change carries - through. */ + through. The hue's readable variant is drawn for a surface, not for a pill + tinted with the same hue, where it reaches only 3.6 to 4.4 against the 4.5 + of 1.4.3. Lifting it toward the theme's extreme clears every pill on both + the pane and a card. */ + --chip-ink-boost:#FFFFFF; + /* An unselected chip sits on the field fill, not on the pane, and --dm is + measured against the pane. */ + --chip-text:#C7C7CC; --chip-dk-bg:color-mix(in srgb, var(--sy-indigo) 20%, transparent); - --chip-dk-fg:var(--sy-indigo-hi); + --chip-dk-fg:color-mix(in srgb, var(--sy-indigo-hi) 82%, var(--chip-ink-boost)); --chip-wg-bg:color-mix(in srgb, var(--sy-green) 20%, transparent); - --chip-wg-fg:var(--sy-green-hi); + --chip-wg-fg:color-mix(in srgb, var(--sy-green-hi) 82%, var(--chip-ink-boost)); --chip-hl-bg:color-mix(in srgb, var(--sy-pink) 20%, transparent); - --chip-hl-fg:var(--sy-pink-hi); + --chip-hl-fg:color-mix(in srgb, var(--sy-pink-hi) 82%, var(--chip-ink-boost)); --chip-bg-bg:color-mix(in srgb, var(--sy-blue) 20%, transparent); - --chip-bg-fg:var(--sy-blue-hi); + --chip-bg-fg:color-mix(in srgb, var(--sy-blue-hi) 82%, var(--chip-ink-boost)); --chip-fl-bg:color-mix(in srgb, var(--sy-brown) 20%, transparent); - --chip-fl-fg:var(--sy-brown-hi); + --chip-fl-fg:color-mix(in srgb, var(--sy-brown-hi) 82%, var(--chip-ink-boost)); --chip-sy-bg:color-mix(in srgb, var(--sy-gray) 22%, transparent); - --chip-sy-fg:var(--sy-gray-hi); + --chip-sy-fg:color-mix(in srgb, var(--sy-gray-hi) 82%, var(--chip-ink-boost)); /* Page neutrals particular to this screen. */ --tile-placeholder:var(--sy-gray2-hi); /* an app tile with no icon yet */ --surface-sunken:var(--sy-gray5); /* recessed well behind the About icon */ @@ -102,6 +109,9 @@ html[data-theme="light"] { --tx:#1C1C1E; --dm:var(--sy-gray-hi); + --chip-ink-boost:#000000; + --chip-text:#5A5A5E; + --tile-placeholder:var(--sy-gray2-hi); --surface-sunken:var(--sy-gray5); --login-badge:#D3E4FF; @@ -247,7 +257,7 @@ html{background:var(--bg-color,var(--bg-outer))} .al-search{background:var(--ov);border:1px solid var(--bd-inner);border-radius:20px;color:var(--tx);font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);padding:7px 14px;width:180px;font-family:inherit} .al-search:focus{outline:none;border-color:var(--ac)} .al-chips{display:flex;gap:5px} -.chip{padding:6px 13px;border-radius:20px;border:none;background:var(--field-fill);color:var(--dm);font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);font-weight:500;cursor:pointer;transition:background var(--t),color var(--t);font-family:inherit} +.chip{padding:6px 13px;border-radius:20px;border:none;background:var(--field-fill);color:var(--chip-text);font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);font-weight:500;cursor:pointer;transition:background var(--t),color var(--t);font-family:inherit} .chip.on{background:var(--ac);color:var(--on-fill)} .chip:hover:not(.on){color:var(--tx)} .row.drow{display:grid;grid-template-columns:20px 48px 1fr auto auto;gap:12px;padding:10px 16px;min-height:68px;cursor:grab;align-items:center} diff --git a/ui/index.html b/ui/index.html index b571bb06..442d1353 100644 --- a/ui/index.html +++ b/ui/index.html @@ -30,7 +30,7 @@

Stackyard

-
+
@@ -53,5 +53,5 @@

Stackyard

- + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index a00f059b..3363dc52 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -13,8 +13,8 @@ import { initInlineEdit, setTogDisabled, wireChecklist, -} from '/js/admin-shared.js?v=d96fc091'; -import { renderColorControl, BADGE_SWATCHES } from '/js/admin-color-control.js?v=89eee5e8'; +} from '/js/admin-shared.js?v=8f69dad6'; +import { renderColorControl, BADGE_SWATCHES } from '/js/admin-color-control.js?v=984a0b69'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; export function buildFolderForm(body, item) { diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index c322a86e..1719f331 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,4 +1,4 @@ -import { ag, ap } from '/js/admin-shared.js?v=d96fc091'; +import { ag, ap } from '/js/admin-shared.js?v=8f69dad6'; import { t } from '/js/i18n.js?v=d056c9c5'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; import { el, inp as inpById, qa } from '/js/utils.js?v=b18c93ed'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 27370254..495e695a 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,4 +1,4 @@ -import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=d96fc091'; +import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=8f69dad6'; import { t } from '/js/i18n.js?v=d056c9c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=b18c93ed'; diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index a199ae97..24170efa 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, ag, ap } from '/js/admin-shared.js?v=d96fc091'; +import { toast, ag, ap } from '/js/admin-shared.js?v=8f69dad6'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; import { t } from '/js/i18n.js?v=d056c9c5'; import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=ddfc6f80'; diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index 607beb5f..3c648779 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -129,10 +129,13 @@ export const CHEV_SVG = ''; /* `root` lets a caller wire a subtree that is not in the document yet. */ -/** @param {string} rowId @param {string} inputId - @param {{ type?: string, placeholder?: string, +/** A function placeholder is resolved on use, not at wiring time: this runs + before the catalog is fetched, so a translated one has to be read later. + @param {string} rowId @param {string} inputId + @param {{ type?: string, placeholder?: string | (() => string), onCommit?: (value: string) => void, root?: ParentNode }} [opts] */ export function initInlineEdit(rowId, inputId, { type = 'text', placeholder = '', onCommit, root = document } = {}) { + const ph = () => (typeof placeholder === 'function' ? placeholder() : placeholder); const byId = id => (root === document ? el(id) : root.querySelector('#' + CSS.escape(id))); const row = byId(rowId); const inp = /** @type {HTMLInputElement} */ (byId(inputId)); @@ -142,15 +145,23 @@ export function initInlineEdit(rowId, inputId, { type = 'text', placeholder = '' if (!valEl || !pen) return; inp.type = type; - inp.placeholder = placeholder; + inp.placeholder = ph(); inp.className = 'row-inp'; inp.style.display = ''; inp.style.cssText = ''; + /* The row's own label carries the translation. A placeholder is a hint, not a + name, and several readers drop it once the field holds a value. */ + const labelEl = q('.rl', row); + if (labelEl) { + if (!labelEl.id) labelEl.id = `${rowId}-rl`; + inp.setAttribute('aria-labelledby', labelEl.id); + } row.insertBefore(inp, pen); function open() { if (row.classList.contains('editing')) return; row.classList.add('editing'); + inp.placeholder = ph(); inp.value = valEl.classList.contains('is-ph') ? '' : valEl.textContent; inp.focus(); inp.select?.(); @@ -163,7 +174,7 @@ export function initInlineEdit(rowId, inputId, { type = 'text', placeholder = '' valEl.textContent = v; valEl.classList.remove('is-ph'); } else { - valEl.textContent = placeholder || ''; + valEl.textContent = ph() || ''; valEl.classList.add('is-ph'); } onCommit?.(v); diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index 588ae887..505734bd 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,6 +1,6 @@ import { state } from '/js/admin-state.js?v=b7731aa4'; -import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=d96fc091'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=71fe9a70'; +import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=8f69dad6'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=45bbb2b7'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, diff --git a/ui/js/admin.js b/ui/js/admin.js index 44a77ebc..02d877f7 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,5 +1,5 @@ -import { buildAppForm, buildFolderForm, serializeKvRows } from '/js/admin-app-form.js?v=f87415c7'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=589ad7b9'; +import { buildAppForm, buildFolderForm, serializeKvRows } from '/js/admin-app-form.js?v=f236bd68'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=5e6450f0'; import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=ebe3e806'; import { reorderItems, resolveAdminSection } from '/js/admin-logic.js?v=ddfc6f80'; import { @@ -10,10 +10,10 @@ import { snapshotItems, upsertItem, } from '/js/admin-save-logic.js?v=52a970d3'; -import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=b2b67c47'; -import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=d96fc091'; +import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=f7078bc0'; +import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=8f69dad6'; import { state } from '/js/admin-state.js?v=b7731aa4'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=55e0396a'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=15aad905'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=d056c9c5'; import { iconChain, loadLocalIcons, resolveIcon } from '/js/icons.js?v=69c2b9bd'; @@ -30,7 +30,7 @@ import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=28416a75'; import { confirmModal, openModal as openDialog, promptModal } from '/js/modal.js?v=ff76dc56'; import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=fbd2d2ef'; import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=b18c93ed'; -import { normalizeColorInput } from '/js/admin-color-control.js?v=89eee5e8'; +import { normalizeColorInput } from '/js/admin-color-control.js?v=984a0b69'; import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=cceca788'; /* A class rather than a bare media query. Some phones report a wider CSS @@ -1043,7 +1043,7 @@ function initAllInlineEdits() { initInlineEdit('ie-pw', 'sec-pw', { type: 'password', - placeholder: t('general.passwordPh'), + placeholder: () => t('general.passwordPh'), onCommit() { const bars = el('sec-pw-bars'); const hint = el('sec-pw-hint'); diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index f9653686..234e9eda 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -7,7 +7,8 @@ import { WIDGET_COST, widgetSrc, cardPreset, -} from '/js/widget-types.js?v=b81ceeea'; + uniqueTitle, +} from '/js/widget-types.js?v=6a5e1619'; import { mk, mkWrap as _mkWrap, @@ -25,7 +26,7 @@ import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=d056c9c5'; import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=42f45ac7'; import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f'; -import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=371827cc'; +import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=45508206'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=771f3230'; import { configChanged, @@ -271,8 +272,10 @@ function mkIcon(item) { return a; } +/* Reset per build: the names must stay stable across a rebuild. */ +let usedWidgetTitles = new Set(); function widgetTitle(item) { - return item.label || widgetReg[item.widgetType]?.label || t('type.widget'); + return uniqueTitle(item.label || widgetReg[item.widgetType]?.label || t('type.widget'), usedWidgetTitles); } function mkWidget(item) { const sz = item.widgetSize || 'medium'; @@ -331,6 +334,7 @@ function buildDesktop() { widgets started. */ teardownWidgets(); BEL.clear(); + usedWidgetTitles = new Set(); /* Before paginate() and before any tile is built: both size against it. */ gm = gridMetrics(); const dock = items.filter(i => i.type === 'app' && i.dock && !i.hidden).slice(0, 4); diff --git a/ui/js/ui.js b/ui/js/ui.js index 027733b8..3239791b 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -1,5 +1,5 @@ import { iconChain } from '/js/icons.js?v=69c2b9bd'; -import { widgetSrc, cardPreset, WIDGET_DESIGN } from '/js/widget-types.js?v=b81ceeea'; +import { widgetSrc, cardPreset, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=6a5e1619'; import { mk, clr, @@ -645,8 +645,10 @@ export function buildMobile() { } const pages = packMobile(gridItems); + /* Scoped to this build, so the names stay stable across a rebuild. */ + const usedWidgetTitles = new Set(); function widgetTitle(item) { - return item.label || widgetReg()[item.widgetType]?.label || t('type.widget'); + return uniqueTitle(item.label || widgetReg()[item.widgetType]?.label || t('type.widget'), usedWidgetTitles); } function mIcon(item) { diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index abe930a8..3fe95e8d 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -4,8 +4,8 @@ import { t } from '/js/i18n.js?v=d056c9c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { wireChecklist } from '/js/admin-shared.js?v=d96fc091'; -import { renderColorControl } from '/js/admin-color-control.js?v=89eee5e8'; +import { wireChecklist } from '/js/admin-shared.js?v=8f69dad6'; +import { renderColorControl } from '/js/admin-color-control.js?v=984a0b69'; import { seedCarried, applyOptionSet, diff --git a/ui/js/widget-types.js b/ui/js/widget-types.js index 803749a1..fab01fa4 100644 --- a/ui/js/widget-types.js +++ b/ui/js/widget-types.js @@ -60,3 +60,13 @@ export function widgetSrc(item, reg, opts) { if (opts?.lang) parts.push('lang=' + encodeURIComponent(opts.lang)); return `/widgets/${type}/${file}?${parts.join('&')}`; } + +/* Two widgets of the same type carry the same manifest label, and a frame list + then holds several entries with one name. */ +/** @param {string} base @param {Set} used */ +export function uniqueTitle(base, used) { + let title = base; + for (let n = 2; used.has(title); n++) title = `${base} ${n}`; + used.add(title); + return title; +} diff --git a/ui/test/books-list-semantics.test.mjs b/ui/test/books-list-semantics.test.mjs new file mode 100644 index 00000000..952f2f31 --- /dev/null +++ b/ui/test/books-list-semantics.test.mjs @@ -0,0 +1,71 @@ +/* The books shelf is a list, and it has to say so in markup. + + The spines were divs carrying role="listitem" with no list above them, so the + role was dropped and each book reached a reader as unpositioned focusable + text with no count. They are `li` elements in a real `ul` now. + + The shelf itself cannot be that list: it also holds the two decorative ledge + strips, which are not books. The list is nested inside it. */ + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const books = fs.readFileSync(path.join(root, 'widgets/books/index.html'), 'utf8'); + +test('the shelf holds a real list, not a div wearing a role', () => { + assert.match(books, /