From 6bef4e69dfd69e93fda4caf60dda7bbee1bc5ee4 Mon Sep 17 00:00:00 2001
From: AropJoe
Date: Sat, 10 Oct 2026 00:23:21 +0100
Subject: [PATCH] Accessibility: WCAG 2.2 AA fixes, keyboard reading, and
checks in CI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Contrast: dim text, red and field borders now meet AA (4.5:1 text, 3:1 fields).
- Focus is always visible in fields; a Skip to content link; the documents list
is a proper table for screen readers.
- Keyboard: in a document, up and down select a paragraph and Enter adds it to
notes; ⌘⇧N / Ctrl+Shift+N and ⌘⇧M work on any selection; the notebook menu
moves with arrows.
- Reflow at 400% zoom without sideways scrolling, in the app and the workspace.
- The / shortcut can be turned off; the mascot stops moving within 5 seconds;
workspace messages are announced.
- npm run a11y checks every main screen with axe-core against WCAG 2.2 A and
AA; CI runs it on macOS.
---
.github/workflows/ci.yml | 13 ++++++
.gitignore | 1 +
assets/ui/app.css | 26 +++++++++--
assets/ui/app.js | 24 +++++++---
assets/ui/index.html | 5 ++-
package-lock.json | 11 +++++
package.json | 4 +-
scripts/a11y-electron.cjs | 92 +++++++++++++++++++++++++++++++++++++++
src/island.ts | 2 +-
src/viewer.ts | 2 +-
src/workspace.ts | 77 ++++++++++++++++++++++++++++++--
11 files changed, 238 insertions(+), 19 deletions(-)
create mode 100644 scripts/a11y-electron.cjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index abd8003..431e408 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -42,3 +42,16 @@ jobs:
with:
name: dist
path: dist/
+
+ accessibility:
+ # Every main screen against WCAG 2.2 A and AA with axe-core, in Chromium (scripts/a11y-electron.cjs).
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npm run build
+ - run: npm run a11y
diff --git a/.gitignore b/.gitignore
index b6ff400..98a6e66 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ ANNOUNCEMENT.md
*.DS_Store
social-preview.html
release/
+a11y-report.json
diff --git a/assets/ui/app.css b/assets/ui/app.css
index f5f83f7..960764c 100644
--- a/assets/ui/app.css
+++ b/assets/ui/app.css
@@ -5,7 +5,8 @@
--s3: #1f1f1d;
--border: #252522;
--border2: #34342f;
- --muted: #6a6a65;
+ --field-border: #6a6a65; /* text fields: 3:1 against every panel (WCAG 1.4.11) */
+ --muted: #888883; /* 4.6:1 on the darkest panel: WCAG AA for small text */
--subtle: #9a9a93;
--body: #CFCEC6;
--text: #F0EFE8;
@@ -15,7 +16,7 @@
--lime-mark: #23310a;
--warn: #E8A020;
--warn-bg: #1E1408;
- --danger: #E05555;
+ --danger: #E85E5E;
--danger-bg: #1E0808;
--mono: 'SF Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
@@ -84,7 +85,7 @@ body.no-mascot .mascot-slot { display: none; }
.app { height: 100%; display: flex; flex-direction: column; }
.topbar { display: flex; align-items: center; gap: 14px; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--s1); }
.brand { display: flex; align-items: center; gap: 8px; color: var(--text); text-decoration: none; font-family: var(--mono); font-weight: 650; font-size: 17px; letter-spacing: -0.03em; flex-shrink: 0; }
-.searchbar { flex: 1; max-width: 760px; margin: 0 auto; display: flex; align-items: center; gap: 8px; background: var(--bg); border: 1px solid var(--border2); border-radius: 12px; padding: 4px 10px 4px 4px; transition: border-color .15s; }
+.searchbar { flex: 1; max-width: 760px; margin: 0 auto; display: flex; align-items: center; gap: 8px; background: var(--bg); border: 1px solid var(--field-border); border-radius: 12px; padding: 4px 10px 4px 4px; transition: border-color .15s; }
.searchbar:focus-within { border-color: var(--lime); }
.searchbar input { flex: 1; min-width: 0; background: transparent; border: 0; outline: none; color: var(--text); font-size: 15px; padding: 8px 4px; }
.searchbar input::-webkit-search-cancel-button { filter: invert(.6); }
@@ -435,3 +436,22 @@ dialog::backdrop { background: rgba(0,0,0,.55); }
.visibility-row select, .visibility-row input { background: var(--bg); color: var(--text); border: 1px solid var(--border2); border-radius: var(--r); padding: 5px 8px; font: inherit; font-size: 12.5px; }
.visibility-row input { flex: 1; min-width: 180px; }
.visibility-row small { flex-basis: 100%; }
+
+/* Read by screen readers, not shown. */
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
+
+/* Fields are outlined so they can be found (WCAG 1.4.11), and show where the keyboard is (2.4.7). */
+input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea { border-color: var(--field-border); }
+input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--lime) !important; outline-offset: 1px; }
+.searchbar input:focus-visible { outline: none !important; } /* the bar around it lights up instead */
+
+.skip-link { position: absolute; left: 12px; top: -48px; z-index: 100; background: var(--lime); color: #0C0C0B; font-weight: 600; padding: 8px 14px; border-radius: var(--r); text-decoration: none; }
+.skip-link:focus { top: 12px; }
+
+/* Zoomed in or narrow (WCAG 1.4.10: no sideways scrolling at 320 CSS pixels). */
+.result-actions { flex-wrap: wrap; }
+@media (max-width: 480px) {
+ .searchbar { min-width: 0; padding-left: 2px; }
+ .mode-switch .labs { display: none; }
+ .mode-switch .mode { padding-left: 8px; padding-right: 8px; }
+}
diff --git a/assets/ui/app.js b/assets/ui/app.js
index 46d51d0..9589486 100644
--- a/assets/ui/app.js
+++ b/assets/ui/app.js
@@ -263,11 +263,17 @@ function renderFeatureList(prefs, shortcut) {
let html = FEATURE_GROUPS.map(g => '
' + esc(g.title) + '
' + g.items.map(([name, label, help]) => check('data-feature="' + name + '"', feature(name), label, help, !admin)).join('')).join('')
html += '
File types Duct reads
Switched-off types are skipped when indexing and hidden from search. Turning one back on rescans watched folders.
'
html += Object.keys(KIND_LABELS).map(kind => check('data-format-kind="' + kind + '"', kindOn(kind), KIND_LABELS[kind], '', !admin)).join('')
+ // Single-key shortcuts can be turned off (WCAG 2.1.4): speech input can type "/" by accident.
+ html += '
Keyboard
' + check('data-local="slashKey"', slashKeyOn(), 'Press / to search', 'Typing / anywhere outside a text box jumps to the search box. ⌘K or Ctrl+K always works.', false)
if (prefs) html += '
This computer
' + DESKTOP_PREFS.map(([name, label, help]) => check('data-pref="' + name + '"', prefs[name] !== false, label, help, false) + (name === 'shortcut' ? shortcutRow(shortcut) : '')).join('')
$('#featureList').innerHTML = html
$('#featuresHint').textContent = admin ? 'Turn off anything you don’t use. Switched-off features disappear from Duct and its API.' : 'Only an admin can change these.'
}
+function slashKeyOn() {
+ try { return localStorage.getItem('duct.slashKey') !== 'off' } catch { return true }
+}
+
const DAY = 86400000
const DATE_RANGES = [
{ id: 'week', label: 'Past week', after: () => Date.now() - 7 * DAY },
@@ -424,6 +430,9 @@ async function renderMascots() {
canvas.height = slot.clientHeight * 2
slot.appendChild(canvas)
slot.player = new DotLottie({ canvas, src: '/mascot/mascot.lottie', animationId: POSES[pose], autoplay: !reduceMotion, loop: true })
+ // Movement stops by itself within 5 seconds (WCAG 2.2.2): it finishes the loop it's in and rests.
+ const player = slot.player
+ setTimeout(() => { try { player.setLoop(false) } catch {} }, 5000)
slot.dataset.pose = pose
slot.player.addEventListener('load', () => {
if (reduceMotion) slot.player.setFrame(Math.floor(slot.player.totalFrames / 2))
@@ -1047,7 +1056,7 @@ $('#q').addEventListener('keydown', e => {
document.addEventListener('keydown', e => {
const typing = e.target.closest && e.target.closest('input, textarea, select, [contenteditable]')
- if ((e.key === '/' && !typing) || ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k')) { e.preventDefault(); focusSearch() }
+ if ((e.key === '/' && !typing && slashKeyOn()) || ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k')) { e.preventDefault(); focusSearch() }
})
function focusSearch() {
@@ -1078,7 +1087,7 @@ function renderDocuments() {
(docs.length === 1 ? 'Duct couldn’t read this file.' : 'Duct couldn’t read these ' + fmt(docs.length) + ' files.') + ' ' +
'Under each one is why, and what usually fixes it. Everything else is searchable meanwhile.