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.

' : '' const statusOf = d => d.status === 'failed' ? 'Couldn’t read' : d.status === 'no-text' ? 'No text (scan?)' : 'Indexed' - $('#docTable').innerHTML = attentionNote + '
NameFolderStatus
' + + $('#docTable').innerHTML = attentionNote + '
TypeNameFolderStatusActions
' + docs.slice(0, 2000).map(d => { const link = isLink(d.path) const actions = [] @@ -1087,12 +1096,12 @@ function renderDocuments() { if (d.status === 'no-text' && !link && isAdmin() && feature('ocrOnDemand')) actions.push('') if (desktop && desktop.revealDocument && !link) actions.push('') if (isAdmin()) actions.push('') - return '
' + badge(d.format) + - '' + esc(d.displayName || fileName(d.path)) + '' + + return '
' + badge(d.format) + '' + + '' + esc(d.displayName || fileName(d.path)) + '' + ((d.tags || []).length ? '' + d.tags.map(t => '' + esc(t) + '').join('') + '' : '') + '' + - '' + esc(link ? d.path : d.source === 'library' ? 'Duct Library' : folderOf(d.path)) + '' + - statusOf(d) + '' + actions.join('') + '' + - (d.status === 'failed' && d.error ? '' + esc(fileProblem(d.error)) + '' : '') + '
' + '' + esc(link ? d.path : d.source === 'library' ? 'Duct Library' : folderOf(d.path)) + '' + + '' + statusOf(d) + '' + actions.join('') + '' + + (d.status === 'failed' && d.error ? '' + esc(fileProblem(d.error)) + '' : '') + '
' }).join('') if (attention) renderMascots() } @@ -1781,6 +1790,7 @@ $('#settings').addEventListener('change', async e => { } catch (err) { el.checked = !el.checked; toast('Couldn’t save that. ' + err.message, true) } return } + if (el.dataset.local === 'slashKey') { try { localStorage.setItem('duct.slashKey', el.checked ? 'on' : 'off') } catch {} return } if (el.dataset.pref) { const ok = await desktop.setPref(el.dataset.pref, el.checked).catch(() => false) if (!ok) { el.checked = !el.checked; toast('Couldn’t save that. Try again?', true); return } diff --git a/assets/ui/index.html b/assets/ui/index.html index 72a93b7..4acfaa1 100644 --- a/assets/ui/index.html +++ b/assets/ui/index.html @@ -9,6 +9,7 @@ +