Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ ANNOUNCEMENT.md
*.DS_Store
social-preview.html
release/
a11y-report.json
26 changes: 23 additions & 3 deletions assets/ui/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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); }
Expand Down Expand Up @@ -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; }
}
24 changes: 17 additions & 7 deletions assets/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,11 +263,17 @@ function renderFeatureList(prefs, shortcut) {
let html = FEATURE_GROUPS.map(g => '<h3>' + esc(g.title) + '</h3>' + g.items.map(([name, label, help]) => check('data-feature="' + name + '"', feature(name), label, help, !admin)).join('')).join('')
html += '<h3>File types Duct reads</h3><p class="hint">Switched-off types are skipped when indexing and hidden from search. Turning one back on rescans watched folders.</p>'
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 += '<h3>Keyboard</h3>' + 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 += '<h3>This computer</h3>' + 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 },
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -1078,7 +1087,7 @@ function renderDocuments() {
(docs.length === 1 ? 'Duct couldn’t read this file.' : 'Duct couldn’t read these ' + fmt(docs.length) + ' files.') + '</strong> ' +
'Under each one is why, and what usually fixes it. Everything else is searchable meanwhile.</p></div>' : ''
const statusOf = d => d.status === 'failed' ? '<span class="status bad">Couldn’t read</span>' : d.status === 'no-text' ? '<span class="status warn">No text (scan?)</span>' : '<span class="status ok">Indexed</span>'
$('#docTable').innerHTML = attentionNote + '<div class="doc-row head" role="row"><span></span><span>Name</span><span class="folder-col">Folder</span><span>Status</span><span></span></div>' +
$('#docTable').innerHTML = attentionNote + '<div class="doc-row head" role="row"><span role="columnheader"><span class="sr-only">Type</span></span><span role="columnheader">Name</span><span class="folder-col" role="columnheader">Folder</span><span role="columnheader">Status</span><span role="columnheader"><span class="sr-only">Actions</span></span></div>' +
docs.slice(0, 2000).map(d => {
const link = isLink(d.path)
const actions = []
Expand All @@ -1087,12 +1096,12 @@ function renderDocuments() {
if (d.status === 'no-text' && !link && isAdmin() && feature('ocrOnDemand')) actions.push('<button class="btn btn-sm" data-doc-act="ocr">Read with OCR</button>')
if (desktop && desktop.revealDocument && !link) actions.push('<button class="btn btn-sm" data-doc-act="reveal">Show</button>')
if (isAdmin()) actions.push('<button class="btn btn-sm btn-danger" data-doc-act="remove" title="Remove from Duct">✕</button>')
return '<div class="doc-row" role="row" data-path="' + esc(d.path) + '">' + badge(d.format) +
'<span class="name-cell"><span class="name" title="' + esc((d.displayName || fileName(d.path)) + '\n' + d.path) + '">' + esc(d.displayName || fileName(d.path)) + '</span>' +
return '<div class="doc-row" role="row" data-path="' + esc(d.path) + '"><span role="cell">' + badge(d.format) + '</span>' +
'<span class="name-cell" role="cell"><span class="name" title="' + esc((d.displayName || fileName(d.path)) + '\n' + d.path) + '">' + esc(d.displayName || fileName(d.path)) + '</span>' +
((d.tags || []).length ? '<span class="tags">' + d.tags.map(t => '<span class="tag">' + esc(t) + '</span>').join('') + '</span>' : '') + '</span>' +
'<span class="folder">' + esc(link ? d.path : d.source === 'library' ? 'Duct Library' : folderOf(d.path)) + '</span>' +
statusOf(d) + '<span class="row-actions">' + actions.join('') + '</span>' +
(d.status === 'failed' && d.error ? '<span class="err" title="' + esc(d.error) + '">' + esc(fileProblem(d.error)) + '</span>' : '') + '</div>'
'<span class="folder" role="cell">' + esc(link ? d.path : d.source === 'library' ? 'Duct Library' : folderOf(d.path)) + '</span>' +
'<span role="cell">' + statusOf(d) + '</span><span class="row-actions" role="cell">' + actions.join('') + '</span>' +
(d.status === 'failed' && d.error ? '<span class="err" role="cell" title="' + esc(d.error) + '">' + esc(fileProblem(d.error)) + '</span>' : '') + '</div>'
}).join('')
if (attention) renderMascots()
}
Expand Down Expand Up @@ -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 }
Expand Down
5 changes: 3 additions & 2 deletions assets/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<script type="module" src="/ui/app.js"></script>
</head>
<body>
<a class="skip-link" href="#content">Skip to content</a>
<div class="offline-note" id="offlineNote" role="status" hidden>You’re offline. Everything in Duct is still searchable.</div>
<!-- First run: shown until something has been added (or the person skips it). -->
<section id="welcome" class="welcome" hidden aria-labelledby="welcomeTitle">
Expand Down Expand Up @@ -116,7 +117,7 @@ <h2>Sources</h2>
</div>
</aside>

<main class="content" id="content">
<main class="content" id="content" tabindex="-1">
<section id="viewHome" class="view" aria-label="Home">
<div class="home">
<div class="mascot-slot mascot-lg" data-mascot="idle" aria-hidden="true"></div>
Expand Down Expand Up @@ -405,7 +406,7 @@ <h2 id="feedbackTitle">Send feedback</h2>
<div class="collect-body">
<p class="hint">Tell us what worked, what didn’t, or what you expected. It goes to the Duct team at Tensflare.</p>
<label class="field"><span>Your message</span><textarea id="feedbackMessage" rows="6" maxlength="5000" placeholder="What happened? What did you expect?"></textarea></label>
<label class="field"><span>Email <small>optional, if you’d like a reply</small></span><input type="email" id="feedbackEmail" maxlength="254"></label>
<label class="field"><span>Email <small>optional, if you’d like a reply</small></span><input type="email" id="feedbackEmail" maxlength="254" autocomplete="email"></label>
<label class="check"><input type="checkbox" id="feedbackDiag" checked> <span><strong>Include diagnostics</strong><small>Version, operating system, settings and error codes. Never your documents, file names, paths or searches.</small></span></label>
<details class="telemetry-details"><summary>See exactly what’s included</summary><pre id="feedbackDiagPreview"></pre></details>
<label class="check" id="feedbackCrashRow" hidden><input type="checkbox" id="feedbackCrashes"> <span><strong>Include crash reports (<span id="feedbackCrashCount">0</span>)</strong><small>The type of error and the place in Duct’s code where it happened. No messages, documents or paths.</small></span></label>
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"electron:build": "npm run build && electron-builder --mac --linux --win",
"electron:build:mac": "npm run build && electron-builder --mac",
"icons": "node scripts/build-icons.mjs",
"bench": "node scripts/bench.mjs"
"bench": "node scripts/bench.mjs",
"a11y": "electron scripts/a11y-electron.cjs"
},
"build": {
"appId": "com.docfide.duct",
Expand Down Expand Up @@ -135,6 +136,7 @@
"@types/express": "^4.17.0",
"@types/multer": "^1.4.0",
"@types/node": "^22.20.5",
"axe-core": "^4.14.0",
"cohere-ai": "^8.0.0",
"electron": "^42.3.0",
"electron-builder": "^26.8.1",
Expand Down
Loading
Loading