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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.lv.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 7 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 🟡
Expand All @@ -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.
Expand Down Expand Up @@ -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.
58 changes: 58 additions & 0 deletions docs/STATIC_DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions docs/releases/v0.1.0-alpha.2.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

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

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
28 changes: 28 additions & 0 deletions scripts/verify-dist.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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',
"connect-src 'none'",
"script-src 'self'",
"object-src 'none'",
"base-uri 'none'",
];

for (const fragment of required) {
if (!normalizedHtml.includes(fragment)) {
throw new Error('Production dist is missing required CSP fragment: ' + fragment);
}
}

if (/(?:src|href)=["']https?:\/\//i.test(normalizedHtml)) {
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');
6 changes: 5 additions & 1 deletion src/styles/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
57 changes: 56 additions & 1 deletion src/ui/components/record-detail-drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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();
Expand All @@ -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;
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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',
Expand Down
19 changes: 9 additions & 10 deletions src/ui/components/results-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand All @@ -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 })]),
]),
]);
}
Expand Down
Loading
Loading