From abb2107df24c0cefeed6e152dd0a1cf8c60145b1 Mon Sep 17 00:00:00 2001 From: QvarcY <118832438+QvarcY@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:29:31 +0300 Subject: [PATCH 1/2] fix: harden accessibility and static distribution --- .github/workflows/ci.yml | 5 +- CHANGELOG.md | 22 +++++++++ README.lv.md | 2 +- README.md | 2 +- ROADMAP.md | 13 ++--- docs/STATIC_DEPLOYMENT.md | 58 +++++++++++++++++++++++ docs/releases/v0.1.0-alpha.2.md | 24 ++++++++++ package-lock.json | 4 +- package.json | 7 +-- scripts/verify-dist.mjs | 24 ++++++++++ src/styles/components.css | 6 ++- src/ui/components/record-detail-drawer.js | 57 +++++++++++++++++++++- src/ui/components/results-table.js | 19 ++++---- src/ui/views/comparing-view.js | 7 ++- vite.config.js | 37 +++++++++++++-- 15 files changed, 257 insertions(+), 30 deletions(-) create mode 100644 docs/STATIC_DEPLOYMENT.md create mode 100644 docs/releases/v0.1.0-alpha.2.md create mode 100644 scripts/verify-dist.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb16edc..8396624 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,11 @@ jobs: - name: Install dependencies run: npm ci + - name: Dependency audit + run: npm audit --audit-level=high + - name: Run tests run: npm test - - name: Build + - name: Build and verify static dist run: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e228d..df48a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable project changes will be recorded here. The project is currently in pre-release development. +## [0.1.0-alpha.2] - 2026-09-26 + +Quality-hardening alpha. + +### Added + +- conservative date normalization with explicit Expert Mode formats for ambiguous dates; +- broader parser, normalization, mapping and export regression coverage; +- EN/LV translation-key parity checks; +- privacy/security source invariants; +- 10,000-row performance regression baseline; +- production-build Content Security Policy with `connect-src 'none'`; +- production static-dist verification; +- dependency audit in CI; +- stronger dialog focus management and table keyboard semantics. + +### Changed + +- automatic matching now refuses closely competing identifier keys instead of guessing; +- known semantic mapping conflicts are rejected automatically; +- README/release metadata advanced to the second alpha candidate. + ## [0.1.0-alpha.1] - 2026-09-26 First functional alpha of Two-Export Comparator. diff --git a/README.lv.md b/README.lv.md index b84cd7c..25b99a1 100644 --- a/README.lv.md +++ b/README.lv.md @@ -7,7 +7,7 @@ > **Divi faili iekšā. Atšķirības ārā.** > Privātumam draudzīgs pārlūka rīks, kas lokāli salīdzina divus CSV/TSV eksportus un vizuāli parāda atšķirības blakus. -**Statuss:** `v0.1.0-alpha.1` kandidāts — pirms publiskā laidiena / aktīvā izstrādē +**Statuss:** `v0.1.0-alpha.2` kandidāts — pirms publiskā laidiena / aktīvā izstrādē ## Kāpēc šis projekts pastāv diff --git a/README.md b/README.md index 0a360e6..bb7a9b8 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ > **Two files in. Differences out.** > A privacy-first browser tool that compares two CSV/TSV exports locally and shows the differences side by side. -**Status:** `v0.1.0-alpha.1` candidate — pre-release / active development +**Status:** `v0.1.0-alpha.2` candidate — pre-release / active development ## Why this exists diff --git a/ROADMAP.md b/ROADMAP.md index ee2bb70..f849979 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,10 +97,10 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked - ✅ Result CSV export. - ⬜ Filtered exports. - ✅ CSV formula-injection protection. -- ⬜ CSP suitable for static deployment, including connect-src 'none' where deployment permits it. +- ✅ Production build injects a static CSP with `connect-src 'none'`; final host should still prefer equivalent HTTP headers. - ✅ No analytics, telemetry or external data APIs in the comparison flow. - ✅ No business-file content stored in localStorage, IndexedDB or cookies. -- 🟡 Automated source invariants now reject network/storage APIs and unsafe HTML/code execution; deployment CSP/download review remains. +- ✅ Automated source invariants reject network/storage APIs and unsafe HTML/code execution; production dist is verified for CSP and external entry resources. - ✅ Privacy claims are protected by automated source invariants for network and persistent browser storage APIs. ## Phase 6 — Quality gate / Kvalitātes vārti 🟡 @@ -111,17 +111,17 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked - ✅ CSV export regression coverage protects formula-like key cells. - ⬜ Integration tests. - ⬜ Regression tests that fail on the pre-fix behavior. -- ⬜ Accessibility audit: keyboard, labels, focus, screen reader semantics. +- 🟡 Code-level accessibility pass completed for keyboard controls, progress announcements and modal focus management; manual screen-reader/contrast walkthrough remains. - ⬜ Responsive review. - ✅ EN/LV translation key parity test. - ⬜ Contributor translation validation. - ✅ 10k-row parse + compare baseline runs in CI. -- ⬜ Dependency audit. +- ✅ High-severity dependency audit runs in CI. ## Phase 7 — Distribution / Izplatīšana ⬜ - ⬜ GitHub Pages deployment. -- ⬜ Offline-capable static build. +- 🟡 Relative-path static build is verified and can be served without a backend; full offline/PWA behavior remains intentionally unevaluated. - ⬜ Evaluate a self-contained single HTML release. - ⬜ Release checksums where useful. - ⬜ Public demo with sample data only. @@ -154,7 +154,8 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked | 2026-09-26 | R7 — Alpha release preparation | ✅ | Prepared and published v0.1.0-alpha.1 prerelease metadata, changelog and release notes; simplified CI to one PR check plus one main-branch check and added concurrency cancellation. Social Preview remains deferred until repository publication. | | 2026-09-26 | R8 — Automatic matching hardening | ✅ | Added explicit mapping roles, conservative key ambiguity rejection, semantic conflict guards and broader parser/normalization/export regression coverage without changing the primary workflow. | | 2026-09-26 | R9 — Alpha quality gate | ✅ | Added conservative date normalization with optional Expert Mode date formats, EN/LV key parity, privacy/security source invariants and a 10k-row CI performance baseline. | +| 2026-09-26 | R10 — Accessibility + static hardening | 🟡 | Added modal focus trapping/restore, real table action buttons, polite progress announcements, production CSP/static-dist verification and CI dependency auditing. Manual device/screen-reader review and alpha.2 publication remain. | ## Next target / Nākamais mērķis -**Next:** manual accessibility/responsive review and deployment CSP/static distribution work. Core comparison scope remains frozen. +**Next:** verify alpha.2 locally, publish `v0.1.0-alpha.2`, then perform the final manual keyboard/responsive/screen-reader walkthrough before repository publication. Core comparison scope remains frozen. diff --git a/docs/STATIC_DEPLOYMENT.md b/docs/STATIC_DEPLOYMENT.md new file mode 100644 index 0000000..f06b92b --- /dev/null +++ b/docs/STATIC_DEPLOYMENT.md @@ -0,0 +1,58 @@ +# Static deployment + +Two-Export Comparator is designed to ship as static files from `dist/`. + +## Build + +```bash +npm ci +npm test +npm run build +``` + +The build command also verifies that: + +- the generated `dist/index.html` contains the production Content Security Policy; +- `connect-src 'none'` is present; +- the entry document does not reference external HTTP(S) scripts/styles/resources. + +Because Vite uses `base: './'`, the generated app can be hosted at a domain root or a nested static path. + +## Recommended hosting headers + +The production HTML contains a CSP meta policy so the downloaded static build keeps a restrictive baseline. When the hosting platform allows response headers, prefer enforcing the equivalent policy as an HTTP header. + +Recommended baseline: + +```text +Content-Security-Policy: + default-src 'self'; + script-src 'self'; + style-src 'self' 'unsafe-inline'; + img-src 'self' data:; + font-src 'self'; + connect-src 'none'; + object-src 'none'; + base-uri 'none'; + form-action 'none'; + frame-src 'none'; + worker-src 'none' +``` + +Also consider: + +```text +X-Content-Type-Options: nosniff +Referrer-Policy: no-referrer +Cross-Origin-Opener-Policy: same-origin +``` + +## Privacy check + +A static host necessarily receives requests for the application files themselves (HTML/JS/CSS). Selected CSV/TSV business files are read by browser JavaScript and are not intentionally sent to the host. + +Before publishing a demo, verify this behavior in browser developer tools using synthetic data. + +## Not included + +There is currently no service worker or background synchronization. Offline/PWA behavior is not part of the core promise. diff --git a/docs/releases/v0.1.0-alpha.2.md b/docs/releases/v0.1.0-alpha.2.md new file mode 100644 index 0000000..d78fc7b --- /dev/null +++ b/docs/releases/v0.1.0-alpha.2.md @@ -0,0 +1,24 @@ +# v0.1.0-alpha.2 — Quality hardening alpha + +Two files in. Differences out. + +This prerelease keeps the same deliberately small product workflow while hardening the comparison engine, privacy invariants and static distribution. + +## Highlights + +- automatic matching refuses genuinely ambiguous key choices instead of guessing; +- known semantic column conflicts are not paired automatically; +- conservative date normalization with optional Expert Mode date formats; +- expanded parser, normalization, export and mapping regression coverage; +- EN/LV translation-key parity checks; +- privacy/security source-invariant tests; +- 10,000-row performance regression baseline; +- production static CSP with `connect-src 'none'`; +- static-dist verification and high-severity dependency audit in CI; +- stronger modal focus handling and accessible table actions. + +## Important + +This remains an alpha prerelease. Manual screen-reader, contrast and representative-device walkthroughs still need to be completed before a public stable release. + +Use synthetic or non-sensitive sample data when reporting bugs publicly. diff --git a/package-lock.json b/package-lock.json index 30eaf1d..ce74c7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "two-export-comparator", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "two-export-comparator", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "devDependencies": { "vite": "^7.0.0" } diff --git a/package.json b/package.json index 8fd7531..78ea5b5 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { "name": "two-export-comparator", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "private": true, "type": "module", "description": "Privacy-first browser utility for comparing two CSV/TSV exports locally.", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && node scripts/verify-dist.mjs", "preview": "vite preview", - "test": "node --test" + "test": "node --test", + "verify:dist": "node scripts/verify-dist.mjs" }, "devDependencies": { "vite": "^7.0.0" diff --git a/scripts/verify-dist.mjs b/scripts/verify-dist.mjs new file mode 100644 index 0000000..9a48c0b --- /dev/null +++ b/scripts/verify-dist.mjs @@ -0,0 +1,24 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const html = await readFile(resolve('dist', 'index.html'), 'utf8'); + +const required = [ + "Content-Security-Policy", + "connect-src 'none'", + "script-src 'self'", + "object-src 'none'", + "base-uri 'none'", +]; + +for (const fragment of required) { + if (!html.includes(fragment)) { + throw new Error('Production dist is missing required CSP fragment: ' + fragment); + } +} + +if (/(?:src|href)=["']https?:\/\//i.test(html)) { + throw new Error('Production dist index contains an external script/style/resource URL'); +} + +console.log('dist verification passed: static CSP present, no external entry resources'); diff --git a/src/styles/components.css b/src/styles/components.css index 797c45d..19cfc4a 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1426,7 +1426,11 @@ .results-table__chevron { color: var(--color-text-3); text-align: right; - width: 40px; + width: 48px; +} + +.results-table__open { + margin-left: auto; } .results-table__row:hover .results-table__chevron { diff --git a/src/ui/components/record-detail-drawer.js b/src/ui/components/record-detail-drawer.js index e45a1f5..4691cb2 100644 --- a/src/ui/components/record-detail-drawer.js +++ b/src/ui/components/record-detail-drawer.js @@ -15,6 +15,8 @@ const STATUS_KEY = { export class RecordDetailDrawer { constructor(store) { this.store = store; + this._isOpen = false; + this._previousFocus = null; this.root = el('aside', { class: 'record-drawer', @@ -28,7 +30,15 @@ export class RecordDetailDrawer { window.addEventListener(LOCALE_CHANGE_EVENT, () => this._sync()); document.addEventListener('keydown', (event) => { - if (event.key === 'Escape' && this.store.openRecordId) this._close(); + if (!this._isOpen) return; + + if (event.key === 'Escape') { + event.preventDefault(); + this._close(); + return; + } + + if (event.key === 'Tab') this._trapFocus(event); }); this._sync(); @@ -44,8 +54,18 @@ export class RecordDetailDrawer { const id = this.store.openRecordId; if (!id || this.store.state !== 'RESULTS') { + const shouldRestoreFocus = this._isOpen; + this._isOpen = false; this.root.hidden = true; replaceChildren(this.root, []); + + if (shouldRestoreFocus) { + const target = this._previousFocus; + this._previousFocus = null; + queueMicrotask(() => { + if (target instanceof HTMLElement && target.isConnected) target.focus(); + }); + } return; } @@ -55,8 +75,20 @@ export class RecordDetailDrawer { return; } + if (!this._isOpen) { + this._isOpen = true; + this._previousFocus = document.activeElement; + } + this.root.hidden = false; this._render(record); + + queueMicrotask(() => { + const closeButton = this.root.querySelector('[data-dialog-close="true"]'); + if (closeButton instanceof HTMLElement && !this.root.contains(document.activeElement)) { + closeButton.focus(); + } + }); } _render(record) { @@ -92,11 +124,34 @@ export class RecordDetailDrawer { class: 'btn btn--ghost btn--icon', 'aria-label': t('record.close'), title: t('record.close'), + dataset: { dialogClose: 'true' }, onclick: () => this._close(), }, [icon(Icons.x, { size: 16 })]), ]); } + _trapFocus(event) { + const focusable = [...this.root.querySelectorAll( + 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + )].filter((node) => node instanceof HTMLElement && !node.hidden); + + if (focusable.length === 0) { + event.preventDefault(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + _renderExplain(record) { return el('p', { class: 'record-drawer__explain', diff --git a/src/ui/components/results-table.js b/src/ui/components/results-table.js index 0dca97e..a685cf6 100644 --- a/src/ui/components/results-table.js +++ b/src/ui/components/results-table.js @@ -86,17 +86,8 @@ export class ResultsTable { return el('tr', { class: 'results-table__row', - tabindex: '0', dataset: { status: record.status }, - role: 'button', - 'aria-label': t('results.openRecord', { key: record.keyLabel }), onclick: () => this._open(record.id), - onkeydown: (event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - this._open(record.id); - } - }, }, [ el('td', {}, [ el('span', { @@ -109,7 +100,15 @@ export class ResultsTable { el('td', { class: 'u-text-mono u-text-muted', text: record.source?.rowA ?? '—' }), el('td', { class: 'u-text-mono u-text-muted', text: record.source?.rowB ?? '—' }), el('td', { class: 'results-table__chevron' }, [ - icon(Icons.arrowRight, { size: 14 }), + el('button', { + type: 'button', + class: 'btn btn--ghost btn--icon btn--sm results-table__open', + 'aria-label': t('results.openRecord', { key: record.keyLabel }), + onclick: (event) => { + event.stopPropagation(); + this._open(record.id); + }, + }, [icon(Icons.arrowRight, { size: 14 })]), ]), ]); } diff --git a/src/ui/views/comparing-view.js b/src/ui/views/comparing-view.js index 666d1ab..b018436 100644 --- a/src/ui/views/comparing-view.js +++ b/src/ui/views/comparing-view.js @@ -46,7 +46,12 @@ export class ComparingView { replaceChildren(this.root, [ el('div', { class: 'u-container' }, [ - el('section', { class: 'comparing' }, [ + el('section', { + class: 'comparing', + 'aria-live': 'polite', + 'aria-busy': 'true', + 'aria-atomic': 'true', + }, [ el('div', { class: 'comparing__spinner', 'aria-hidden': 'true' }), el('h2', { class: 'comparing__title', text: title }), el('p', { class: 'comparing__subtitle u-text-muted', text: t('comparing.local') }), diff --git a/vite.config.js b/vite.config.js index 8bc6797..85422ad 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,38 @@ -import { defineConfig } from 'vite'; +import { defineConfig } from 'vite'; -export default defineConfig({ +const STATIC_CSP = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'none'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'none'", + "frame-src 'none'", + "worker-src 'none'", +].join('; '); + +function staticCspPlugin() { + return { + name: 'static-csp', + transformIndexHtml() { + return [{ + tag: 'meta', + attrs: { + 'http-equiv': 'Content-Security-Policy', + content: STATIC_CSP, + }, + injectTo: 'head-prepend', + }]; + }, + }; +} + +export default defineConfig(({ command }) => ({ base: './', + plugins: command === 'build' ? [staticCspPlugin()] : [], build: { target: 'es2022', outDir: 'dist', @@ -13,4 +44,4 @@ export default defineConfig({ port: 5173, open: true, }, -}); \ No newline at end of file +})); From 079f5da9a5d9bc6273aa0aba2775ad4fbf9b4205 Mon Sep 17 00:00:00 2001 From: QvarcY <118832438+QvarcY@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:30:45 +0300 Subject: [PATCH 2/2] fix: decode CSP entities during dist verification --- scripts/verify-dist.mjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/verify-dist.mjs b/scripts/verify-dist.mjs index 9a48c0b..3eb5bea 100644 --- a/scripts/verify-dist.mjs +++ b/scripts/verify-dist.mjs @@ -2,9 +2,13 @@ import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; const html = await readFile(resolve('dist', 'index.html'), 'utf8'); +const normalizedHtml = html + .replaceAll(''', "'") + .replaceAll('"', '"') + .replaceAll('&', '&'); const required = [ - "Content-Security-Policy", + 'Content-Security-Policy', "connect-src 'none'", "script-src 'self'", "object-src 'none'", @@ -12,12 +16,12 @@ const required = [ ]; for (const fragment of required) { - if (!html.includes(fragment)) { + if (!normalizedHtml.includes(fragment)) { throw new Error('Production dist is missing required CSP fragment: ' + fragment); } } -if (/(?:src|href)=["']https?:\/\//i.test(html)) { +if (/(?:src|href)=["']https?:\/\//i.test(normalizedHtml)) { throw new Error('Production dist index contains an external script/style/resource URL'); }