diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md new file mode 100644 index 00000000..fdff2cd3 --- /dev/null +++ b/ACCESSIBILITY.md @@ -0,0 +1,40 @@ +# Accessibility + +Stackyard targets [WCAG 2.1](https://www.w3.org/TR/WCAG21/) and [WCAG 2.2](https://www.w3.org/TR/WCAG22/) at level AA. + +## Conformance status + +Stackyard is **partially conformant** with WCAG 2.2 level AA. Partially conformant means most of the standard is met, and the parts that are not are listed below. + +This is a self-assessment. No external audit has been carried out. + +## What is supported + +- Every control has a name, a role, and a visible focus indicator. +- The interface is operable by keyboard alone. Reordering, paging, search, and dialogs each have a keyboard route. +- Dialogs trap focus, close on Escape, and return focus to the control that opened them. +- Text in the dashboard and in Settings meets the 4.5:1 contrast minimum in both the light and the dark theme. The ratios are computed from the stylesheets by a test, so a colour change that drops a pair below the minimum fails the build. +- The interface honours `prefers-reduced-motion` and `prefers-contrast`. +- Content reflows to a 320 CSS pixel viewport with no horizontal scrolling, and survives text resizing to 200%. +- The interface is translated into six languages. Accessible names are translated with it, apart from those listed below. +- Page changes are announced. Polled figures are not announced, so a screen reader is not interrupted by data that changes on a timer. + +## Known limitations + +- **Widgets need an extra step to read.** Each widget is an embedded document. A screen reader does not enter one during linear navigation, so its contents are read only after the reader is asked to enter it. Widgets containing a button or a link are also reachable with Tab. +- **A phone shortcut that responds only to touch.** On a phone, a widget that has a link set opens it when tapped away from the widget's own controls. There is no keyboard equivalent for that shortcut. Widgets that carry their own link also expose it as a control, and that one is reachable by keyboard. The shortcut does not exist on a desktop. +- **Widget colours are not covered by the contrast test.** Each widget is a separate document with its own stylesheet. Those colours are checked by hand rather than by the build, and a widget rendered on a transparent background cannot be measured automatically. + +## How this was assessed + +- Automated testing with [axe-core](https://github.com/dequelabs/axe-core) in Chromium and WebKit, against the released container. +- Manual browser testing for reflow, text resize, text spacing, target size, and keyboard operation. +- Manual screen reader testing with VoiceOver, on macOS with Safari and on iOS. + +Automated testing has covered every widget. Screen reader testing has covered the dashboard, Settings, and the widgets on a running dashboard, but not every widget in every configuration. The Windows screen readers NVDA and JAWS have not been used. + +## Reporting a problem + +Open an issue at [github.com/SandObserver/stackyard/issues](https://github.com/SandObserver/stackyard/issues). Include the page, the assistive technology and browser, and what you expected to happen. + +Last reviewed 2026-08-23, against Stackyard 1.8.0 and later. diff --git a/CHANGELOG.md b/CHANGELOG.md index 228a3302..ab4fa15e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- An accessibility statement, in `ACCESSIBILITY.md`. + +### 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. + +- Tab no longer moves to tiles on a dashboard page that is off screen. + +- Pagination dots now meet the minimum target size. + +- Widgets no longer read their figures aloud on every poll. System Summary, + Weather, DNS and Now Playing announced themselves on a timer. + +- The Move up and Move down buttons in Settings, and the pull request filter + names in the GitHub widget, are now translated. + ## [1.8.0] - 2026-08-23 ### Added diff --git a/README.md b/README.md index 042a7ce8..9d5e7683 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,10 @@ Full instructions, including building from source, are at [Installation](https:/ Stackyard never returns stored secrets to the browser, guards the URLs you test in the admin UI against SSRF and pins the resolved IP, and bounds every upstream call so one slow service cannot hang the dashboard. Some features trade safety for convenience and are opt-in with warnings. Read [docs/security.md](docs/security.md) before exposing Stackyard beyond your LAN. +## Accessibility + +Stackyard targets WCAG 2.2 level AA and is partially conformant. Every control is named and reachable by keyboard, colours meet the contrast minimum in both themes, and the interface honours the reduced-motion and increased-contrast settings. Known limitations and how this was assessed are in [ACCESSIBILITY.md](ACCESSIBILITY.md). + ## Contributing Contributions are welcome, within the constraints that keep Stackyard small and auditable (one container, no backend dependencies, vanilla frontend). See [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/frontend.md](docs/frontend.md). Participation is covered by the [Code of Conduct](CODE_OF_CONDUCT.md). diff --git a/ui/admin/index.html b/ui/admin/index.html index 4ede3b57..1975848f 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/css/dashboard.css b/ui/css/dashboard.css index 5b0a98e6..44474fb9 100644 --- a/ui/css/dashboard.css +++ b/ui/css/dashboard.css @@ -255,7 +255,9 @@ #dots { position:fixed; bottom:160px; left:50%; /* desktop only — mobile overrides bottom inline */ transform:translateX(-50%); - display:flex; gap:10px; z-index:500; + /* 24px between centres: an interior dot's hit area is bounded by its + neighbours', so the pitch is its width. */ + display:flex; gap:16px; z-index:500; background:rgba(255,255,255,.28); -webkit-backdrop-filter:blur(12px) saturate(140%); backdrop-filter:blur(12px) saturate(140%); border-radius:24px; padding:10px 18px; diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 2c1dada9..0d47e02d 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -29,7 +29,9 @@ "configured": "Eingerichtet", "notSet": "Nicht gesetzt", "show": "Anzeigen", - "hide": "Ausblenden" + "hide": "Ausblenden", + "moveUp": "Nach oben", + "moveDown": "Nach unten" }, "general": { "title": "Titel", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 13aa2e0e..0f29c7c0 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -29,7 +29,9 @@ "configured": "Configured", "notSet": "Not set", "show": "Show", - "hide": "Hide" + "hide": "Hide", + "moveUp": "Move up", + "moveDown": "Move down" }, "general": { "title": "Title", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index 7a7dd60b..6a8c79a2 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -29,7 +29,9 @@ "configured": "Configurado", "notSet": "Sin definir", "show": "Mostrar", - "hide": "Ocultar" + "hide": "Ocultar", + "moveUp": "Subir", + "moveDown": "Bajar" }, "general": { "title": "Título", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index ddc47a0f..a8f2f988 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -29,7 +29,9 @@ "configured": "تنظیم‌شده", "notSet": "تنظیم نشده", "show": "نمایش", - "hide": "پنهان کردن" + "hide": "پنهان کردن", + "moveUp": "انتقال به بالا", + "moveDown": "انتقال به پایین" }, "general": { "title": "عنوان", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index a9f93454..de276fc7 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -29,7 +29,9 @@ "configured": "Définie", "notSet": "Non définie", "show": "Afficher", - "hide": "Masquer" + "hide": "Masquer", + "moveUp": "Monter", + "moveDown": "Descendre" }, "general": { "title": "Titre", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 02ad2c6d..a4aa9e62 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -29,7 +29,9 @@ "configured": "已设置", "notSet": "未设置", "show": "显示", - "hide": "隐藏" + "hide": "隐藏", + "moveUp": "上移", + "moveDown": "下移" }, "general": { "title": "标题", diff --git a/ui/index.html b/ui/index.html index b571bb06..2a3a3e63 100644 --- a/ui/index.html +++ b/ui/index.html @@ -24,13 +24,13 @@ Stackyard - +

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..aadefa12 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 @@ -319,7 +319,7 @@ function mkRow(item, idx, { indent = false, childIdx = null, folderId = null } = const mkMove = (dir, can) => { const b = document.createElement('button'); b.className = 'btn bg sm ic'; - const lbl = dir < 0 ? 'Move up' : 'Move down'; + const lbl = t(dir < 0 ? 'common.moveUp' : 'common.moveDown'); b.title = lbl; b.setAttribute('aria-label', lbl + ': ' + (item.label || item.id || 'item')); b.textContent = dir < 0 ? '↑' : '↓'; @@ -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..ab5bb34f 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=14202a72'; 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); @@ -392,6 +396,20 @@ function announcePage(index, total) { live.textContent = t('home.pageAnnounce', { page: index + 1, total }); } +/* A page that has scrolled off is still in the DOM and still focusable, so Tab + walks out of the visible page into tiles nobody can see and the pager does + not follow. inert takes them out of the tab order and the accessibility tree + together. */ +/** @param {number} current */ +function syncPageInert(current) { + const strip = el('pages'); + if (!strip) return; + [...strip.children].forEach((page, i) => { + if (i === current) page.removeAttribute('inert'); + else page.setAttribute('inert', ''); + }); +} + function goTo(n, dotEls, announce = true) { const total = dotEls ? dotEls.length : totalPages; const was = pg; @@ -400,6 +418,7 @@ function goTo(n, dotEls, announce = true) { if (_stateRef) _stateRef.pg = pg; storeSet(PAGE_STORE, String(pg)); const strip = el('pages'); + syncPageInert(pg); const t = `translateX(-${pg * 100}vw)`; strip.style.transform = strip.style.webkitTransform = t; strip.style.willChange = 'transform'; diff --git a/ui/js/ui.js b/ui/js/ui.js index 027733b8..f5414565 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, @@ -415,6 +415,12 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { curPage = Math.max(0, Math.min(pages.length - 1, n)); strip.style.transform = strip.style.webkitTransform = `translateX(-${curPage * pageW}px)`; dotEls.forEach((d, j) => d.classList.toggle('on', j === curPage)); + /* A page that has scrolled off stays focusable, so Tab would leave the + visible page for tiles nobody can see. */ + [...strip.children].forEach((page, j) => { + if (j === curPage) page.removeAttribute('inert'); + else page.setAttribute('inert', ''); + }); } function buildPage(apps) { @@ -645,8 +651,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, /