diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..a29f639
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,13 @@
+* text=auto eol=lf
+
+*.bat text eol=crlf
+*.cmd text eol=crlf
+*.ps1 text eol=crlf
+
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.webp binary
+*.ico binary
+*.pdf binary
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..3e92aca
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,46 @@
+name: Bug report
+description: Report reproducible incorrect behavior
+title: "[Bug]: "
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for reporting a bug. Please use synthetic data only — never attach confidential exports.
+ - type: textarea
+ id: summary
+ attributes:
+ label: What happened?
+ description: Describe the incorrect behavior.
+ validations:
+ required: true
+ - type: textarea
+ id: reproduce
+ attributes:
+ label: Steps to reproduce
+ placeholder: |
+ 1. Open...
+ 2. Load synthetic File A...
+ 3. Load synthetic File B...
+ 4. Observe...
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ validations:
+ required: true
+ - type: input
+ id: browser
+ attributes:
+ label: Browser / version
+ - type: input
+ id: os
+ attributes:
+ label: Operating system
+ - type: textarea
+ id: fixture
+ attributes:
+ label: Minimal synthetic fixture
+ description: Paste the smallest non-sensitive example that reproduces the problem.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..8005e32
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,2 @@
+blank_issues_enabled: false
+contact_links: []
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..a3259e2
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,41 @@
+name: Feature request
+description: Suggest a focused improvement
+title: "[Feature]: "
+labels: ["enhancement"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Core promise: two files in → visual differences out.
+ Please explain how the proposal improves that workflow.
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem
+ description: What comparison problem is difficult today?
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed improvement
+ validations:
+ required: true
+ - type: textarea
+ id: scope
+ attributes:
+ label: Why does this belong in the core?
+ description: How does it help users understand differences faster without making the normal workflow harder?
+ validations:
+ required: true
+ - type: checkboxes
+ id: principles
+ attributes:
+ label: Scope check
+ options:
+ - label: This directly helps compare two files.
+ required: true
+ - label: This can preserve local-first/privacy-first behavior.
+ required: true
+ - label: This does not require turning the project into a general spreadsheet/workspace product.
+ required: true
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..fc9d380
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,25 @@
+## Summary
+
+What changed and why?
+
+## Scope
+
+- [ ] This change directly supports comparing two files or the quality of that workflow.
+- [ ] The default UX remains understandable for non-expert users.
+- [ ] Local-first/privacy-first behavior is preserved.
+
+## Verification
+
+- [ ] `npm test`
+- [ ] `npm run build`
+- [ ] I tested the affected UI/behavior manually where relevant.
+- [ ] New or changed behavior has meaningful regression coverage where practical.
+
+## Data safety
+
+- [ ] No real confidential business/customer export is included in this PR.
+
+## Documentation
+
+- [ ] README/docs updated if user-visible behavior changed.
+- [ ] ROADMAP updated if this completes or changes a planned development round.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..297753c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,34 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ - ui/frontend-prototype
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ test-and-build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests
+ run: npm test
+
+ - name: Build
+ run: npm run build
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..d5ed992
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,90 @@
+# Contributing to Two-Export Comparator
+
+Thanks for helping improve the project.
+
+The project intentionally stays focused:
+
+> **Take two files and visually show what is different.**
+
+Please read [PROJECT_SCOPE.md](docs/PROJECT_SCOPE.md) before proposing a large feature.
+
+## Development setup
+
+```bash
+npm install
+npm test
+npm run dev
+```
+
+Before opening a PR:
+
+```bash
+npm test
+npm run build
+```
+
+## Pull request expectations
+
+A good PR should:
+
+- solve one clear problem;
+- keep parser/comparison logic outside UI components;
+- preserve local-first behavior;
+- avoid unnecessary dependencies;
+- include or update tests for behavioral changes;
+- update documentation when behavior changes;
+- update `ROADMAP.md` when completing a planned development round.
+
+## Tests
+
+Tests should prove behavior, not merely execute code.
+
+For a bug fix, the preferred regression test should:
+
+1. fail on the broken behavior;
+2. pass with the fix;
+3. fail again if the protected behavior is removed.
+
+Realistic synthetic fixtures are preferred over tests that only mock everything away.
+
+## Privacy and test data
+
+Never commit real customer, accounting, banking, payment, or other confidential exports.
+
+Use synthetic fixture data only.
+
+## Translations
+
+Translations are a first-class contribution area.
+
+See [docs/TRANSLATIONS.md](docs/TRANSLATIONS.md).
+
+A translation PR should normally:
+
+1. copy an existing locale under `src/i18n/locales/`;
+2. keep the same message keys;
+3. translate message values naturally;
+4. register the locale in `src/i18n/locales/index.js`;
+5. test the full primary workflow;
+6. optionally add a `README.xx.md` translation.
+
+Do not translate imported file values or source column names. They belong to the user data.
+
+## Good contribution areas
+
+- CSV/TSV edge cases;
+- deterministic matching;
+- normalization;
+- duplicate/ambiguity handling;
+- security hardening;
+- accessibility;
+- performance;
+- translations;
+- documentation;
+- regression fixtures.
+
+## Large features
+
+Please discuss large scope changes before implementing them.
+
+A feature that is technically impressive but makes the default workflow harder to understand is unlikely to belong in the core.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e403781
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 QvarcY
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.lv.md b/README.lv.md
new file mode 100644
index 0000000..c1e4c1a
--- /dev/null
+++ b/README.lv.md
@@ -0,0 +1,227 @@
+# Two-Export Comparator
+
+[English](README.md) · [Latviešu](README.lv.md)
+
+
+
+> **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:** pirms publiskā laidiena / aktīvā izstrādē
+
+## Kāpēc šis projekts pastāv
+
+Divu eksportu salīdzināšanai nevajadzētu prasīt Excel formulu labirintu, failu augšupielādi mākonī vai datu inženiera zināšanas.
+
+Two-Export Comparator tiek veidots ap vienu ļoti vienkāršu plūsmu:
+
+```text
+Ieliec Failu A + Failu B
+ ↓
+Automātiska lokāla analīze
+ ↓
+Vizuāls salīdzinājums blakus
+ ↓
+Sarkans = atšķiras
+Neitrāls / zils = sakrīt
+ ↓
+Eksportē rezultātu
+```
+
+Parastam lietotājam nav jāzina salīdzināšanas algoritmi, normalizācijas noteikumi vai kolonnu sasaistes terminoloģija. Rīks cenšas šo darbu izdarīt automātiski. Manuālā sasaistīšana paliek tikai zem **Eksperta iestatījumiem**.
+
+## Ko rīks meklē
+
+Salīdzināšanas dzinējs ierakstus var klasificēt šādi:
+
+| Statuss | Nozīme |
+| --- | --- |
+| `MATCHED` | Atrasts viens drošs ierakstu pāris, un salīdzinātās vērtības sakrīt. |
+| `MISMATCH` | Tas pats ieraksts atrasts abos failos, bet viena vai vairākas vērtības atšķiras. |
+| `ONLY_A` | Ieraksts atrodas tikai Failā A. |
+| `ONLY_B` | Ieraksts atrodas tikai Failā B. |
+| `DUPLICATE` | Viena normalizēta atslēga sastopama vairākas reizes. |
+| `AMBIGUOUS` | Vienu drošu pāri nav iespējams noteikt. Dzinējs nemin. |
+
+## Pašreizējās iespējas
+
+- CSV un TSV faili
+- komata, semikola un tabulācijas atdalītāju noteikšana
+- CSV lauki pēdiņās
+- vairāku rindu vērtības pēdiņās
+- UTF-8 un UTF-8 BOM
+- teksta, skaitļu un datumu pamata tipu noteikšana
+- automātiski kolonnu pāru ieteikumi
+- determinēta lokāla salīdzināšana
+- skaitliska pielaide
+- dublikātu un neskaidru sasaistes gadījumu noteikšana
+- avota rindu izsekojamība
+- vizuāls salīdzinājums blakus
+- CSV rezultāta eksports
+- angļu un latviešu interfeiss
+- valodu reģistrs, kuru viegli papildināt contributoriem
+
+## Privātums pēc arhitektūras
+
+Biznesa eksportos var būt sensitīva informācija. Tāpēc aplikācijas parastajai salīdzināšanas plūsmai nav nepieciešams sūtīt failus uz serveri.
+
+- faili tiek nolasīti pārlūkā;
+- aplikācija failu saturu neaugšupielādē;
+- nav nepieciešams lietotāja konts;
+- importētie biznesa dati netiek glabāti aplikācijas datubāzē;
+- salīdzināšanai nav nepieciešama analītika vai telemetrija;
+- importētās vērtības tiek attēlotas ar drošām DOM metodēm;
+- pārlādējot vai aizverot lapu, atmiņā ielādētie dati pazūd.
+
+Tas apraksta arhitektūru, nevis ir vispārīgs juridisks apgalvojums, piemēram, “100% atbilst GDPR”.
+
+## Ātra palaišana
+
+Nepieciešams:
+
+- aktuāls Node.js
+- npm
+
+```bash
+git clone https://github.com/QvarcY/two-export-comparator.git
+cd two-export-comparator
+npm install
+npm test
+npm run dev
+```
+
+Produkcijas build:
+
+```bash
+npm run build
+npm run preview
+```
+
+## Kā darbojas automātiskā sasaistīšana
+
+Aplikācija analizē kolonnu nosaukumus, noteiktos datu tipus, vērtību pārklāšanos un unikalitāti, lai piedāvātu, kuras kolonnas abos failos nozīmē vienu un to pašu.
+
+Piemēram:
+
+```text
+Reference ↔ Payment Ref
+Amount ↔ Total
+Date ↔ Paid Date
+```
+
+Spēcīgākais identifikatora kandidāts tiek izmantots ierakstu sasaistīšanai. Papildu saderīgie pāri tiek izmantoti vērtību salīdzināšanai.
+
+Automātiskie ieteikumi apzināti ir konservatīvi. Ja drošu sasaisti nevar noteikt, lietotājs var atvērt **Eksperta iestatījumus**, nevis dzinējs klusām izdomā rezultātu.
+
+## Atbalstītie ievades dati
+
+Šobrīd:
+
+- `.csv`
+- `.tsv`
+- UTF-8 / UTF-8 BOM
+- līdz 50 MiB vienam failam pašreizējā MVP
+
+Tādus formātus kā XLSX ir jēga pievienot tikai tad, kad CSV/TSV kodols ir stabils.
+
+## Projekta robežas
+
+Šis projekts apzināti paliek mazs.
+
+**Kodola solījums:**
+
+> Paņem divus failus un vizuāli parādi, kas tajos atšķiras.
+
+Tam nav jākļūst par ERP, CRM, grāmatvedības sistēmu, mākoņa darba vidi, BI platformu vai failu glabātuvi.
+
+Pirms pievienot jaunu funkciju, jāuzdod viens jautājums:
+
+> Vai tas palīdz lietotājam ātrāk saprast atšķirības starp diviem failiem?
+
+Ja nē, tad šī funkcija, visticamāk, nepieder projekta kodolam.
+
+Skati [PROJECT_SCOPE.md](docs/PROJECT_SCOPE.md).
+
+## Arhitektūra
+
+```text
+src/
+├── app/ stāvoklis + aplikācijas kontrolieris
+├── engine/ parsēšana, normalizācija, sasaistīšana un salīdzināšana
+├── i18n/ valodu reģistrs un tulkojumi
+├── models/ DTO / servisa kontrakti
+├── services/ pārlūka salīdzināšanas serviss
+├── ui/
+│ ├── components/
+│ ├── renderers/
+│ └── views/
+└── styles/
+```
+
+UI ir atkarīgs no `ComparisonService` robežas, nevis no parsera iekšējās uzbūves. Tas neļauj salīdzināšanas loģikai sajaukties ar UI komponentiem.
+
+## Izstrādes principi
+
+- viena skaidra problēma, atrisināta labi;
+- local-first, kur tas ir praktiski;
+- nekādas klusās minēšanas neskaidru datu gadījumā;
+- minimālas dependencies;
+- droša importēto vērtību attēlošana;
+- pieejamas vadīklas;
+- determinēta loģika pirms AI;
+- regression testam jānokrīt, ja aizsargātā uzvedība tiek sabojāta.
+
+## Valodas
+
+Oficiālās UI valodas:
+
+- English (`en`)
+- Latviešu (`lv`)
+
+Jaunas valodas pievienošana neprasa mainīt salīdzināšanas loģiku.
+
+Skati [Tulkošanas rokasgrāmatu](docs/TRANSLATIONS.md).
+
+README tulkojumus var pievienot kā `README.xx.md`.
+
+## Attīstības plāns
+
+Projekta kanoniskais attīstības žurnāls ir [ROADMAP.md](ROADMAP.md).
+
+Pašreizējais virziens:
+
+1. ✅ projekta arhitektūra un frontend pamats
+2. ✅ Visual Diff kā galvenais UX
+3. ✅ īsta lokāla CSV/TSV dzinēja MVP versija
+4. 🟡 sasaistīšanas un normalizācijas nostiprināšana
+5. ⬜ eksporta, privātuma un drošības audits
+6. ⬜ pieejamības, veiktspējas un regression kvalitātes vārti
+7. ⬜ statiska/offline izplatīšana
+8. ⬜ publisks laidiens
+
+Pēc katras nozīmīgas izstrādes kārtas tiek atjaunināts `ROADMAP.md`.
+
+## Contribution
+
+Īpaši noderīgi contribution virzieni:
+
+- parsera edge cases;
+- determinēta sasaistīšana;
+- testa fixtures;
+- pieejamība;
+- veiktspēja;
+- drošības nostiprināšana;
+- tulkojumi;
+- dokumentācija.
+
+Pirms PR izveides izlasi [CONTRIBUTING.md](CONTRIBUTING.md).
+
+## Drošība
+
+Lūdzu, nepublicē sensitīvus biznesa eksportus issues, pull requestos, screenshots vai testa fixtures.
+
+Skati [SECURITY.md](SECURITY.md).
+
+## Licence
+
+MIT — skati [LICENSE](LICENSE).
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..93798c2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,227 @@
+# Two-Export Comparator
+
+[English](README.md) · [Latviešu](README.lv.md)
+
+
+
+> **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:** pre-release / active development
+
+## Why this exists
+
+Comparing exports should not require a spreadsheet formula maze, a cloud upload, or a data-engineering background.
+
+Two-Export Comparator is built around one simple workflow:
+
+```text
+Drop File A + File B
+ ↓
+Automatic local analysis
+ ↓
+Side-by-side Visual Diff
+ ↓
+Red = different
+Neutral / blue = matching
+ ↓
+Export the result
+```
+
+The normal user should not need to understand matching algorithms, normalization rules, or column-mapping terminology. The app attempts to do that work automatically. Manual mapping remains available only as **Expert settings**.
+
+## What it looks for
+
+The comparison engine can classify records as:
+
+| Status | Meaning |
+| --- | --- |
+| `MATCHED` | A unique pair was found and the compared values match. |
+| `MISMATCH` | The same record was found in both files, but one or more values differ. |
+| `ONLY_A` | The record exists only in File A. |
+| `ONLY_B` | The record exists only in File B. |
+| `DUPLICATE` | A normalized key appears multiple times. |
+| `AMBIGUOUS` | A unique safe pairing cannot be determined. The engine does not guess. |
+
+## Current capabilities
+
+- CSV and TSV input
+- comma, semicolon and tab delimiter detection
+- quoted CSV fields
+- multiline quoted values
+- UTF-8 and UTF-8 BOM handling
+- basic type inference for text, numbers and dates
+- automatic column-pair suggestions
+- deterministic local comparison
+- numeric tolerance
+- duplicate and ambiguity detection
+- source-row traceability
+- side-by-side Visual Diff
+- CSV result export
+- English and Latvian UI
+- contributor-friendly locale registry
+
+## Privacy by architecture
+
+Business exports can contain sensitive information. The application is designed so the normal comparison flow does not require sending those files anywhere.
+
+- files are read in the browser;
+- file contents are not uploaded by the application;
+- there is no account requirement;
+- imported business data is not stored in an application database;
+- no analytics or telemetry is required for comparison;
+- imported values are rendered with safe DOM APIs;
+- refreshing or closing the page clears in-memory file data.
+
+This describes the architecture. It is **not** a blanket legal claim such as “100% GDPR compliant”.
+
+## Quick start
+
+Requirements:
+
+- a current Node.js version
+- npm
+
+```bash
+git clone https://github.com/QvarcY/two-export-comparator.git
+cd two-export-comparator
+npm install
+npm test
+npm run dev
+```
+
+Production build:
+
+```bash
+npm run build
+npm run preview
+```
+
+## How automatic matching works
+
+The app analyzes column names, inferred data types, value overlap and uniqueness to suggest which columns represent the same concept across both files.
+
+Examples:
+
+```text
+Reference ↔ Payment Ref
+Amount ↔ Total
+Date ↔ Paid Date
+```
+
+The first strong identifier candidate is used to pair records. Additional compatible pairs are used for comparison.
+
+Automatic suggestions are intentionally conservative. If the app cannot determine a safe mapping, the user can open **Expert settings** instead of the engine silently guessing.
+
+## Supported input
+
+Current target:
+
+- `.csv`
+- `.tsv`
+- UTF-8 / UTF-8 BOM
+- up to 50 MiB per file in the current MVP
+
+Planned formats such as XLSX should only be added after the core CSV/TSV workflow is stable.
+
+## Project scope
+
+This project intentionally stays small.
+
+**Core promise:**
+
+> Take two files and visually show what is different.
+
+The project should not become an ERP, CRM, accounting suite, cloud workspace, BI platform, or file-storage service.
+
+Before adding a feature, ask:
+
+> Does this help the user understand the differences between two files faster?
+
+If the answer is no, it probably does not belong in the core product.
+
+See [PROJECT_SCOPE.md](docs/PROJECT_SCOPE.md).
+
+## Architecture
+
+```text
+src/
+├── app/ state + application controller
+├── engine/ parsing, normalization, mapping and comparison logic
+├── i18n/ locale registry and translations
+├── models/ DTO / service contracts
+├── services/ browser comparison service
+├── ui/
+│ ├── components/
+│ ├── renderers/
+│ └── views/
+└── styles/
+```
+
+The UI depends on the `ComparisonService` boundary rather than parser internals. This keeps technical comparison logic out of UI components.
+
+## Development principles
+
+- one focused job, done well;
+- local-first where practical;
+- no silent guessing with ambiguous data;
+- minimal dependencies;
+- safe rendering of imported values;
+- accessible controls;
+- deterministic comparison before AI;
+- regression tests must fail when the protected behavior is broken.
+
+## Languages
+
+Official UI languages:
+
+- English (`en`)
+- Latviešu (`lv`)
+
+Adding another language does not require changing comparison logic.
+
+See [Translation guide](docs/TRANSLATIONS.md).
+
+README translations may be added as `README.xx.md`.
+
+## Roadmap
+
+The canonical development ledger is [ROADMAP.md](ROADMAP.md).
+
+Current direction:
+
+1. ✅ project architecture and frontend foundation
+2. ✅ Visual Diff as the primary UX
+3. ✅ real local CSV/TSV engine MVP
+4. 🟡 harden matching and normalization
+5. ⬜ export/privacy/security review
+6. ⬜ accessibility, performance and regression quality gate
+7. ⬜ static/offline distribution
+8. ⬜ public release
+
+Every meaningful development round updates `ROADMAP.md`.
+
+## Contributing
+
+Contributions are welcome, especially for:
+
+- parser edge cases;
+- deterministic matching;
+- test fixtures;
+- accessibility;
+- performance;
+- security hardening;
+- translations;
+- documentation.
+
+Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR.
+
+## Security
+
+Please do not publish sensitive business exports in issues, pull requests, screenshots, or fixtures.
+
+See [SECURITY.md](SECURITY.md).
+
+## License
+
+MIT — see [LICENSE](LICENSE).
diff --git a/README_FRONTEND.md b/README_FRONTEND.md
index e69de29..e0b2bac 100644
--- a/README_FRONTEND.md
+++ b/README_FRONTEND.md
@@ -0,0 +1,44 @@
+# Frontend architecture notes
+
+The public project description lives in [README.md](README.md) and [README.lv.md](README.lv.md).
+
+The application is intentionally split so UI code does not own parser or comparison logic:
+
+~~~text
+UI / views / components
+ ↓
+AppController + AppStore
+ ↓
+ComparisonService
+ ↓
+BrowserComparisonService
+ ↓
+engine/
+ parsing · normalization · mapping suggestions · comparison
+~~~
+
+`MockComparisonService` remains available for deterministic UI development, but the application now uses `BrowserComparisonService` by default.
+
+## Commands
+
+~~~bash
+npm install
+npm test
+npm run dev
+npm run build
+npm run preview
+~~~
+
+## Important
+
+Do not implement parser, normalization or comparison algorithms inside UI components.
+
+Keep the default workflow simple:
+
+~~~text
+2 files → automatic local comparison → Visual Diff
+~~~
+
+Manual mapping belongs under Expert settings.
+
+Language files live under `src/i18n/locales/`. See [docs/TRANSLATIONS.md](docs/TRANSLATIONS.md).
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..bb9e01b
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,153 @@
+# Two-Export Comparator — Roadmap / Attīstības plāns
+
+This file is the canonical development ledger for the project.
+Šis fails ir projekta galvenais attīstības žurnāls.
+
+**Rule / Noteikums:** every meaningful development round updates this file with completed work, verification and the next target. / Pēc katras nozīmīgas izstrādes kārtas šis fails tiek atjaunināts ar paveikto, pārbaudi un nākamo mērķi.
+
+Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked
+
+## Phase 0 — Concept and architecture / Ideja un arhitektūra ✅
+
+- ✅ Narrow product definition: compare two exports and explain differences.
+- ✅ Browser-first, local-processing privacy model.
+- ✅ Stable service boundary between UI and future engine.
+- ✅ AppStore + AppController separation.
+- ✅ MockComparisonService for deterministic UI development.
+- ✅ Baseline Git repository and frontend branch strategy.
+
+## Phase 1 — Frontend prototype and project foundation / Frontend prototips un projekta pamats ✅
+
+- ✅ Modern dark-first UI prototype.
+- ✅ File A / File B panels.
+- ✅ Mapping workspace retained as Expert Mode.
+- ✅ Automatic compare path after two files are selected.
+- ✅ Side-by-side Visual Diff becomes the primary result experience.
+- ✅ Compare progress screen.
+- ✅ Results cards, table and record drawer.
+- ✅ Safe DOM renderer using textContent for imported values.
+- ✅ Pagination instead of rendering the whole result set.
+- ✅ English + Latvian i18n registry foundation.
+- ✅ Language selector driven by locale registry.
+- ✅ Live EN/LV switching without page reload.
+- ✅ Language switching preserves in-memory files and mapping state.
+- ✅ EN/LV coverage through the main workflow.
+- ✅ English and Latvian README.
+- ✅ Translation contribution guide.
+- ✅ Canonical roadmap.
+- ✅ One real main landmark instead of duplicate main IDs.
+- ✅ Mock file replacement keeps stable A/B slot IDs.
+- ✅ Comparison failure returns to a usable mapping state.
+- ✅ Package metadata aligned with the Vite 7 lockfile baseline.
+- ✅ Repository line-ending policy added.
+- ✅ Clean production build verified locally with Vite 7.3.6.
+- ⬜ Source-generated parser warnings still need code-based localization.
+
+**Exit criteria:** complete bilingual UI, clean build, no duplicate IDs, package metadata consistent, no known frontend blocker.
+
+## Phase 2 — Real file inspection and parsing / Īsta failu pārbaude un parsēšana ✅
+
+- ✅ Replace mock file inspection in BrowserComparisonService.
+- ✅ CSV parser with quoted fields and multiline quoted values.
+- ✅ TSV support.
+- ✅ Comma / semicolon / tab delimiter detection.
+- ✅ UTF-8 and UTF-8 BOM handling.
+- ✅ Detect replacement characters and fail instead of silently showing corrupted UTF-8.
+- ✅ Header discovery and safe preview.
+- ✅ Row and column metadata with basic type inference.
+- ✅ 50 MiB file-size guard and malformed quoted-field error.
+- ✅ Parser fixtures for BOM, delimiter detection, quoted commas and multiline values.
+
+**Exit criteria:** real CSV/TSV files can be inspected locally without upload and parser fixtures prove edge-case behavior.
+
+## Phase 3 — Mapping and normalization / Kolonnu savienošana un normalizācija 🟡
+
+- ✅ Exact text keys.
+- ✅ Composite keys are supported by the mapping/indexing model.
+- ✅ Trim / case / repeated-whitespace normalization.
+- ✅ Explicit numeric parsing for dot/comma decimals.
+- ⬜ Conservative date handling.
+- ⬜ User-selected ambiguous date format.
+- ✅ Numeric tolerance: absolute and percentage.
+- ✅ Deterministic column mapping suggestions without AI.
+- 🟡 Basic mapping validation exists; error UX still needs hardening.
+
+**Exit criteria:** mappings are explicit, auditable and never silently guess ambiguous formats.
+
+## Phase 4 — Comparison engine / Salīdzināšanas dzinējs 🟡
+
+- ✅ Hash-map indexing; avoids O(n²) pair scans.
+- ✅ MATCHED.
+- ✅ ONLY_A.
+- ✅ ONLY_B.
+- ✅ MISMATCH.
+- ✅ DUPLICATE.
+- ✅ AMBIGUOUS.
+- ✅ Source row traceability.
+- ✅ Deterministic pairing rules for unique keys.
+- ✅ Duplicate/ambiguous keys are surfaced instead of silently accepted as matches.
+- ⬜ Large-file performance tests.
+
+**Exit criteria:** removing or breaking any comparison rule causes the relevant regression fixture to fail.
+
+## Phase 5 — Export, privacy and security hardening / Eksports, privātums un drošība 🟡
+
+- ✅ Result CSV export.
+- ⬜ Filtered exports.
+- ✅ CSV formula-injection protection.
+- ⬜ CSP suitable for static deployment, including connect-src 'none' where deployment permits it.
+- ✅ No analytics, telemetry or external data APIs in the comparison flow.
+- ✅ No business-file content stored in localStorage, IndexedDB or cookies.
+- ⬜ Security review of imported values and generated downloads.
+- ⬜ Privacy explanation verified against actual behavior.
+
+## Phase 6 — Quality gate / Kvalitātes vārti 🟡
+
+- ✅ Node test coverage for parser and browser comparison service.
+- ✅ Synthetic behavior fixtures for real comparison outcomes.
+- ⬜ Integration tests.
+- ⬜ Regression tests that fail on the pre-fix behavior.
+- ⬜ Accessibility audit: keyboard, labels, focus, screen reader semantics.
+- ⬜ Responsive review.
+- ⬜ EN/LV translation completeness check.
+- ⬜ Contributor translation validation.
+- ⬜ Performance baseline for large files.
+- ⬜ Dependency audit.
+
+## Phase 7 — Distribution / Izplatīšana ⬜
+
+- ⬜ GitHub Pages deployment.
+- ⬜ Offline-capable static build.
+- ⬜ Evaluate a self-contained single HTML release.
+- ⬜ Release checksums where useful.
+- ⬜ Public demo with sample data only.
+- ⬜ Verify that demo hosting never receives selected business files.
+
+## Phase 8 — Public open-source release / Publisks open-source release ⬜
+
+- ⬜ Final project name/branding review.
+- ⬜ MIT license and public repository metadata.
+- ⬜ CONTRIBUTING polish.
+- ⬜ Issue and PR templates.
+- ⬜ Good first issues.
+- ⬜ Help-wanted issues for parsers, formats and translations.
+- ⬜ First tagged release.
+- ⬜ Public launch documentation and screenshots.
+
+---
+
+## Development rounds / Izstrādes kārtas
+
+| Date | Round | Status | What changed / Kas mainīts |
+| --- | --- | --- | --- |
+| 2026-09-26 | R0 — Scaffold | ✅ | Vite project, service boundary, initial repository baseline. |
+| 2026-09-26 | R1 — Frontend prototype | ✅ | File panels, mapping UI, progress view, result dashboard and drawer added. |
+| 2026-09-26 | R2 — i18n + documentation foundation | ✅ | EN/LV locale registry, bilingual README, translation guide, roadmap, line-ending policy and package cleanup. |
+| 2026-09-26 | R3 — Live i18n + frontend cleanup | ✅ | Fixed language-switch reset/crash, wired EN/LV through the workflow, kept files in memory during locale changes, simplified mapping and fixed mock slot replacement/error recovery. |
+| 2026-09-26 | R4 — Visual Diff pivot | ✅ | Product flow changed to Upload → automatic comparison → side-by-side Visual Diff. Manual mapping moved behind Expert settings. |
+| 2026-09-26 | R5 — Real browser engine MVP | ✅ | Replaced mock default with real local CSV/TSV parsing, automatic mapping suggestions and deterministic comparison sufficient for the Visual Diff flow. Regression fixtures pass (4/4) and production build verified locally. |
+| 2026-09-26 | R6 — Repository polish | ✅ | Reworked EN/LV README around the Visual Diff product promise, documented strict project scope, strengthened contribution/security guidance, added GitHub issue/PR templates, README hero artwork and CI workflow. |
+
+## Next target / Nākamais mērķis
+
+**Next:** harden automatic matching/normalization without expanding the core workflow, then prepare the first public release candidate.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..0ce9485
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,35 @@
+# Security policy
+
+## Data-safety principle
+
+Two-Export Comparator is designed around local browser processing. File contents should not be sent to a backend as part of the normal comparison workflow.
+
+## Reporting a vulnerability
+
+Please avoid publishing security-sensitive details in a public issue before a fix is available.
+
+When reporting a problem, include:
+
+- affected version/commit;
+- browser and operating system;
+- minimal reproduction steps;
+- whether the issue can expose imported file contents;
+- whether export generation is involved.
+
+Do **not** attach real confidential business exports. Use a minimal synthetic fixture instead.
+
+## Sensitive areas
+
+Security review should pay particular attention to:
+
+- imported cell values rendered in the DOM;
+- CSV formula injection in generated exports;
+- malformed CSV/TSV input;
+- resource exhaustion from large files;
+- accidental network requests;
+- persistence of imported data;
+- future third-party dependencies.
+
+## Supported versions
+
+The project is currently pre-release. Security fixes target the active development branch until the first public release is tagged.
diff --git a/docs/PROJECT_SCOPE.md b/docs/PROJECT_SCOPE.md
new file mode 100644
index 0000000..df03679
--- /dev/null
+++ b/docs/PROJECT_SCOPE.md
@@ -0,0 +1,68 @@
+# Project scope
+
+Two-Export Comparator should remain intentionally small.
+
+## Core promise
+
+> Take two files and visually show what is different.
+
+The default workflow should remain:
+
+```text
+2 files → automatic local comparison → visual differences → optional export
+```
+
+## In scope
+
+- CSV / TSV parsing
+- deterministic matching
+- visual difference highlighting
+- duplicate / ambiguity handling
+- safe local processing
+- result export
+- accessibility
+- performance
+- translation support
+- narrowly-scoped Expert settings
+
+## Not core scope
+
+The project should not grow into:
+
+- ERP
+- CRM
+- accounting software
+- cloud file storage
+- collaboration workspace
+- project management
+- BI/dashboard suite
+- user-account platform
+- scheduled data synchronization
+- generic spreadsheet editor
+- AI chat interface
+
+A future feature may still be useful, but usefulness alone is not enough to put it in the core.
+
+## Feature test
+
+Before adding a feature, ask:
+
+1. Does it directly help compare two files?
+2. Does it reduce the time to understand a difference?
+3. Can it stay optional if most users do not need it?
+4. Does it preserve local-first/privacy-first behavior?
+5. Can we test it deterministically?
+
+If most answers are no, the feature should stay out.
+
+## UX rule
+
+The normal user should not need to understand:
+
+- keys;
+- normalization;
+- tolerance terminology;
+- schema mapping;
+- parser internals.
+
+Those concepts may exist internally or under Expert settings, but the primary flow must remain understandable without documentation.
diff --git a/docs/TRANSLATIONS.md b/docs/TRANSLATIONS.md
new file mode 100644
index 0000000..761f1df
--- /dev/null
+++ b/docs/TRANSLATIONS.md
@@ -0,0 +1,51 @@
+# Translation guide
+
+Two-Export Comparator uses a small locale registry instead of embedding language logic into the comparison engine.
+
+## Current official locales
+
+- en — English
+- lv — Latviešu
+
+## Add a UI language
+
+1. Copy src/i18n/locales/en.js.
+2. Rename it to the ISO-style language code you want to add, for example de.js or pl.js.
+3. Translate every message value. Do not rename message keys.
+4. Import the file in src/i18n/locales/index.js.
+5. Add one registry entry containing:
+ - code
+ - nativeName
+ - shortLabel
+ - messages
+6. Run npm run dev.
+7. Select the language from the header.
+8. Check file selection, mapping, progress, results, errors and accessibility labels.
+9. Run npm run build.
+
+The language selector is generated from the registry, so a correctly registered language appears automatically.
+
+## Important rules
+
+- Do not translate source file column names or imported business data.
+- Keep placeholders such as {slot}, {shown}, {total}, {count}, {page} unchanged.
+- Do not add HTML to translation values.
+- Keep translations concise enough for buttons and narrow screens.
+- Translate meaning naturally; word-for-word translation is not required.
+- English is the fallback locale.
+
+## README translations
+
+A contributor may also add a README translation:
+
+~~~text
+README.de.md
+README.pl.md
+README.fr.md
+~~~
+
+Then add the new language link to the language line at the top of README.md and other maintained README translations.
+
+## Privacy note
+
+Language preference is not stored together with business-file contents. The initial implementation uses a URL language parameter and browser-language detection instead of persisting imported business data.
diff --git a/docs/assets/hero.svg b/docs/assets/hero.svg
new file mode 100644
index 0000000..d0086c2
--- /dev/null
+++ b/docs/assets/hero.svg
@@ -0,0 +1,41 @@
+
+ Two-Export Comparator
+ Privacy-first local CSV and TSV visual diff tool
+
+
+ Two-Export Comparator
+ Two files in. Differences out. Local only.
+
+
+
+ FILE A
+ FILE B
+
+
+
+ INV-1001
+ INV-1001
+ 125.00
+ 125.00
+
+
+
+ INV-1002
+ INV-1002
+
+
+ 125.00
+ 152.00
+
+
+ DIFFERENT
+
+
+
+ matching rows stay visually quiet
+ matching rows stay visually quiet
+
+
+ No upload · No account
+ CSV · TSV · Visual Diff
+
diff --git a/index.html b/index.html
index c715ea4..36c86ae 100644
--- a/index.html
+++ b/index.html
@@ -1,17 +1,21 @@
-
-
-
-
-
-
- Two Export Comparator
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Two-Export Comparator
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package.json b/package.json
index 90beda8..93d8668 100644
--- a/package.json
+++ b/package.json
@@ -3,10 +3,12 @@
"version": "0.0.1",
"private": true,
"type": "module",
+ "description": "Privacy-first browser utility for comparing two CSV/TSV exports locally.",
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "node --test"
},
"devDependencies": {
"vite": "^7.0.0"
diff --git a/src/app/app-controller.js b/src/app/app-controller.js
index e69de29..86cb463 100644
--- a/src/app/app-controller.js
+++ b/src/app/app-controller.js
@@ -0,0 +1,255 @@
+import { AppEvents } from './app-events.js';
+
+export class AppController {
+ constructor(store, service) {
+ this.store = store;
+ this.service = service;
+ this._wireEvents();
+ }
+
+ _wireEvents() {
+ window.addEventListener(AppEvents.FILE_A_SELECTED, (event) => {
+ this.handleFileSelected('A', event.detail.file);
+ });
+
+ window.addEventListener(AppEvents.FILE_B_SELECTED, (event) => {
+ this.handleFileSelected('B', event.detail.file);
+ });
+
+ window.addEventListener(AppEvents.FILE_A_REMOVED, () => this.handleFileRemoved('A'));
+ window.addEventListener(AppEvents.FILE_B_REMOVED, () => this.handleFileRemoved('B'));
+ window.addEventListener(AppEvents.COMPARE_REQUESTED, (event) => {
+ this.handleCompare(event.detail.mapping);
+ });
+ window.addEventListener(AppEvents.EXPORT_REQUESTED, (event) => {
+ this.handleExport(event.detail);
+ });
+ window.addEventListener(AppEvents.RESET_REQUESTED, () => this.handleReset());
+ window.addEventListener(AppEvents.EXPERT_MAPPING_REQUESTED, () => this.handleExpertMapping());
+ }
+
+ async handleFileSelected(slot, file) {
+ const loadingKey = slot === 'A' ? 'fileLoadingA' : 'fileLoadingB';
+ this.store.patch({ [loadingKey]: true });
+
+ try {
+ const inspection = await this.service.inspectFile(file, { slot });
+ const filePatch = slot === 'A'
+ ? { fileA: inspection }
+ : { fileB: inspection };
+
+ this.store.patch({
+ ...filePatch,
+ [loadingKey]: false,
+ });
+
+ const ready = this._recomputeState();
+ if (ready) {
+ await this.handleAutoCompare();
+ }
+ } catch (error) {
+ this.store.patch({ [loadingKey]: false });
+
+ this.store.pushToast({
+ severity: 'error',
+ titleKey: 'toast.readErrorTitle',
+ messageKey: 'toast.readErrorMessage',
+ });
+
+ console.error('[AppController] inspectFile failed:', error);
+ }
+ }
+
+ handleFileRemoved(slot) {
+ this.store.patch(
+ slot === 'A'
+ ? { fileA: null, fileLoadingA: false }
+ : { fileB: null, fileLoadingB: false }
+ );
+
+ this._recomputeState();
+ }
+
+ async handleCompare(mapping) {
+ const { fileA, fileB } = this.store;
+ if (!fileA || !fileB) return;
+
+ this.store.patch({
+ mapping,
+ progress: null,
+ result: null,
+ });
+
+ this.store.setState('COMPARING');
+
+ try {
+ const result = await this.service.compare(
+ {
+ fileAId: fileA.id,
+ fileBId: fileB.id,
+ mapping,
+ },
+ {
+ onProgress: (progress) => {
+ this.store.patch({ progress });
+ window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_PROGRESS, {
+ detail: progress,
+ }));
+ },
+ }
+ );
+
+ this.store.patch({
+ result,
+ progress: null,
+ });
+
+ this.store.setState('RESULTS');
+
+ window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_SUCCEEDED, {
+ detail: result,
+ }));
+ } catch (error) {
+ this.store.patch({ progress: null });
+ this.store.setState(this.store.mappingValid ? 'READY_TO_COMPARE' : 'MAPPING');
+
+ this.store.pushToast({
+ severity: 'error',
+ titleKey: 'toast.compareFailedTitle',
+ messageKey: 'toast.compareFailedMessage',
+ });
+
+ window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_FAILED, {
+ detail: error,
+ }));
+
+ console.error('[AppController] compare failed:', error);
+ }
+ }
+
+ async handleExport(options = {}) {
+ if (!this.store.result) return;
+
+ try {
+ const blob = await this.service.createExport(this.store.result, options);
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+
+ anchor.href = url;
+ anchor.download = 'comparison-' + (this.store.result.runId ?? 'export') + '.csv';
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ URL.revokeObjectURL(url);
+
+ this.store.pushToast({
+ severity: 'info',
+ titleKey: 'toast.exportReadyTitle',
+ messageKey: 'toast.exportReadyMessage',
+ });
+ } catch (error) {
+ this.store.pushToast({
+ severity: 'error',
+ titleKey: 'toast.exportFailedTitle',
+ });
+
+ console.error('[AppController] export failed:', error);
+ }
+ }
+
+ async handleAutoCompare() {
+ const { fileA, fileB } = this.store;
+ if (!fileA || !fileB) return;
+
+ try {
+ const suggestions = await this.service.suggestMappings(fileA, fileB);
+
+ if (!suggestions?.length) {
+ this.store.patch({ mapping: null, mappingValid: false });
+ this.store.setState('MAPPING');
+ return;
+ }
+
+ const [keySuggestion, ...comparisonSuggestions] = suggestions;
+ const mapping = {
+ keys: [{
+ columnA: keySuggestion.columnA,
+ columnB: keySuggestion.columnB,
+ type: 'text',
+ }],
+ comparisons: comparisonSuggestions.map((suggestion) => ({
+ columnA: suggestion.columnA,
+ columnB: suggestion.columnB,
+ type: this._guessMappingType(fileA, fileB, suggestion.columnA, suggestion.columnB),
+ tolerance: { mode: 'absolute', value: 0.01 },
+ })),
+ displayOnly: { fileA: [], fileB: [] },
+ normalization: {
+ trimText: true,
+ caseInsensitive: true,
+ collapseWhitespace: true,
+ numberLocale: 'auto',
+ dateFormatA: null,
+ dateFormatB: null,
+ },
+ };
+
+ this.store.patch({ mapping, mappingValid: true });
+ await this.handleCompare(mapping);
+ } catch (error) {
+ this.store.patch({ mapping: null, mappingValid: false });
+ this.store.setState('MAPPING');
+ console.error('[AppController] automatic comparison setup failed:', error);
+ }
+ }
+
+ _guessMappingType(fileA, fileB, columnA, columnB) {
+ const typeA = fileA.columns.find((column) => column.id === columnA)?.inferredType;
+ const typeB = fileB.columns.find((column) => column.id === columnB)?.inferredType;
+
+ if (typeA === 'number' || typeB === 'number') return 'number';
+ if (typeA === 'date' || typeB === 'date') return 'date';
+ return 'text';
+ }
+
+ handleExpertMapping() {
+ if (!this.store.fileA || !this.store.fileB) return;
+ this.store.setState(this.store.mappingValid ? 'READY_TO_COMPARE' : 'MAPPING');
+ }
+
+ handleReset() {
+ this.service.reset();
+
+ this.store.patch({
+ fileA: null,
+ fileB: null,
+ fileLoadingA: false,
+ fileLoadingB: false,
+ mapping: null,
+ mappingValid: false,
+ progress: null,
+ result: null,
+ resultFilter: 'ALL',
+ openRecordId: null,
+ });
+
+ this.store.setState('EMPTY');
+ }
+
+ _recomputeState() {
+ const { fileA, fileB } = this.store;
+
+ if (!fileA && !fileB) {
+ this.store.setState('EMPTY');
+ return false;
+ }
+
+ if (fileA && fileB) {
+ this.store.setState('FILES_READY');
+ return true;
+ }
+
+ this.store.setState('FILES_PARTIAL');
+ return false;
+ }
+}
diff --git a/src/app/app-events.js b/src/app/app-events.js
index e69de29..f2be6c5 100644
--- a/src/app/app-events.js
+++ b/src/app/app-events.js
@@ -0,0 +1,35 @@
+/**
+ * @file Application event names. String constants — no magic strings in code.
+ */
+
+export const AppEvents = Object.freeze({
+ // File lifecycle
+ FILE_A_SELECTED: 'FILE_A_SELECTED',
+ FILE_B_SELECTED: 'FILE_B_SELECTED',
+ FILE_A_REMOVED: 'FILE_A_REMOVED',
+ FILE_B_REMOVED: 'FILE_B_REMOVED',
+
+ // Mapping
+ MAPPING_UPDATED: 'MAPPING_UPDATED',
+ NORMALIZATION_UPDATED: 'NORMALIZATION_UPDATED',
+ EXPERT_MAPPING_REQUESTED: 'EXPERT_MAPPING_REQUESTED',
+
+ // Comparison
+ COMPARE_REQUESTED: 'COMPARE_REQUESTED',
+ COMPARE_PROGRESS: 'COMPARE_PROGRESS',
+ COMPARE_SUCCEEDED: 'COMPARE_SUCCEEDED',
+ COMPARE_FAILED: 'COMPARE_FAILED',
+
+ // Results
+ RESULT_FILTER_CHANGED: 'RESULT_FILTER_CHANGED',
+ RESULT_RECORD_OPENED: 'RESULT_RECORD_OPENED',
+ RESULT_RECORD_CLOSED: 'RESULT_RECORD_CLOSED',
+
+ // Export / reset
+ EXPORT_REQUESTED: 'EXPORT_REQUESTED',
+ RESET_REQUESTED: 'RESET_REQUESTED',
+
+ // UI
+ TOAST_SHOWN: 'TOAST_SHOWN',
+ TOAST_DISMISSED: 'TOAST_DISMISSED',
+});
\ No newline at end of file
diff --git a/src/app/app-store.js b/src/app/app-store.js
index e69de29..de5babe 100644
--- a/src/app/app-store.js
+++ b/src/app/app-store.js
@@ -0,0 +1,51 @@
+import { AppEvents } from './app-events.js';
+
+export class AppStore extends EventTarget {
+ constructor() {
+ super();
+
+ this.state = 'EMPTY';
+ this.fileA = null;
+ this.fileB = null;
+ this.fileLoadingA = false;
+ this.fileLoadingB = false;
+ this.mapping = null;
+ this.mappingValid = false;
+ this.progress = null;
+ this.result = null;
+ this.resultFilter = 'ALL';
+ this.openRecordId = null;
+ this.toasts = [];
+ }
+
+ setState(next) {
+ if (this.state === next) return;
+
+ const previous = this.state;
+ this.state = next;
+ this.dispatchEvent(new CustomEvent('state', {
+ detail: { prev: previous, next },
+ }));
+ }
+
+ patch(patch) {
+ Object.assign(this, patch);
+ this.dispatchEvent(new CustomEvent('change', { detail: patch }));
+ }
+
+ pushToast(toast) {
+ const id = 'toast-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7);
+ this.toasts = [...this.toasts, { id, ...toast }];
+ this.dispatchEvent(new CustomEvent(AppEvents.TOAST_SHOWN, {
+ detail: { id, ...toast },
+ }));
+ return id;
+ }
+
+ dismissToast(id) {
+ this.toasts = this.toasts.filter((toast) => toast.id !== id);
+ this.dispatchEvent(new CustomEvent(AppEvents.TOAST_DISMISSED, {
+ detail: { id },
+ }));
+ }
+}
diff --git a/src/engine/delimited-parser.js b/src/engine/delimited-parser.js
new file mode 100644
index 0000000..788f5fd
--- /dev/null
+++ b/src/engine/delimited-parser.js
@@ -0,0 +1,208 @@
+const CANDIDATE_DELIMITERS = [',', ';', '\t'];
+
+export function detectDelimiter(text) {
+ const sample = String(text ?? '').slice(0, 65536);
+ let best = { delimiter: ',', score: -Infinity };
+
+ for (const delimiter of CANDIDATE_DELIMITERS) {
+ const rows = parseDelimitedText(sample, delimiter, { tolerateUnclosedQuote: true })
+ .filter((row) => row.some((cell) => String(cell).trim() !== ''))
+ .slice(0, 20);
+
+ if (rows.length < 2) continue;
+
+ const widths = rows.map((row) => row.length);
+ const modeWidth = mode(widths);
+ const consistent = widths.filter((width) => width === modeWidth).length;
+ const score = modeWidth > 1
+ ? consistent * 10 + modeWidth - Math.max(...widths) + Math.min(...widths)
+ : -100;
+
+ if (score > best.score) best = { delimiter, score };
+ }
+
+ return best.delimiter;
+}
+
+export function parseDelimitedText(text, delimiter, options = {}) {
+ const input = String(text ?? '').replace(/^\uFEFF/, '');
+ const rows = [];
+ let row = [];
+ let field = '';
+ let quoted = false;
+
+ for (let i = 0; i < input.length; i += 1) {
+ const char = input[i];
+
+ if (quoted) {
+ if (char === '"') {
+ if (input[i + 1] === '"') {
+ field += '"';
+ i += 1;
+ } else {
+ quoted = false;
+ }
+ } else {
+ field += char;
+ }
+ continue;
+ }
+
+ if (char === '"' && field.length === 0) {
+ quoted = true;
+ continue;
+ }
+
+ if (char === delimiter) {
+ row.push(field);
+ field = '';
+ continue;
+ }
+
+ if (char === '\n' || char === '\r') {
+ if (char === '\r' && input[i + 1] === '\n') i += 1;
+ row.push(field);
+ field = '';
+ rows.push(row);
+ row = [];
+ continue;
+ }
+
+ field += char;
+ }
+
+ if (quoted && !options.tolerateUnclosedQuote) {
+ throw new Error('CSV_PARSE_UNCLOSED_QUOTE');
+ }
+
+ if (field.length > 0 || row.length > 0) {
+ row.push(field);
+ rows.push(row);
+ }
+
+ while (
+ rows.length &&
+ rows[rows.length - 1].every((cell) => String(cell).trim() === '')
+ ) {
+ rows.pop();
+ }
+
+ return rows;
+}
+
+export function tableFromText(text) {
+ const clean = String(text ?? '').replace(/^\uFEFF/, '');
+
+ if (clean.includes('\uFFFD')) {
+ throw new Error('UNSUPPORTED_ENCODING');
+ }
+
+ const delimiter = detectDelimiter(clean);
+ const matrix = parseDelimitedText(clean, delimiter);
+
+ if (matrix.length === 0) {
+ throw new Error('EMPTY_FILE');
+ }
+
+ const maxWidth = Math.max(...matrix.map((row) => row.length));
+ const rawHeader = [...matrix[0]];
+
+ while (rawHeader.length < maxWidth) rawHeader.push('');
+
+ const columns = dedupeHeaders(rawHeader);
+ const rows = matrix
+ .slice(1)
+ .filter((row) => row.some((cell) => String(cell).trim() !== ''))
+ .map((row) => {
+ const record = {};
+ for (let i = 0; i < columns.length; i += 1) {
+ record[columns[i].id] = String(row[i] ?? '');
+ }
+ return record;
+ });
+
+ const typedColumns = columns.map((column) => ({
+ ...column,
+ inferredType: inferType(rows.map((row) => row[column.id])),
+ }));
+
+ return {
+ delimiter,
+ columns: typedColumns,
+ rows,
+ };
+}
+
+function dedupeHeaders(rawHeaders) {
+ const used = new Map();
+
+ return rawHeaders.map((raw, index) => {
+ const base = String(raw ?? '').trim() || 'Column ' + (index + 1);
+ const count = (used.get(base) ?? 0) + 1;
+ used.set(base, count);
+
+ const id = count === 1 ? base : base + ' (' + count + ')';
+ return {
+ id,
+ label: id,
+ inferredType: 'unknown',
+ };
+ });
+}
+
+function inferType(values) {
+ const sample = values
+ .map((value) => String(value ?? '').trim())
+ .filter(Boolean)
+ .slice(0, 200);
+
+ if (sample.length === 0) return 'unknown';
+
+ const numberHits = sample.filter((value) => parseFlexibleNumber(value) !== null).length;
+ if (numberHits / sample.length >= 0.9) return 'number';
+
+ const dateHits = sample.filter((value) => isDateLike(value)).length;
+ if (dateHits / sample.length >= 0.9) return 'date';
+
+ return 'text';
+}
+
+export function parseFlexibleNumber(value) {
+ const raw = String(value ?? '').trim().replace(/\s+/g, '');
+ if (!raw) return null;
+
+ const normalized = raw.includes(',') && !raw.includes('.')
+ ? raw.replace(',', '.')
+ : raw.replace(/,/g, '');
+
+ if (!/^[+-]?(?:\d+|\d*\.\d+)$/.test(normalized)) return null;
+
+ const number = Number(normalized);
+ return Number.isFinite(number) ? number : null;
+}
+
+function isDateLike(value) {
+ const raw = String(value ?? '').trim();
+ return (
+ /^\d{4}-\d{2}-\d{2}(?:[T\s].*)?$/.test(raw) ||
+ /^\d{1,2}[./-]\d{1,2}[./-]\d{4}$/.test(raw)
+ );
+}
+
+function mode(values) {
+ const counts = new Map();
+ let bestValue = values[0];
+ let bestCount = 0;
+
+ for (const value of values) {
+ const count = (counts.get(value) ?? 0) + 1;
+ counts.set(value, count);
+
+ if (count > bestCount) {
+ bestCount = count;
+ bestValue = value;
+ }
+ }
+
+ return bestValue;
+}
diff --git a/src/engine/mapping-suggester.js b/src/engine/mapping-suggester.js
new file mode 100644
index 0000000..48dee29
--- /dev/null
+++ b/src/engine/mapping-suggester.js
@@ -0,0 +1,155 @@
+export function suggestColumnMappings(fileA, fileB, dataA, dataB) {
+ const columnsA = fileA.columns ?? [];
+ const columnsB = fileB.columns ?? [];
+
+ if (!columnsA.length || !columnsB.length) return [];
+
+ const keyCandidate = bestKeyPair(columnsA, columnsB, dataA.rows, dataB.rows);
+ if (!keyCandidate) return [];
+
+ const suggestions = [{
+ columnA: keyCandidate.a.id,
+ columnB: keyCandidate.b.id,
+ confidence: round(keyCandidate.score),
+ }];
+
+ const usedB = new Set([keyCandidate.b.id]);
+
+ for (const columnA of columnsA) {
+ if (columnA.id === keyCandidate.a.id) continue;
+
+ let best = null;
+
+ for (const columnB of columnsB) {
+ if (usedB.has(columnB.id)) continue;
+ if (!compatibleTypes(columnA.inferredType, columnB.inferredType)) continue;
+
+ const score = comparePairScore(columnA, columnB, dataA.rows, dataB.rows);
+ if (!best || score > best.score) best = { a: columnA, b: columnB, score };
+ }
+
+ if (best && best.score >= 0.48) {
+ suggestions.push({
+ columnA: best.a.id,
+ columnB: best.b.id,
+ confidence: round(best.score),
+ });
+ usedB.add(best.b.id);
+ }
+ }
+
+ return suggestions;
+}
+
+function bestKeyPair(columnsA, columnsB, rowsA, rowsB) {
+ let best = null;
+
+ for (const a of columnsA) {
+ for (const b of columnsB) {
+ if (!compatibleTypes(a.inferredType, b.inferredType)) continue;
+
+ const valuesA = nonEmpty(rowsA.map((row) => row[a.id]));
+ const valuesB = nonEmpty(rowsB.map((row) => row[b.id]));
+ if (!valuesA.length || !valuesB.length) continue;
+
+ const overlap = overlapRatio(valuesA, valuesB);
+ const uniqueness = Math.min(uniqueRatio(valuesA), uniqueRatio(valuesB));
+ const semantic = semanticSimilarity(a.label, b.label);
+
+ const score = overlap * 0.62 + uniqueness * 0.25 + semantic * 0.13;
+
+ if (!best || score > best.score) best = { a, b, score };
+ }
+ }
+
+ return best && best.score >= 0.35 ? best : null;
+}
+
+function comparePairScore(a, b, rowsA, rowsB) {
+ const valuesA = nonEmpty(rowsA.map((row) => row[a.id]));
+ const valuesB = nonEmpty(rowsB.map((row) => row[b.id]));
+
+ const semantic = semanticSimilarity(a.label, b.label);
+ const overlap = overlapRatio(valuesA, valuesB);
+ const type = compatibleTypes(a.inferredType, b.inferredType) ? 1 : 0;
+
+ return semantic * 0.5 + overlap * 0.35 + type * 0.15;
+}
+
+function compatibleTypes(a, b) {
+ if (a === 'unknown' || b === 'unknown') return true;
+ return a === b;
+}
+
+function semanticSimilarity(a, b) {
+ const groupA = semanticGroup(a);
+ const groupB = semanticGroup(b);
+
+ if (groupA && groupA === groupB) return 1;
+
+ const tokensA = tokenSet(a);
+ const tokensB = tokenSet(b);
+ if (!tokensA.size || !tokensB.size) return 0;
+
+ let intersection = 0;
+ for (const token of tokensA) if (tokensB.has(token)) intersection += 1;
+
+ return intersection / Math.max(tokensA.size, tokensB.size);
+}
+
+function semanticGroup(label) {
+ const value = String(label ?? '').toLowerCase();
+
+ if (/\b(ref|reference|invoice|inv|order|payment.?ref|transaction.?id|id|number|no)\b/.test(value)) {
+ return 'identifier';
+ }
+ if (/\b(amount|total|sum|price|value|balance)\b/.test(value)) return 'amount';
+ if (/\b(date|time|paid.?date|created|updated)\b/.test(value)) return 'date';
+ if (/\b(description|desc|memo|note|details)\b/.test(value)) return 'description';
+ if (/\b(customer|client|buyer|name)\b/.test(value)) return 'party';
+
+ return null;
+}
+
+function tokenSet(value) {
+ return new Set(
+ String(value ?? '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, ' ')
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean)
+ );
+}
+
+function overlapRatio(valuesA, valuesB) {
+ const setA = new Set(valuesA.map(normalize));
+ const setB = new Set(valuesB.map(normalize));
+
+ if (!setA.size || !setB.size) return 0;
+
+ let intersection = 0;
+ for (const value of setA) if (setB.has(value)) intersection += 1;
+
+ return intersection / Math.min(setA.size, setB.size);
+}
+
+function uniqueRatio(values) {
+ if (!values.length) return 0;
+ return new Set(values.map(normalize)).size / values.length;
+}
+
+function nonEmpty(values) {
+ return values
+ .map((value) => String(value ?? '').trim())
+ .filter(Boolean)
+ .slice(0, 1000);
+}
+
+function normalize(value) {
+ return String(value ?? '').trim().toLocaleLowerCase().replace(/\s+/g, ' ');
+}
+
+function round(value) {
+ return Math.round(value * 100) / 100;
+}
diff --git a/src/engine/normalization.js b/src/engine/normalization.js
new file mode 100644
index 0000000..72f7810
--- /dev/null
+++ b/src/engine/normalization.js
@@ -0,0 +1,64 @@
+import { parseFlexibleNumber } from './delimited-parser.js';
+
+export function normalizeText(value, config = {}) {
+ let result = String(value ?? '');
+
+ if (config.trimText !== false) result = result.trim();
+ if (config.collapseWhitespace !== false) result = result.replace(/\s+/g, ' ');
+ if (config.caseInsensitive !== false) result = result.toLocaleLowerCase();
+
+ return result;
+}
+
+export function normalizeKey(row, mappings, config = {}) {
+ return mappings
+ .map((mapping) => {
+ const raw = row[mapping.columnA ?? mapping.columnB] ?? '';
+ return normalizeText(raw, config);
+ })
+ .join('\u001F');
+}
+
+export function normalizeKeyForSide(row, mappings, side, config = {}) {
+ return mappings
+ .map((mapping) => {
+ const column = side === 'A' ? mapping.columnA : mapping.columnB;
+ return normalizeText(row[column] ?? '', config);
+ })
+ .join('\u001F');
+}
+
+export function compareValues(valueA, valueB, mapping, config = {}) {
+ if (mapping.type === 'number') {
+ const a = parseFlexibleNumber(valueA);
+ const b = parseFlexibleNumber(valueB);
+
+ if (a === null || b === null) {
+ return {
+ equal: normalizeText(valueA, config) === normalizeText(valueB, config),
+ delta: null,
+ };
+ }
+
+ const delta = Math.abs(a - b);
+ const tolerance = mapping.tolerance?.value ?? 0;
+
+ if (mapping.tolerance?.mode === 'percentage') {
+ const base = Math.max(Math.abs(a), Math.abs(b), Number.EPSILON);
+ return {
+ equal: (delta / base) * 100 <= tolerance,
+ delta,
+ };
+ }
+
+ return {
+ equal: delta <= tolerance,
+ delta,
+ };
+ }
+
+ return {
+ equal: normalizeText(valueA, config) === normalizeText(valueB, config),
+ delta: null,
+ };
+}
diff --git a/src/i18n/index.js b/src/i18n/index.js
new file mode 100644
index 0000000..1d660fe
--- /dev/null
+++ b/src/i18n/index.js
@@ -0,0 +1,80 @@
+import { localeRegistry } from './locales/index.js';
+
+const FALLBACK_LOCALE = 'en';
+export const LOCALE_CHANGE_EVENT = 'tec:localechange';
+
+function normalizeLocale(value) {
+ return String(value || '').trim().toLowerCase().split('-')[0];
+}
+
+function detectLocale() {
+ const url = new URL(window.location.href);
+ const fromUrl = normalizeLocale(url.searchParams.get('lang'));
+ if (localeRegistry[fromUrl]) return fromUrl;
+
+ const candidates = Array.isArray(navigator.languages) && navigator.languages.length
+ ? navigator.languages
+ : [navigator.language];
+
+ for (const candidate of candidates) {
+ const code = normalizeLocale(candidate);
+ if (localeRegistry[code]) return code;
+ }
+
+ return FALLBACK_LOCALE;
+}
+
+let activeLocale = detectLocale();
+
+function applyDocumentLocale() {
+ document.documentElement.lang = activeLocale;
+ document.documentElement.dataset.locale = activeLocale;
+}
+
+applyDocumentLocale();
+
+export function getLocale() {
+ return activeLocale;
+}
+
+export function getSupportedLocales() {
+ return Object.values(localeRegistry).map(({ code, nativeName, shortLabel }) => ({
+ code,
+ nativeName,
+ shortLabel,
+ }));
+}
+
+export function t(key, params = {}) {
+ const current = localeRegistry[activeLocale]?.messages ?? {};
+ const fallback = localeRegistry[FALLBACK_LOCALE]?.messages ?? {};
+ const raw = current[key] ?? fallback[key] ?? key;
+
+ return String(raw).replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, name) => {
+ return Object.prototype.hasOwnProperty.call(params, name)
+ ? String(params[name])
+ : '{' + name + '}';
+ });
+}
+
+export function setLocale(locale) {
+ const next = normalizeLocale(locale);
+ if (!localeRegistry[next] || next === activeLocale) return;
+
+ const previous = activeLocale;
+ activeLocale = next;
+ applyDocumentLocale();
+
+ const url = new URL(window.location.href);
+ url.searchParams.set('lang', next);
+
+ try {
+ window.history.replaceState(window.history.state, '', url.toString());
+ } catch {
+ // The language switch must still work if the URL cannot be rewritten.
+ }
+
+ window.dispatchEvent(new CustomEvent(LOCALE_CHANGE_EVENT, {
+ detail: { previous, current: next },
+ }));
+}
diff --git a/src/i18n/locales/en.js b/src/i18n/locales/en.js
new file mode 100644
index 0000000..93c48c3
--- /dev/null
+++ b/src/i18n/locales/en.js
@@ -0,0 +1,146 @@
+export default {
+ 'meta.title': 'Two-Export Comparator',
+ 'meta.description': "Compare two CSV/TSV exports locally. Find what doesn't match — no upload, no account, no cloud.",
+
+ 'language.label': 'Language',
+
+ 'app.brand': 'Two-Export Comparator',
+ 'app.tagline': 'Local processing · No uploads',
+ 'app.localOnly': 'Local only',
+ 'app.filesNeverLeave': 'Files never leave this browser',
+
+ 'skip.content': 'Skip to content',
+
+ 'landing.titlePrefix': 'Compare two exports. ',
+ 'landing.titleAccent': "Find what doesn't match.",
+ 'landing.subtitle': 'Two files in. Differences out. No account, no upload, no cloud database.',
+ 'landing.fileSelection': 'File selection',
+
+ 'privacy.local': 'Your files are processed locally in this browser and are not uploaded by this application.',
+
+ 'file.label': 'File {slot}',
+ 'file.choose': 'Choose File {slot}',
+ 'file.chooseCsv': 'Choose File {slot} — CSV or TSV',
+ 'file.none': 'No file selected',
+ 'file.remove': 'Remove',
+ 'file.removeSlot': 'Remove File {slot}',
+ 'file.drop': 'Drop CSV/TSV or browse',
+ 'file.staysLocal': 'Files stay in this browser',
+ 'file.rows': 'Rows',
+ 'file.columns': 'Columns',
+ 'file.delimiter': 'Delimiter',
+ 'file.encoding': 'Encoding',
+ 'file.size': 'Size',
+ 'file.preview': 'PREVIEW',
+ 'file.showingRows': 'Showing {shown} of {total} rows',
+ 'file.readError': "We couldn't read that file",
+ 'file.tryDifferent': 'Try a different CSV or TSV export.',
+ 'file.chooseAnother': 'Choose another file',
+ 'file.genericError': 'Something went wrong',
+
+ 'delimiter.comma': 'comma',
+ 'delimiter.tab': 'tab',
+ 'delimiter.semicolon': 'semicolon',
+ 'delimiter.pipe': 'pipe',
+
+ 'mapping.aria': 'Column mapping',
+ 'mapping.title': 'What do you want to compare?',
+ 'mapping.hint': 'First choose how the same record is identified in both files. Then choose which values should be checked.',
+ 'mapping.matchKeys': '1. How do we identify the same record?',
+ 'mapping.matchKeysHint': 'For example: invoice number, order ID or payment reference.',
+ 'mapping.addKey': '+ Add another identifier',
+ 'mapping.noKeys': 'Choose at least one pair of columns that identifies the same record in both files.',
+ 'mapping.compareFields': '2. What should be checked for those records?',
+ 'mapping.compareHint': 'For example: amount, date or status. This step is optional.',
+ 'mapping.addCompare': '+ Add another check',
+ 'mapping.noCompare': 'No additional fields will be compared.',
+ 'mapping.normalization': 'Advanced comparison rules',
+ 'mapping.normalizationHint': 'You normally do not need to change these settings.',
+ 'mapping.trim': 'Ignore spaces at the beginning and end',
+ 'mapping.ignoreCase': 'Ignore upper/lower case',
+ 'mapping.collapseWhitespace': 'Treat repeated spaces as one',
+ 'mapping.startOver': 'Start over',
+ 'mapping.compareFiles': 'Compare files',
+ 'mapping.fileAColumn': 'File A column',
+ 'mapping.fileBColumn': 'File B column',
+ 'mapping.remove': 'Remove mapping',
+ 'mapping.select': '— select —',
+ 'mapping.toleranceMode': 'Tolerance mode',
+ 'mapping.toleranceValue': 'Tolerance value',
+ 'mapping.role.key': 'IDENTIFIER',
+ 'mapping.role.compare': 'CHECK',
+ 'mapping.role.display': 'DISPLAY',
+ 'mapping.role.ignore': 'IGNORE',
+
+ 'comparing.preparing': 'Preparing…',
+ 'comparing.local': 'Working locally. Nothing leaves this browser.',
+ 'comparing.progress': 'Comparison progress',
+ 'comparing.phase.parse-a': 'Parse File A',
+ 'comparing.phase.parse-b': 'Parse File B',
+ 'comparing.phase.index': 'Index records',
+ 'comparing.phase.compare': 'Compare',
+ 'comparing.phase.finalize': 'Finalize',
+
+ 'results.complete': 'Comparison complete',
+ 'results.rowsSummary': '{a} rows in File A · {b} rows in File B',
+ 'results.startOver': 'Start over',
+ 'results.exportCsv': 'Export CSV',
+ 'results.filters': 'Result filters',
+ 'results.all': 'All',
+ 'results.mismatch': 'Mismatch',
+ 'results.onlyA': 'Only A',
+ 'results.onlyB': 'Only B',
+ 'results.duplicate': 'Duplicate',
+ 'results.ambiguous': 'Ambiguous',
+ 'results.matched': 'Matched',
+ 'results.status': 'Status',
+ 'results.key': 'Key',
+ 'results.rowA': 'Row A',
+ 'results.rowB': 'Row B',
+ 'results.openRecord': 'Open record {key}',
+ 'results.nothing': 'Nothing in this category.',
+ 'results.records': '{count} records',
+ 'results.page': '{count} records · page {page} of {pages}',
+ 'results.prev': '← Prev',
+ 'results.next': 'Next →',
+
+ 'visual.autoCompared': 'Compared automatically',
+ 'visual.differencesFound': 'Differences found',
+ 'visual.filesMatch': 'Files match',
+ 'visual.diffSubtitle': '{count} records need attention. Matching rows stay visually quiet.',
+ 'visual.matchSubtitle': 'No differences were found in the compared data.',
+ 'visual.fileA': 'File A',
+ 'visual.fileB': 'File B',
+ 'visual.expert': 'Expert settings',
+ 'visual.onlyHere': 'Only in one file',
+ 'visual.onlyA': 'Only in File A',
+ 'visual.onlyB': 'Only in File B',
+ 'visual.notInFile': 'Not in this file',
+ 'visual.changed': 'Different values',
+ 'visual.matched': 'Matches',
+ 'visual.ambiguous': 'Needs review',
+ 'visual.row': 'row {row}',
+
+ 'record.detail': 'Record detail',
+ 'record.close': 'Close',
+ 'record.fileA': 'File A',
+ 'record.fileB': 'File B',
+ 'record.row': 'row {row}',
+ 'record.differences': 'Differences',
+ 'record.explain.MATCHED': 'Key exists in both files and all compared values pass.',
+ 'record.explain.ONLY_A': 'Record exists only in File A.',
+ 'record.explain.ONLY_B': 'Record exists only in File B.',
+ 'record.explain.MISMATCH': 'Key matched, but one or more compared fields differ.',
+ 'record.explain.DUPLICATE': 'The same normalized key occurs multiple times.',
+ 'record.explain.AMBIGUOUS': 'The engine cannot determine a unique pairing safely. No guess was made.',
+
+ 'toast.notifications': 'Notifications',
+ 'toast.dismiss': 'Dismiss',
+ 'toast.readErrorTitle': "We couldn't read that file",
+ 'toast.readErrorMessage': 'Try a different CSV or TSV export.',
+ 'toast.compareFailedTitle': 'Comparison failed',
+ 'toast.compareFailedMessage': 'Try adjusting the mapping or the files.',
+ 'toast.exportReadyTitle': 'Export ready',
+ 'toast.exportReadyMessage': 'Your download has started.',
+ 'toast.exportFailedTitle': 'Export failed'
+};
diff --git a/src/i18n/locales/index.js b/src/i18n/locales/index.js
new file mode 100644
index 0000000..5f14c4c
--- /dev/null
+++ b/src/i18n/locales/index.js
@@ -0,0 +1,17 @@
+import en from './en.js';
+import lv from './lv.js';
+
+export const localeRegistry = {
+ en: {
+ code: 'en',
+ nativeName: 'English',
+ shortLabel: 'EN',
+ messages: en,
+ },
+ lv: {
+ code: 'lv',
+ nativeName: 'Latviešu',
+ shortLabel: 'LV',
+ messages: lv,
+ },
+};
diff --git a/src/i18n/locales/lv.js b/src/i18n/locales/lv.js
new file mode 100644
index 0000000..dc35569
--- /dev/null
+++ b/src/i18n/locales/lv.js
@@ -0,0 +1,146 @@
+export default {
+ 'meta.title': 'Two-Export Comparator',
+ 'meta.description': 'Salīdzini divus CSV/TSV eksportus lokāli. Atrodi, kas nesakrīt — bez augšupielādes, konta un mākoņa.',
+
+ 'language.label': 'Valoda',
+
+ 'app.brand': 'Two-Export Comparator',
+ 'app.tagline': 'Lokāla apstrāde · Bez augšupielādes',
+ 'app.localOnly': 'Tikai lokāli',
+ 'app.filesNeverLeave': 'Faili nekad neatstāj šo pārlūku',
+
+ 'skip.content': 'Pāriet uz saturu',
+
+ 'landing.titlePrefix': 'Salīdzini divus eksportus. ',
+ 'landing.titleAccent': 'Atrodi, kas nesakrīt.',
+ 'landing.subtitle': 'Divi faili iekšā. Atšķirības ārā. Bez konta, bez augšupielādes un bez mākoņdatubāzes.',
+ 'landing.fileSelection': 'Failu izvēle',
+
+ 'privacy.local': 'Faili tiek apstrādāti lokāli šajā pārlūkā, un aplikācija tos neaugšupielādē.',
+
+ 'file.label': 'Fails {slot}',
+ 'file.choose': 'Izvēlēties Failu {slot}',
+ 'file.chooseCsv': 'Izvēlēties Failu {slot} — CSV vai TSV',
+ 'file.none': 'Fails nav izvēlēts',
+ 'file.remove': 'Noņemt',
+ 'file.removeSlot': 'Noņemt Failu {slot}',
+ 'file.drop': 'Ievelc CSV/TSV vai izvēlies failu',
+ 'file.staysLocal': 'Faili paliek šajā pārlūkā',
+ 'file.rows': 'Rindas',
+ 'file.columns': 'Kolonnas',
+ 'file.delimiter': 'Atdalītājs',
+ 'file.encoding': 'Kodējums',
+ 'file.size': 'Izmērs',
+ 'file.preview': 'PRIEKŠSKATĪJUMS',
+ 'file.showingRows': 'Rāda {shown} no {total} rindām',
+ 'file.readError': 'Failu neizdevās nolasīt',
+ 'file.tryDifferent': 'Pamēģini citu CSV vai TSV eksportu.',
+ 'file.chooseAnother': 'Izvēlēties citu failu',
+ 'file.genericError': 'Radās kļūda',
+
+ 'delimiter.comma': 'komats',
+ 'delimiter.tab': 'tabulācija',
+ 'delimiter.semicolon': 'semikols',
+ 'delimiter.pipe': 'vertikālā svītra',
+
+ 'mapping.aria': 'Kolonnu savienošana',
+ 'mapping.title': 'Ko vēlies salīdzināt?',
+ 'mapping.hint': 'Vispirms norādi, pēc kuras kolonnas abos failos atpazīt vienu un to pašu ierakstu. Pēc tam izvēlies, kuras vērtības pārbaudīt.',
+ 'mapping.matchKeys': '1. Kā atpazīt vienu un to pašu ierakstu?',
+ 'mapping.matchKeysHint': 'Piemēram: rēķina numurs, pasūtījuma ID vai maksājuma atsauce.',
+ 'mapping.addKey': '+ Pievienot vēl vienu identifikatoru',
+ 'mapping.noKeys': 'Izvēlies vismaz vienu kolonnu pāri, kas abos failos apzīmē vienu un to pašu ierakstu.',
+ 'mapping.compareFields': '2. Ko pārbaudīt šajos ierakstos?',
+ 'mapping.compareHint': 'Piemēram: summa, datums vai statuss. Šis solis nav obligāts.',
+ 'mapping.addCompare': '+ Pievienot vēl vienu pārbaudi',
+ 'mapping.noCompare': 'Papildu lauki netiks salīdzināti.',
+ 'mapping.normalization': 'Papildu salīdzināšanas noteikumi',
+ 'mapping.normalizationHint': 'Parasti šos iestatījumus nav jāmaina.',
+ 'mapping.trim': 'Ignorēt atstarpes sākumā un beigās',
+ 'mapping.ignoreCase': 'Nešķirot lielos un mazos burtus',
+ 'mapping.collapseWhitespace': 'Vairākas atstarpes uzskatīt par vienu',
+ 'mapping.startOver': 'Sākt no jauna',
+ 'mapping.compareFiles': 'Salīdzināt failus',
+ 'mapping.fileAColumn': 'Faila A kolonna',
+ 'mapping.fileBColumn': 'Faila B kolonna',
+ 'mapping.remove': 'Noņemt savienojumu',
+ 'mapping.select': '— izvēlies —',
+ 'mapping.toleranceMode': 'Pielaides režīms',
+ 'mapping.toleranceValue': 'Pielaides vērtība',
+ 'mapping.role.key': 'IDENTIFIKATORS',
+ 'mapping.role.compare': 'PĀRBAUDĪT',
+ 'mapping.role.display': 'RĀDĪT',
+ 'mapping.role.ignore': 'IGNORĒT',
+
+ 'comparing.preparing': 'Sagatavo…',
+ 'comparing.local': 'Apstrāde notiek lokāli. Nekas neatstāj šo pārlūku.',
+ 'comparing.progress': 'Salīdzināšanas progress',
+ 'comparing.phase.parse-a': 'Nolasīt Failu A',
+ 'comparing.phase.parse-b': 'Nolasīt Failu B',
+ 'comparing.phase.index': 'Indeksēt ierakstus',
+ 'comparing.phase.compare': 'Salīdzināt',
+ 'comparing.phase.finalize': 'Pabeigt',
+
+ 'results.complete': 'Salīdzināšana pabeigta',
+ 'results.rowsSummary': '{a} rindas Failā A · {b} rindas Failā B',
+ 'results.startOver': 'Sākt no jauna',
+ 'results.exportCsv': 'Eksportēt CSV',
+ 'results.filters': 'Rezultātu filtri',
+ 'results.all': 'Visi',
+ 'results.mismatch': 'Nesakrīt',
+ 'results.onlyA': 'Tikai A',
+ 'results.onlyB': 'Tikai B',
+ 'results.duplicate': 'Dublikāti',
+ 'results.ambiguous': 'Neskaidri',
+ 'results.matched': 'Sakrīt',
+ 'results.status': 'Statuss',
+ 'results.key': 'Atslēga',
+ 'results.rowA': 'Rinda A',
+ 'results.rowB': 'Rinda B',
+ 'results.openRecord': 'Atvērt ierakstu {key}',
+ 'results.nothing': 'Šajā kategorijā ierakstu nav.',
+ 'results.records': '{count} ieraksti',
+ 'results.page': '{count} ieraksti · {page}. lapa no {pages}',
+ 'results.prev': '← Iepriekšējā',
+ 'results.next': 'Nākamā →',
+
+ 'visual.autoCompared': 'Salīdzināts automātiski',
+ 'visual.differencesFound': 'Atrastas atšķirības',
+ 'visual.filesMatch': 'Faili sakrīt',
+ 'visual.diffSubtitle': '{count} ierakstiem jāpievērš uzmanība. Sakritības paliek vizuāli neitrālas.',
+ 'visual.matchSubtitle': 'Salīdzinātajos datos atšķirības netika atrastas.',
+ 'visual.fileA': 'Fails A',
+ 'visual.fileB': 'Fails B',
+ 'visual.expert': 'Eksperta iestatījumi',
+ 'visual.onlyHere': 'Tikai vienā failā',
+ 'visual.onlyA': 'Tikai Failā A',
+ 'visual.onlyB': 'Tikai Failā B',
+ 'visual.notInFile': 'Nav šajā failā',
+ 'visual.changed': 'Atšķirīgas vērtības',
+ 'visual.matched': 'Sakrīt',
+ 'visual.ambiguous': 'Jāpārbauda',
+ 'visual.row': 'rinda {row}',
+
+ 'record.detail': 'Ieraksta detaļas',
+ 'record.close': 'Aizvērt',
+ 'record.fileA': 'Fails A',
+ 'record.fileB': 'Fails B',
+ 'record.row': 'rinda {row}',
+ 'record.differences': 'Atšķirības',
+ 'record.explain.MATCHED': 'Atslēga ir abos failos, un visas salīdzinātās vērtības atbilst.',
+ 'record.explain.ONLY_A': 'Ieraksts atrodas tikai Failā A.',
+ 'record.explain.ONLY_B': 'Ieraksts atrodas tikai Failā B.',
+ 'record.explain.MISMATCH': 'Atslēga sakrīt, bet viens vai vairāki salīdzinātie lauki atšķiras.',
+ 'record.explain.DUPLICATE': 'Viena normalizēta atslēga sastopama vairākas reizes.',
+ 'record.explain.AMBIGUOUS': 'Dzinējs nevar droši noteikt vienu unikālu pāri. Minējums netika veikts.',
+
+ 'toast.notifications': 'Paziņojumi',
+ 'toast.dismiss': 'Aizvērt',
+ 'toast.readErrorTitle': 'Failu neizdevās nolasīt',
+ 'toast.readErrorMessage': 'Pamēģini citu CSV vai TSV eksportu.',
+ 'toast.compareFailedTitle': 'Salīdzināšana neizdevās',
+ 'toast.compareFailedMessage': 'Pārbaudi kolonnu savienojumus vai failus.',
+ 'toast.exportReadyTitle': 'Eksports gatavs',
+ 'toast.exportReadyMessage': 'Lejupielāde ir sākta.',
+ 'toast.exportFailedTitle': 'Eksports neizdevās'
+};
diff --git a/src/main.js b/src/main.js
index 80cea3f..4f4d60a 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,8 +1,94 @@
-import "./styles/tokens.css";
-import "./styles/base.css";
-import "./styles/components.css";
-import "./styles/utilities.css";
+import './styles/tokens.css';
+import './styles/base.css';
+import './styles/utilities.css';
+import './styles/components.css';
-const app = document.querySelector("#app");
+import { AppStore } from './app/app-store.js';
+import { AppController } from './app/app-controller.js';
+import { BrowserComparisonService } from './services/browser-comparison-service.js';
+import { LandingView } from './ui/views/landing-view.js';
+import { MappingView } from './ui/views/mapping-view.js';
+import { ComparingView } from './ui/views/comparing-view.js';
+import { ResultsView } from './ui/views/results-view.js';
+import { ToastHost } from './ui/components/toast-host.js';
+import { SkipLink } from './ui/components/skip-link.js';
+import { LanguageSwitcher } from './ui/components/language-switcher.js';
+import { el, replaceChildren } from './ui/renderers/dom.js';
+import { LOCALE_CHANGE_EVENT, t } from './i18n/index.js';
-app.textContent = "Two Export Comparator — frontend scaffold ready.";
+export function startApp(config = {}) {
+ const service = config.comparisonService ?? new BrowserComparisonService();
+ const store = new AppStore();
+ const controller = new AppController(store, service);
+ const root = document.getElementById('app');
+
+ const brandText = el('span');
+ const tagline = el('span', { class: 'app-header__tagline' });
+ const privacyText = el('span');
+ const privacyBadge = el('span', { class: 'privacy-badge' }, [
+ el('span', { class: 'privacy-badge__dot', 'aria-hidden': 'true' }),
+ privacyText,
+ ]);
+
+ const header = el('header', { class: 'app-header' }, [
+ el('div', { class: 'app-header__brand' }, [
+ el('div', { class: 'app-header__mark', text: '⇄' }),
+ brandText,
+ ]),
+ el('div', { class: 'app-header__meta' }, [
+ tagline,
+ LanguageSwitcher(),
+ privacyBadge,
+ ]),
+ ]);
+
+ const landingView = new LandingView(store);
+ const mappingView = new MappingView(store, service);
+ const comparingView = new ComparingView(store);
+ const resultsView = new ResultsView(store);
+ const toastHost = new ToastHost(store);
+
+ const main = el('main', { id: 'main', class: 'app-content' }, [
+ landingView.root,
+ mappingView.root,
+ comparingView.root,
+ resultsView.root,
+ ]);
+
+ replaceChildren(root, [
+ SkipLink(),
+ header,
+ main,
+ toastHost.root,
+ ]);
+
+ const syncLanguage = () => {
+ document.title = t('meta.title');
+ const description = document.querySelector('meta[name="description"]');
+ if (description) description.setAttribute('content', t('meta.description'));
+
+ brandText.textContent = t('app.brand');
+ tagline.textContent = t('app.tagline');
+ privacyText.textContent = t('app.localOnly');
+ privacyBadge.title = t('app.filesNeverLeave');
+ };
+
+ const syncState = () => {
+ root.dataset.state = store.state;
+ landingView.root.hidden = !['EMPTY', 'FILES_PARTIAL', 'FILES_READY'].includes(store.state);
+ };
+
+ window.addEventListener(LOCALE_CHANGE_EVENT, syncLanguage);
+ store.addEventListener('state', syncState);
+
+ syncLanguage();
+ syncState();
+
+ if (import.meta.env?.DEV) {
+ window.__app = { store, controller, service };
+ }
+
+ return { store, controller, service };
+}
+
+startApp();
diff --git a/src/models/contracts.js b/src/models/contracts.js
index e69de29..313c721 100644
--- a/src/models/contracts.js
+++ b/src/models/contracts.js
@@ -0,0 +1,148 @@
+/**
+ * @file Data Transfer Object contracts for Two-Export Comparator.
+ * The UI depends only on these shapes — never on parser internals.
+ */
+
+/**
+ * @typedef {'text' | 'number' | 'date' | 'unknown'} InferredType
+ */
+
+/**
+ * @typedef {Object} ColumnInfo
+ * @property {string} id
+ * @property {string} label
+ * @property {InferredType} inferredType
+ */
+
+/**
+ * @typedef {Object} FileWarning
+ * @property {string} code
+ * @property {'info' | 'warning' | 'error'} severity
+ * @property {string} title
+ * @property {string} [message]
+ * @property {boolean} [recoverable]
+ * @property {string[]} [actions]
+ */
+
+/**
+ * @typedef {Object} FileInspection
+ * @property {string} id
+ * @property {string} name
+ * @property {number} sizeBytes
+ * @property {number | null} rowCount
+ * @property {string | null} delimiter
+ * @property {string | null} encoding
+ * @property {ColumnInfo[]} columns
+ * @property {Record[]} previewRows
+ * @property {FileWarning[]} warnings
+ */
+
+/**
+ * @typedef {'text' | 'number' | 'date'} MappingFieldType
+ */
+
+/**
+ * @typedef {Object} Tolerance
+ * @property {'absolute' | 'percentage'} mode
+ * @property {number} value
+ */
+
+/**
+ * @typedef {Object} KeyMapping
+ * @property {string} columnA
+ * @property {string} columnB
+ * @property {MappingFieldType} type
+ */
+
+/**
+ * @typedef {Object} CompareMapping
+ * @property {string} columnA
+ * @property {string} columnB
+ * @property {MappingFieldType} type
+ * @property {Tolerance} [tolerance]
+ */
+
+/**
+ * @typedef {Object} NormalizationConfig
+ * @property {boolean} trimText
+ * @property {boolean} caseInsensitive
+ * @property {boolean} collapseWhitespace
+ * @property {string} numberLocale
+ * @property {string | null} dateFormatA
+ * @property {string | null} dateFormatB
+ */
+
+/**
+ * @typedef {Object} MappingConfig
+ * @property {KeyMapping[]} keys
+ * @property {CompareMapping[]} comparisons
+ * @property {{ fileA: string[], fileB: string[] }} displayOnly
+ * @property {NormalizationConfig} normalization
+ */
+
+/**
+ * @typedef {Object} ComparisonRequest
+ * @property {string} fileAId
+ * @property {string} fileBId
+ * @property {MappingConfig} mapping
+ */
+
+/**
+ * @typedef {'MATCHED' | 'ONLY_A' | 'ONLY_B' | 'MISMATCH' | 'DUPLICATE' | 'AMBIGUOUS'} ResultStatus
+ */
+
+/**
+ * @typedef {Object} FieldDifference
+ * @property {string} field
+ * @property {unknown} valueA
+ * @property {unknown} valueB
+ * @property {number} [delta]
+ */
+
+/**
+ * @typedef {Object} ResultRecord
+ * @property {string} id
+ * @property {ResultStatus} status
+ * @property {string} keyLabel
+ * @property {{ rowA: number | null, rowB: number | null }} source
+ * @property {Record} [displayA]
+ * @property {Record} [displayB]
+ * @property {FieldDifference[]} [differences]
+ */
+
+/**
+ * @typedef {Object} ComparisonSummary
+ * @property {number} totalA
+ * @property {number} totalB
+ * @property {number} matched
+ * @property {number} onlyA
+ * @property {number} onlyB
+ * @property {number} mismatched
+ * @property {number} duplicates
+ * @property {number} ambiguous
+ */
+
+/**
+ * @typedef {Object} ComparisonResult
+ * @property {string} runId
+ * @property {string} createdAt
+ * @property {ComparisonSummary} summary
+ * @property {ResultRecord[]} records
+ * @property {FileWarning[]} warnings
+ */
+
+/**
+ * @typedef {Object} ProgressEvent
+ * @property {'parse-a' | 'parse-b' | 'index' | 'compare' | 'finalize'} phase
+ * @property {string} message
+ * @property {number} [completed]
+ * @property {number} [total]
+ */
+
+/**
+ * @typedef {Object} ExportOptions
+ * @property {ResultStatus[]} [includeStatuses]
+ * @property {'csv'} [format]
+ */
+
+export {};
\ No newline at end of file
diff --git a/src/services/browser-comparison-service.js b/src/services/browser-comparison-service.js
index e69de29..2e67a68 100644
--- a/src/services/browser-comparison-service.js
+++ b/src/services/browser-comparison-service.js
@@ -0,0 +1,344 @@
+import { ComparisonService } from './comparison-service.js';
+import { tableFromText } from '../engine/delimited-parser.js';
+import { compareValues, normalizeKeyForSide } from '../engine/normalization.js';
+import { suggestColumnMappings } from '../engine/mapping-suggester.js';
+
+const MAX_FILE_BYTES = 50 * 1024 * 1024;
+const PREVIEW_ROWS = 5;
+
+export class BrowserComparisonService extends ComparisonService {
+ constructor() {
+ super();
+ this._files = new Map();
+ }
+
+ async inspectFile(file, options = {}) {
+ if (!file || typeof file.text !== 'function') {
+ throw new Error('INVALID_FILE');
+ }
+
+ if (file.size > MAX_FILE_BYTES) {
+ throw new Error('FILE_TOO_LARGE');
+ }
+
+ const text = await file.text();
+ const parsed = tableFromText(text);
+
+ const slot = options.slot === 'A' || options.slot === 'B'
+ ? options.slot
+ : (this._files.has('file-a') ? 'B' : 'A');
+
+ const id = 'file-' + slot.toLowerCase();
+
+ const inspection = {
+ id,
+ name: file.name || id + '.csv',
+ sizeBytes: file.size ?? text.length,
+ rowCount: parsed.rows.length,
+ delimiter: parsed.delimiter,
+ encoding: 'UTF-8',
+ columns: parsed.columns,
+ previewRows: parsed.rows.slice(0, PREVIEW_ROWS),
+ warnings: [],
+ };
+
+ this._files.set(id, {
+ file,
+ inspection,
+ parsed,
+ });
+
+ return inspection;
+ }
+
+ async suggestMappings(fileA, fileB) {
+ const dataA = this._files.get(fileA.id);
+ const dataB = this._files.get(fileB.id);
+
+ if (!dataA || !dataB) return [];
+
+ return suggestColumnMappings(
+ fileA,
+ fileB,
+ dataA.parsed,
+ dataB.parsed
+ );
+ }
+
+ async compare(request, { onProgress } = {}) {
+ const dataA = this._files.get(request.fileAId);
+ const dataB = this._files.get(request.fileBId);
+
+ if (!dataA || !dataB) {
+ throw new Error('FILES_NOT_AVAILABLE');
+ }
+
+ const mapping = request.mapping;
+ if (!mapping?.keys?.length) {
+ throw new Error('MISSING_KEY_MAPPING');
+ }
+
+ onProgress?.({
+ phase: 'index',
+ message: 'Indexing records…',
+ completed: 20,
+ total: 100,
+ });
+
+ const indexA = buildIndex(dataA.parsed.rows, mapping.keys, 'A', mapping.normalization);
+ const indexB = buildIndex(dataB.parsed.rows, mapping.keys, 'B', mapping.normalization);
+
+ onProgress?.({
+ phase: 'compare',
+ message: 'Comparing records…',
+ completed: 55,
+ total: 100,
+ });
+
+ const records = [];
+ const seenKeys = new Set();
+
+ for (const [key, rowsA] of indexA.entries()) {
+ seenKeys.add(key);
+ const rowsB = indexB.get(key) ?? [];
+
+ if (rowsB.length === 0) {
+ for (const itemA of rowsA) {
+ records.push(makeSingleSideRecord(
+ 'ONLY_A',
+ key,
+ itemA,
+ null,
+ dataA,
+ dataB
+ ));
+ }
+ continue;
+ }
+
+ if (rowsA.length > 1 || rowsB.length > 1) {
+ records.push(makeDuplicateRecord(
+ rowsA.length > 1 && rowsB.length > 1 ? 'AMBIGUOUS' : 'DUPLICATE',
+ key,
+ rowsA,
+ rowsB,
+ dataA,
+ dataB
+ ));
+ continue;
+ }
+
+ records.push(comparePair(
+ key,
+ rowsA[0],
+ rowsB[0],
+ mapping,
+ dataA,
+ dataB
+ ));
+ }
+
+ for (const [key, rowsB] of indexB.entries()) {
+ if (seenKeys.has(key)) continue;
+
+ for (const itemB of rowsB) {
+ records.push(makeSingleSideRecord(
+ 'ONLY_B',
+ key,
+ null,
+ itemB,
+ dataA,
+ dataB
+ ));
+ }
+ }
+
+ onProgress?.({
+ phase: 'finalize',
+ message: 'Finalizing…',
+ completed: 100,
+ total: 100,
+ });
+
+ const summary = {
+ totalA: dataA.parsed.rows.length,
+ totalB: dataB.parsed.rows.length,
+ matched: countStatus(records, 'MATCHED'),
+ onlyA: countStatus(records, 'ONLY_A'),
+ onlyB: countStatus(records, 'ONLY_B'),
+ mismatched: countStatus(records, 'MISMATCH'),
+ duplicates: countStatus(records, 'DUPLICATE'),
+ ambiguous: countStatus(records, 'AMBIGUOUS'),
+ };
+
+ return {
+ runId: 'cmp-' + Date.now(),
+ createdAt: new Date().toISOString(),
+ summary,
+ records,
+ warnings: [],
+ };
+ }
+
+ async createExport(result, options = {}) {
+ const allowed = options.includeStatuses?.length
+ ? new Set(options.includeStatuses)
+ : null;
+
+ const rows = [
+ ['status', 'key', 'rowA', 'rowB', 'differences'],
+ ];
+
+ for (const record of result.records) {
+ if (allowed && !allowed.has(record.status)) continue;
+
+ rows.push([
+ record.status,
+ record.keyLabel,
+ record.source?.rowA ?? '',
+ record.source?.rowB ?? '',
+ (record.differences ?? [])
+ .map((difference) => {
+ return difference.fieldA + ' ↔ ' + difference.fieldB +
+ ': ' + String(difference.valueA ?? '') +
+ ' → ' + String(difference.valueB ?? '');
+ })
+ .join(' | '),
+ ]);
+ }
+
+ const csv = rows
+ .map((row) => row.map(csvCell).join(','))
+ .join('\r\n');
+
+ return new Blob([csv], { type: 'text/csv;charset=utf-8' });
+ }
+
+ reset() {
+ this._files.clear();
+ }
+}
+
+function buildIndex(rows, keys, side, normalization) {
+ const index = new Map();
+
+ rows.forEach((row, indexNumber) => {
+ const key = normalizeKeyForSide(row, keys, side, normalization);
+ if (!index.has(key)) index.set(key, []);
+ index.get(key).push({
+ row,
+ rowNumber: indexNumber + 2,
+ });
+ });
+
+ return index;
+}
+
+function comparePair(key, itemA, itemB, mapping, dataA, dataB) {
+ const differences = [];
+
+ for (const comparison of mapping.comparisons ?? []) {
+ const valueA = itemA.row[comparison.columnA] ?? '';
+ const valueB = itemB.row[comparison.columnB] ?? '';
+ const result = compareValues(
+ valueA,
+ valueB,
+ comparison,
+ mapping.normalization
+ );
+
+ if (!result.equal) {
+ differences.push({
+ field: comparison.columnA,
+ fieldA: comparison.columnA,
+ fieldB: comparison.columnB,
+ valueA,
+ valueB,
+ ...(result.delta !== null ? { delta: result.delta } : {}),
+ });
+ }
+ }
+
+ return {
+ id: nextResultId(),
+ status: differences.length ? 'MISMATCH' : 'MATCHED',
+ keyLabel: readableKey(key),
+ source: {
+ rowA: itemA.rowNumber,
+ rowB: itemB.rowNumber,
+ },
+ displayA: displayRow(itemA.row, dataA.inspection.columns),
+ displayB: displayRow(itemB.row, dataB.inspection.columns),
+ differences,
+ };
+}
+
+function makeSingleSideRecord(status, key, itemA, itemB, dataA, dataB) {
+ return {
+ id: nextResultId(),
+ status,
+ keyLabel: readableKey(key),
+ source: {
+ rowA: itemA?.rowNumber ?? null,
+ rowB: itemB?.rowNumber ?? null,
+ },
+ ...(itemA
+ ? { displayA: displayRow(itemA.row, dataA.inspection.columns) }
+ : {}),
+ ...(itemB
+ ? { displayB: displayRow(itemB.row, dataB.inspection.columns) }
+ : {}),
+ };
+}
+
+function makeDuplicateRecord(status, key, rowsA, rowsB, dataA, dataB) {
+ return {
+ id: nextResultId(),
+ status,
+ keyLabel: readableKey(key),
+ source: {
+ rowA: rowsA[0]?.rowNumber ?? null,
+ rowB: rowsB[0]?.rowNumber ?? null,
+ },
+ ...(rowsA[0]
+ ? { displayA: displayRow(rowsA[0].row, dataA.inspection.columns) }
+ : {}),
+ ...(rowsB[0]
+ ? { displayB: displayRow(rowsB[0].row, dataB.inspection.columns) }
+ : {}),
+ };
+}
+
+function displayRow(row, columns) {
+ const result = {};
+ for (const column of columns) result[column.label] = row[column.id] ?? '';
+ return result;
+}
+
+function countStatus(records, status) {
+ return records.filter((record) => record.status === status).length;
+}
+
+function readableKey(key) {
+ return String(key ?? '').split('\u001F').join(' · ');
+}
+
+let resultCounter = 0;
+function nextResultId() {
+ resultCounter += 1;
+ return 'result-' + resultCounter;
+}
+
+function csvCell(value) {
+ let text = String(value ?? '');
+
+ if (/^[=+\-@]/.test(text)) {
+ text = "'" + text;
+ }
+
+ if (/[",\r\n]/.test(text)) {
+ text = '"' + text.replace(/"/g, '""') + '"';
+ }
+
+ return text;
+}
diff --git a/src/services/comparison-service.js b/src/services/comparison-service.js
index e69de29..c0074f1 100644
--- a/src/services/comparison-service.js
+++ b/src/services/comparison-service.js
@@ -0,0 +1,58 @@
+/**
+ * @file ComparisonService — the single integration boundary.
+ * All technical logic lives behind this contract.
+ * During the GUI phase, MockComparisonService is used.
+ * Later, BrowserComparisonService replaces it without UI redesign.
+ */
+
+/**
+ * @interface
+ */
+export class ComparisonService {
+ /**
+ * Inspect a file and return metadata, columns and a safe preview.
+ * @param {File} file
+ * @param {Object} [options]
+ * @returns {Promise}
+ */
+ async inspectFile(_file, _options = {}) {
+ throw new Error('ComparisonService.inspectFile not implemented');
+ }
+
+ /**
+ * Suggest column mappings. Proposals only — user must confirm.
+ * @param {import('../models/contracts.js').FileInspection} fileA
+ * @param {import('../models/contracts.js').FileInspection} fileB
+ * @returns {Promise>}
+ */
+ async suggestMappings(_fileA, _fileB) {
+ throw new Error('ComparisonService.suggestMappings not implemented');
+ }
+
+ /**
+ * Run the comparison.
+ * @param {import('../models/contracts.js').ComparisonRequest} request
+ * @param {{ onProgress?: (e: import('../models/contracts.js').ProgressEvent) => void }} [callbacks]
+ * @returns {Promise}
+ */
+ async compare(_request, _callbacks = {}) {
+ throw new Error('ComparisonService.compare not implemented');
+ }
+
+ /**
+ * Produce a downloadable Blob for the given result.
+ * @param {import('../models/contracts.js').ComparisonResult} result
+ * @param {import('../models/contracts.js').ExportOptions} [options]
+ * @returns {Promise}
+ */
+ async createExport(_result, _options = {}) {
+ throw new Error('ComparisonService.createExport not implemented');
+ }
+
+ /**
+ * Clear in-memory file/parsed data and caches.
+ */
+ reset() {
+ throw new Error('ComparisonService.reset not implemented');
+ }
+}
\ No newline at end of file
diff --git a/src/services/mock-comparison-service.js b/src/services/mock-comparison-service.js
index e69de29..db585a1 100644
--- a/src/services/mock-comparison-service.js
+++ b/src/services/mock-comparison-service.js
@@ -0,0 +1,202 @@
+import { ComparisonService } from './comparison-service.js';
+
+/**
+ * Deterministic mock service for design/demo purposes.
+ * Does NOT pretend to be production comparison logic.
+ * @extends ComparisonService
+ */
+export class MockComparisonService extends ComparisonService {
+ constructor() {
+ super();
+ this._files = new Map();
+ }
+
+ /**
+ * @param {File} file
+ * @param {object} [options]
+ * @returns {Promise}
+ */
+ async inspectFile(file, options = {}) {
+ await this._delay(420);
+
+ const slot = options.slot === 'A' || options.slot === 'B' ? options.slot : null;
+ const id = slot ? 'file-' + slot.toLowerCase() : (this._files.has('file-a') ? 'file-b' : 'file-a');
+ const isA = id === 'file-a';
+
+ /** @type {import('../models/contracts.js').FileInspection} */
+ const inspection = {
+ id,
+ name: file.name,
+ sizeBytes: file.size,
+ rowCount: isA ? 1204 : 1197,
+ delimiter: ',',
+ encoding: 'UTF-8',
+ columns: isA
+ ? [
+ { id: 'Reference', label: 'Reference', inferredType: 'text' },
+ { id: 'Date', label: 'Date', inferredType: 'date' },
+ { id: 'Amount', label: 'Amount', inferredType: 'number' },
+ { id: 'Description', label: 'Description', inferredType: 'text' },
+ ]
+ : [
+ { id: 'Payment Ref', label: 'Payment Ref', inferredType: 'text' },
+ { id: 'Paid Date', label: 'Paid Date', inferredType: 'date' },
+ { id: 'Total', label: 'Total', inferredType: 'number' },
+ { id: 'Customer', label: 'Customer', inferredType: 'text' },
+ ],
+ previewRows: isA
+ ? [
+ { Reference: 'INV-1001', Date: '2026-09-15', Amount: '125.00', Description: 'Consulting' },
+ { Reference: 'INV-1002', Date: '2026-09-18', Amount: '125.00', Description: 'Design' },
+ { Reference: 'INV-1003', Date: '2026-09-20', Amount: '89.50', Description: 'Hosting' },
+ ]
+ : [
+ { 'Payment Ref': 'INV-1001', 'Paid Date': '2026-09-15', Total: '125.00', Customer: 'Acme' },
+ { 'Payment Ref': 'INV-1002', 'Paid Date': '2026-09-18', Total: '152.00', Customer: 'Acme' },
+ { 'Payment Ref': 'INV-1004', 'Paid Date': '2026-09-22', Total: '45.00', Customer: 'Beta' },
+ ],
+ warnings: [],
+ };
+
+ this._files.set(id, { file, inspection });
+ return inspection;
+ }
+
+ /**
+ * @param {import('../models/contracts.js').FileInspection} fileA
+ * @param {import('../models/contracts.js').FileInspection} fileB
+ */
+ async suggestMappings(fileA, fileB) {
+ await this._delay(220);
+
+ const suggestions = [];
+ const pairs = [
+ ['Reference', 'Payment Ref'],
+ ['Amount', 'Total'],
+ ['Date', 'Paid Date'],
+ ];
+
+ for (const [a, b] of pairs) {
+ if (
+ fileA.columns.some((c) => c.id === a) &&
+ fileB.columns.some((c) => c.id === b)
+ ) {
+ suggestions.push({ columnA: a, columnB: b, confidence: 0.91 });
+ }
+ }
+ return suggestions;
+ }
+
+ /**
+ * @param {import('../models/contracts.js').ComparisonRequest} request
+ * @param {{ onProgress?: (e: import('../models/contracts.js').ProgressEvent) => void }} [callbacks]
+ */
+ async compare(_request, { onProgress } = {}) {
+ const phases = [
+ { phase: 'parse-a', message: 'Parsing File A…', completed: 0, total: 100 },
+ { phase: 'parse-b', message: 'Parsing File B…', completed: 40, total: 100 },
+ { phase: 'index', message: 'Indexing records…', completed: 70, total: 100 },
+ { phase: 'compare', message: 'Comparing records…', completed: 90, total: 100 },
+ { phase: 'finalize', message: 'Finalizing…', completed: 100, total: 100 },
+ ];
+
+ for (const p of phases) {
+ onProgress?.(p);
+ await this._delay(360);
+ }
+
+ /** @type {import('../models/contracts.js').ComparisonResult} */
+ return {
+ runId: 'cmp-mock-001',
+ createdAt: new Date().toISOString(),
+ summary: {
+ totalA: 1204,
+ totalB: 1197,
+ matched: 1176,
+ onlyA: 12,
+ onlyB: 7,
+ mismatched: 4,
+ duplicates: 2,
+ ambiguous: 3,
+ },
+ records: this._sampleRecords(),
+ warnings: [],
+ };
+ }
+
+ async createExport(_result, _options = {}) {
+ const csv = [
+ 'status,key,rowA,rowB,note',
+ 'MISMATCH,INV-1002,482,391,amount differs',
+ 'ONLY_A,INV-1003,483,,missing in B',
+ 'ONLY_B,INV-1004,,392,missing in A',
+ ].join('\n');
+
+ return new Blob([csv], { type: 'text/csv;charset=utf-8' });
+ }
+
+ reset() {
+ this._files.clear();
+ }
+
+ /** @private */
+ _sampleRecords() {
+ return [
+ {
+ id: 'result-1',
+ status: 'MISMATCH',
+ keyLabel: 'INV-1002',
+ source: { rowA: 482, rowB: 391 },
+ displayA: { Date: '2026-09-18', Amount: '125.00' },
+ displayB: { 'Paid Date': '2026-09-18', Total: '152.00' },
+ differences: [
+ { field: 'amount', fieldA: 'Amount', fieldB: 'Total', valueA: 125.0, valueB: 152.0, delta: 27.0 },
+ ],
+ },
+ {
+ id: 'result-2',
+ status: 'ONLY_A',
+ keyLabel: 'INV-1003',
+ source: { rowA: 483, rowB: null },
+ displayA: { Date: '2026-09-20', Amount: '89.50' },
+ },
+ {
+ id: 'result-3',
+ status: 'ONLY_B',
+ keyLabel: 'INV-1004',
+ source: { rowA: null, rowB: 392 },
+ displayB: { 'Paid Date': '2026-09-22', Total: '45.00' },
+ },
+ {
+ id: 'result-4',
+ status: 'MATCHED',
+ keyLabel: 'INV-1001',
+ source: { rowA: 481, rowB: 390 },
+ displayA: { Date: '2026-09-15', Amount: '125.00' },
+ displayB: { 'Paid Date': '2026-09-15', Total: '125.00' },
+ differences: [],
+ },
+ {
+ id: 'result-5',
+ status: 'DUPLICATE',
+ keyLabel: 'INV-1005',
+ source: { rowA: 484, rowB: 393 },
+ displayA: { Date: '2026-09-21', Amount: '50.00' },
+ displayB: { 'Paid Date': '2026-09-21', Total: '50.00' },
+ },
+ {
+ id: 'result-6',
+ status: 'AMBIGUOUS',
+ keyLabel: 'INV-1006',
+ source: { rowA: 485, rowB: 394 },
+ displayA: { Date: '2026-09-22', Amount: '75.00' },
+ displayB: { 'Paid Date': '2026-09-22', Total: '75.00' },
+ },
+ ];
+ }
+
+ /** @private */
+ _delay(ms) {
+ return new Promise((r) => setTimeout(r, ms));
+ }
+}
\ No newline at end of file
diff --git a/src/styles/base.css b/src/styles/base.css
index e69de29..93c32d9 100644
--- a/src/styles/base.css
+++ b/src/styles/base.css
@@ -0,0 +1,184 @@
+/* ==========================================================================
+ Base — reset, typography, focus
+ ========================================================================== */
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+ scroll-behavior: smooth;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+}
+
+body {
+ font-family: var(--font-sans);
+ font-size: var(--text-base);
+ line-height: var(--leading-base);
+ color: var(--color-text-0);
+ background-color: var(--color-bg-0);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ min-height: 100dvh;
+ overflow-x: hidden;
+}
+
+/* Subtle ambient gradient — premium, calm, no motion */
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+ background:
+ radial-gradient(
+ 1200px 600px at 15% -10%,
+ rgba(110, 231, 168, 0.06),
+ transparent 60%
+ ),
+ radial-gradient(
+ 900px 500px at 90% 0%,
+ rgba(96, 165, 250, 0.05),
+ transparent 55%
+ );
+}
+
+img,
+svg,
+video {
+ display: block;
+ max-width: 100%;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+ color: inherit;
+}
+
+button {
+ cursor: pointer;
+ background: none;
+ border: none;
+}
+
+a {
+ color: var(--color-accent);
+ text-decoration: none;
+}
+
+a:hover {
+ color: var(--color-accent-hi);
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ font-weight: var(--weight-semi);
+ line-height: var(--leading-tight);
+ letter-spacing: var(--tracking-tight);
+ color: var(--color-text-0);
+}
+
+h1 { font-size: var(--text-4xl); }
+h2 { font-size: var(--text-2xl); }
+h3 { font-size: var(--text-xl); }
+h4 { font-size: var(--text-lg); }
+
+p {
+ color: var(--color-text-1);
+}
+
+code,
+pre,
+kbd,
+samp {
+ font-family: var(--font-mono);
+ font-size: 0.9em;
+}
+
+/* ---------- Focus ring — accessible, consistent ---------- */
+:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+ border-radius: var(--radius-sm);
+}
+
+:focus:not(:focus-visible) {
+ outline: none;
+}
+
+/* ---------- Selection ---------- */
+::selection {
+ background-color: rgba(110, 231, 168, 0.28);
+ color: var(--color-text-0);
+}
+
+/* ---------- Scrollbar (webkit) ---------- */
+::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-border-1);
+ border-radius: var(--radius-pill);
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-border-2);
+ background-clip: padding-box;
+}
+
+/* ---------- Reduced motion — global kill switch ---------- */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.001ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.001ms !important;
+ scroll-behavior: auto !important;
+ }
+}
+
+/* ---------- Visually hidden ---------- */
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* ---------- Hidden attribute ---------- */
+[hidden] {
+ display: none !important;
+}
\ No newline at end of file
diff --git a/src/styles/components.css b/src/styles/components.css
index e69de29..6f93480 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -0,0 +1,1922 @@
+/* ==========================================================================
+ Components — Two-Export Comparator
+ shell · header · buttons · cards · badges · toasts · skeletons ·
+ landing · file panel · mapping · comparing · results · drawer
+ ========================================================================== */
+
+/* ==========================================================================
+ App shell
+ ========================================================================== */
+.app-shell {
+ position: relative;
+ z-index: var(--z-base);
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+}
+
+.app-main {
+ flex: 1;
+ padding: var(--space-10) 0 var(--space-16);
+}
+
+/* ==========================================================================
+ App header
+ ========================================================================== */
+.app-header {
+ position: sticky;
+ top: 0;
+ z-index: 50;
+ height: var(--header-height);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-inline: var(--space-6);
+ background: rgba(11, 13, 16, 0.72);
+ backdrop-filter: blur(14px) saturate(1.4);
+ -webkit-backdrop-filter: blur(14px) saturate(1.4);
+ border-bottom: 1px solid var(--color-border-0);
+}
+
+[data-theme='light'] .app-header {
+ background: rgba(250, 251, 252, 0.78);
+}
+
+.app-header__brand {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ font-weight: var(--weight-semi);
+ font-size: var(--text-md);
+ letter-spacing: var(--tracking-tight);
+ color: var(--color-text-0);
+}
+
+.app-header__mark {
+ width: 26px;
+ height: 26px;
+ border-radius: var(--radius-sm);
+ background: linear-gradient(135deg, var(--color-accent), #3b82f6);
+ display: grid;
+ place-items: center;
+ color: #0b0d10;
+ font-weight: var(--weight-bold);
+ font-size: var(--text-xs);
+ box-shadow: var(--shadow-glow-accent);
+}
+
+.app-header__meta {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+}
+
+.app-header__tagline {
+ font-size: var(--text-sm);
+ color: var(--color-text-2);
+}
+
+@media (max-width: 640px) {
+ .app-header {
+ padding-inline: var(--space-4);
+ }
+ .app-header__tagline {
+ display: none;
+ }
+}
+
+/* ==========================================================================
+ Privacy badge
+ ========================================================================== */
+.privacy-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-1) var(--space-3);
+ font-size: var(--text-xs);
+ font-weight: var(--weight-medium);
+ color: var(--color-match);
+ background: var(--tint-match);
+ border: 1px solid rgba(74, 222, 128, 0.22);
+ border-radius: var(--radius-pill);
+ white-space: nowrap;
+}
+
+.privacy-badge__dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--color-match);
+ box-shadow: 0 0 8px rgba(74, 222, 128, 0.7);
+}
+
+/* ==========================================================================
+ Buttons
+ ========================================================================== */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--text-base);
+ font-weight: var(--weight-medium);
+ line-height: 1;
+ border-radius: var(--radius-md);
+ border: 1px solid transparent;
+ transition:
+ background-color var(--dur-fast) var(--ease-standard),
+ border-color var(--dur-fast) var(--ease-standard),
+ color var(--dur-fast) var(--ease-standard),
+ transform var(--dur-fast) var(--ease-standard),
+ box-shadow var(--dur-base) var(--ease-standard);
+ user-select: none;
+ white-space: nowrap;
+}
+
+.btn:active {
+ transform: translateY(1px);
+}
+
+.btn:disabled,
+.btn[aria-disabled='true'] {
+ opacity: 0.45;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.btn--primary {
+ background: var(--color-accent);
+ color: #0b0d10;
+ font-weight: var(--weight-semi);
+ box-shadow: var(--shadow-glow-accent);
+}
+
+.btn--primary:hover:not(:disabled) {
+ background: var(--color-accent-hi);
+ box-shadow:
+ 0 0 0 1px rgba(110, 231, 168, 0.35),
+ 0 10px 36px rgba(110, 231, 168, 0.18);
+}
+
+.btn--secondary {
+ background: var(--color-surface-1);
+ color: var(--color-text-0);
+ border-color: var(--color-border-1);
+}
+
+.btn--secondary:hover:not(:disabled) {
+ background: var(--color-surface-2);
+ border-color: var(--color-border-2);
+}
+
+.btn--ghost {
+ background: transparent;
+ color: var(--color-text-1);
+}
+
+.btn--ghost:hover:not(:disabled) {
+ background: var(--color-surface-1);
+ color: var(--color-text-0);
+}
+
+.btn--danger {
+ background: transparent;
+ color: var(--color-mismatch);
+ border-color: rgba(248, 113, 113, 0.30);
+}
+
+.btn--danger:hover:not(:disabled) {
+ background: var(--tint-mismatch);
+ border-color: rgba(248, 113, 113, 0.55);
+}
+
+.btn--sm {
+ padding: var(--space-1) var(--space-3);
+ font-size: var(--text-sm);
+ border-radius: var(--radius-sm);
+}
+
+.btn--lg {
+ padding: var(--space-3) var(--space-6);
+ font-size: var(--text-md);
+ border-radius: var(--radius-lg);
+}
+
+.btn--icon {
+ padding: var(--space-2);
+ width: 36px;
+ height: 36px;
+}
+
+/* ==========================================================================
+ Cards
+ ========================================================================== */
+.card {
+ background: var(--color-surface-0);
+ border: 1px solid var(--color-border-0);
+ border-radius: var(--radius-lg);
+ padding: var(--space-6);
+ transition:
+ border-color var(--dur-base) var(--ease-standard),
+ background-color var(--dur-base) var(--ease-standard),
+ box-shadow var(--dur-base) var(--ease-standard),
+ transform var(--dur-base) var(--ease-standard);
+}
+
+.card--interactive:hover {
+ border-color: var(--color-border-1);
+ background: var(--color-surface-1);
+ box-shadow: var(--shadow-md);
+}
+
+.card--elevated {
+ background: var(--color-bg-2);
+ border-color: var(--color-border-1);
+ box-shadow: var(--shadow-sm);
+}
+
+/* ==========================================================================
+ Badges
+ ========================================================================== */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: 2px var(--space-2);
+ font-size: var(--text-xs);
+ font-weight: var(--weight-medium);
+ border-radius: var(--radius-sm);
+ white-space: nowrap;
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+}
+
+.badge[data-status='MATCHED'] { color: var(--color-match); background: var(--tint-match); }
+.badge[data-status='MISMATCH'] { color: var(--color-mismatch); background: var(--tint-mismatch); }
+.badge[data-status='ONLY_A'] { color: var(--color-only-a); background: var(--tint-only-a); }
+.badge[data-status='ONLY_B'] { color: var(--color-only-b); background: var(--tint-only-b); }
+.badge[data-status='DUPLICATE'] { color: var(--color-dup); background: var(--tint-dup); }
+.badge[data-status='AMBIGUOUS'] { color: var(--color-ambig); background: var(--tint-ambig); }
+
+/* ==========================================================================
+ Divider
+ ========================================================================== */
+.divider {
+ height: 1px;
+ background: var(--color-border-0);
+ border: none;
+ margin-block: var(--space-6);
+}
+
+/* ==========================================================================
+ Toast
+ ========================================================================== */
+.toast {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border-1);
+ background: var(--color-bg-2);
+ font-size: var(--text-sm);
+ color: var(--color-text-1);
+}
+
+.toast[data-severity='info'] { border-color: rgba(96, 165, 250, 0.35); background: var(--tint-info); }
+.toast[data-severity='warning'] { border-color: rgba(251, 191, 36, 0.35); background: var(--tint-only-a); }
+.toast[data-severity='error'] { border-color: rgba(248, 113, 113, 0.35); background: var(--tint-mismatch); }
+
+/* ==========================================================================
+ Landing
+ ========================================================================== */
+.landing {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--space-16) var(--space-6) var(--space-12);
+ gap: var(--space-12);
+}
+
+.landing__hero {
+ max-width: 640px;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ animation: landing-rise var(--dur-slower) var(--ease-out) both;
+}
+
+.landing__title {
+ font-size: clamp(1.75rem, 3.5vw + 1rem, 2.75rem);
+ font-weight: var(--weight-semi);
+ line-height: var(--leading-tight);
+ letter-spacing: -0.02em;
+ color: var(--color-text-0);
+}
+
+.landing__title-accent {
+ background: linear-gradient(135deg, var(--color-accent) 0%, #60a5fa 100%);
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ -webkit-text-fill-color: transparent;
+}
+
+.landing__subtitle {
+ font-size: var(--text-lg);
+ color: var(--color-text-2);
+ line-height: var(--leading-snug);
+}
+
+.landing__workflow {
+ width: 100%;
+ max-width: var(--container-max);
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: stretch;
+ gap: var(--space-6);
+ animation: landing-rise var(--dur-slower) var(--ease-out) 80ms both;
+}
+
+.landing__connector {
+ align-self: center;
+ color: var(--color-text-3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--color-surface-1);
+ border: 1px solid var(--color-border-0);
+}
+
+.landing__footer {
+ display: flex;
+ justify-content: center;
+ animation: landing-rise var(--dur-slower) var(--ease-out) 160ms both;
+}
+
+@keyframes landing-rise {
+ from {
+ opacity: 0;
+ transform: translateY(12px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (max-width: 860px) {
+ .landing {
+ padding: var(--space-10) var(--space-4) var(--space-8);
+ gap: var(--space-8);
+ }
+ .landing__workflow {
+ grid-template-columns: 1fr;
+ }
+ .landing__connector {
+ transform: rotate(90deg);
+ margin-inline: auto;
+ }
+}
+
+/* ==========================================================================
+ FilePanel
+ ========================================================================== */
+.file-panel {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ background: var(--color-surface-0);
+ border: 1px solid var(--color-border-0);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ transition:
+ border-color var(--dur-base) var(--ease-standard),
+ background-color var(--dur-base) var(--ease-standard),
+ box-shadow var(--dur-base) var(--ease-standard);
+ min-height: 280px;
+}
+
+.file-panel[data-state='empty']:hover {
+ border-color: var(--color-border-1);
+}
+
+.file-panel[data-state='ready'] {
+ border-color: rgba(110, 231, 168, 0.20);
+}
+
+.file-panel[data-state='warning'] {
+ border-color: rgba(251, 191, 36, 0.28);
+}
+
+.file-panel[data-state='error'] {
+ border-color: rgba(248, 113, 113, 0.30);
+}
+
+.file-panel__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ padding: var(--space-4) var(--space-5);
+ border-bottom: 1px solid var(--color-border-0);
+ background: var(--color-surface-0);
+}
+
+.file-panel[data-state='empty'] .file-panel__header {
+ border-bottom-color: transparent;
+}
+
+.file-panel__label {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-3);
+ min-width: 0;
+ flex: 1;
+}
+
+.file-panel__slot {
+ font-size: var(--text-xs);
+ font-weight: var(--weight-bold);
+ letter-spacing: 0.08em;
+ color: var(--color-text-3);
+ white-space: nowrap;
+}
+
+.file-panel__name {
+ font-size: var(--text-base);
+ font-weight: var(--weight-medium);
+ color: var(--color-text-0);
+ min-width: 0;
+}
+
+.file-panel__name--empty {
+ color: var(--color-text-3);
+ font-weight: var(--weight-regular);
+}
+
+/* ---------- Dropzone ---------- */
+.file-panel__dropzone {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-4);
+ margin: var(--space-4);
+ padding: var(--space-8) var(--space-6);
+ background: transparent;
+ border: 1.5px dashed var(--color-border-1);
+ border-radius: var(--radius-md);
+ color: var(--color-text-2);
+ text-align: center;
+ transition:
+ border-color var(--dur-base) var(--ease-standard),
+ background-color var(--dur-base) var(--ease-standard),
+ color var(--dur-base) var(--ease-standard),
+ transform var(--dur-base) var(--ease-standard);
+}
+
+.file-panel__dropzone:hover,
+.file-panel__dropzone[data-dragover='true'] {
+ border-color: var(--color-accent);
+ background: rgba(110, 231, 168, 0.04);
+ color: var(--color-text-0);
+}
+
+.file-panel__dropzone[data-dragover='true'] {
+ transform: scale(1.01);
+ box-shadow: 0 0 0 4px rgba(110, 231, 168, 0.08);
+}
+
+.file-panel__dropzone-icon {
+ width: 44px;
+ height: 44px;
+ border-radius: var(--radius-md);
+ display: grid;
+ place-items: center;
+ background: var(--color-surface-2);
+ color: var(--color-text-1);
+ transition:
+ background-color var(--dur-base) var(--ease-standard),
+ color var(--dur-base) var(--ease-standard);
+}
+
+.file-panel__dropzone:hover .file-panel__dropzone-icon,
+.file-panel__dropzone[data-dragover='true'] .file-panel__dropzone-icon {
+ background: rgba(110, 231, 168, 0.12);
+ color: var(--color-accent);
+}
+
+.file-panel__dropzone-text {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.file-panel__dropzone-primary {
+ font-size: var(--text-base);
+ font-weight: var(--weight-medium);
+ color: var(--color-text-0);
+}
+
+.file-panel__dropzone-secondary {
+ font-size: var(--text-sm);
+ color: var(--color-text-3);
+}
+
+/* ---------- FilePanel body ---------- */
+.file-panel__body {
+ padding: var(--space-5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ flex: 1;
+}
+
+.file-panel__meta {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
+ gap: var(--space-3);
+ padding-bottom: var(--space-4);
+ border-bottom: 1px solid var(--color-border-0);
+}
+
+.file-panel__meta-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.file-panel__meta-label {
+ font-size: var(--text-xs);
+ color: var(--color-text-3);
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+ font-weight: var(--weight-medium);
+}
+
+.file-panel__meta-value {
+ font-size: var(--text-base);
+ color: var(--color-text-0);
+ font-weight: var(--weight-medium);
+ font-variant-numeric: tabular-nums;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.file-panel__warnings {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+/* ---------- Preview ---------- */
+.file-panel__preview {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ min-width: 0;
+}
+
+.file-panel__preview-label {
+ letter-spacing: var(--tracking-wide);
+ font-weight: var(--weight-semi);
+}
+
+.file-panel__preview-scroll {
+ overflow: auto;
+ border: 1px solid var(--color-border-0);
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-1);
+ max-height: 180px;
+}
+
+.file-panel__preview-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--text-xs);
+ font-family: var(--font-mono);
+}
+
+.file-panel__preview-table th,
+.file-panel__preview-table td {
+ padding: var(--space-2) var(--space-3);
+ text-align: left;
+ white-space: nowrap;
+ border-bottom: 1px solid var(--color-border-0);
+ max-width: 180px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.file-panel__preview-table th {
+ position: sticky;
+ top: 0;
+ background: var(--color-bg-2);
+ color: var(--color-text-2);
+ font-weight: var(--weight-semi);
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+ font-size: 10px;
+ z-index: 1;
+}
+
+.file-panel__preview-table td {
+ color: var(--color-text-1);
+}
+
+.file-panel__preview-table tr:last-child td {
+ border-bottom: none;
+}
+
+.file-panel__preview-foot {
+ text-align: right;
+}
+
+/* ---------- Skeletons ---------- */
+.file-panel__body--loading {
+ gap: var(--space-3);
+}
+
+.skeleton {
+ background: linear-gradient(
+ 90deg,
+ var(--color-surface-1) 0%,
+ var(--color-surface-2) 50%,
+ var(--color-surface-1) 100%
+ );
+ background-size: 200% 100%;
+ border-radius: var(--radius-sm);
+ animation: skeleton-shimmer 1.4s var(--ease-standard) infinite;
+}
+
+.skeleton--line {
+ height: 12px;
+}
+
+.skeleton--block {
+ height: 120px;
+ border-radius: var(--radius-md);
+}
+
+.skeleton--w40 { width: 40%; }
+.skeleton--w60 { width: 60%; }
+.skeleton--w80 { width: 80%; }
+
+@keyframes skeleton-shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .skeleton {
+ animation: none;
+ background: var(--color-surface-2);
+ }
+}
+
+/* ==========================================================================
+ PrivacyNote
+ ========================================================================== */
+.privacy-note {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--text-sm);
+ color: var(--color-text-2);
+ background: var(--color-surface-0);
+ border: 1px solid var(--color-border-0);
+ border-radius: var(--radius-pill);
+}
+
+.privacy-note svg {
+ color: var(--color-match);
+ flex-shrink: 0;
+}
+
+/* ==========================================================================
+ ToastHost
+ ========================================================================== */
+.toast-host {
+ position: fixed;
+ bottom: var(--space-6);
+ right: var(--space-6);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ z-index: var(--z-toast);
+ pointer-events: none;
+ max-width: min(380px, calc(100vw - var(--space-8)));
+}
+
+.toast--floating {
+ pointer-events: auto;
+ background: var(--color-bg-2);
+ box-shadow: var(--shadow-lg);
+ animation: toast-in var(--dur-slow) var(--ease-out) both;
+}
+
+.toast__body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+@keyframes toast-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@media (max-width: 640px) {
+ .toast-host {
+ bottom: var(--space-4);
+ right: var(--space-4);
+ left: var(--space-4);
+ max-width: none;
+ }
+}
+
+/* ==========================================================================
+ Form controls
+ ========================================================================== */
+.select,
+.input {
+ width: 100%;
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--text-base);
+ color: var(--color-text-0);
+ background: var(--color-bg-2);
+ border: 1px solid var(--color-border-1);
+ border-radius: var(--radius-md);
+ transition:
+ border-color var(--dur-fast) var(--ease-standard),
+ background-color var(--dur-fast) var(--ease-standard),
+ box-shadow var(--dur-fast) var(--ease-standard);
+ appearance: none;
+ -webkit-appearance: none;
+}
+
+.select {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' stroke='%238b939e' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right var(--space-3) center;
+ background-size: 12px;
+ padding-right: var(--space-8);
+}
+
+.select:hover,
+.input:hover {
+ border-color: var(--color-border-2);
+}
+
+.select:focus,
+.input:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px rgba(110, 231, 168, 0.15);
+}
+
+.select--sm,
+.input--sm {
+ padding: var(--space-1) var(--space-2);
+ font-size: var(--text-sm);
+ border-radius: var(--radius-sm);
+ width: auto;
+}
+
+.input--sm {
+ width: 80px;
+}
+
+/* ==========================================================================
+ Toggle
+ ========================================================================== */
+.toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-3);
+ cursor: pointer;
+ user-select: none;
+}
+
+.toggle input {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.toggle__track {
+ position: relative;
+ width: 34px;
+ height: 20px;
+ border-radius: var(--radius-pill);
+ background: var(--color-bg-4);
+ border: 1px solid var(--color-border-1);
+ transition:
+ background-color var(--dur-base) var(--ease-standard),
+ border-color var(--dur-base) var(--ease-standard);
+ flex-shrink: 0;
+}
+
+.toggle__thumb {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--color-text-2);
+ transition:
+ transform var(--dur-base) var(--ease-emphasis),
+ background-color var(--dur-base) var(--ease-standard);
+}
+
+.toggle input:checked + .toggle__track {
+ background: rgba(110, 231, 168, 0.20);
+ border-color: rgba(110, 231, 168, 0.55);
+}
+
+.toggle input:checked + .toggle__track .toggle__thumb {
+ transform: translateX(14px);
+ background: var(--color-accent);
+}
+
+.toggle input:focus-visible + .toggle__track {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+}
+
+.toggle__label {
+ font-size: var(--text-base);
+ color: var(--color-text-1);
+}
+
+/* ==========================================================================
+ Mapping workspace
+ ========================================================================== */
+.mapping-workspace {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-8);
+ animation: fade-rise var(--dur-slow) var(--ease-out) both;
+}
+
+.mapping-workspace__header {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ max-width: 720px;
+}
+
+.mapping-workspace__title {
+ font-size: var(--text-2xl);
+}
+
+.mapping-workspace__hint {
+ color: var(--color-text-2);
+ font-size: var(--text-md);
+}
+
+.mapping-workspace__col-heads {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ gap: var(--space-4);
+ padding-bottom: var(--space-3);
+ border-bottom: 1px solid var(--color-border-0);
+}
+
+.mapping-workspace__col-head {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.mapping-workspace__col-head--spacer {
+ width: 32px;
+}
+
+.mapping-workspace__col-label {
+ font-size: var(--text-xs);
+ font-weight: var(--weight-bold);
+ letter-spacing: 0.08em;
+ color: var(--color-text-3);
+}
+
+.mapping-workspace__col-name {
+ font-size: var(--text-sm);
+ color: var(--color-text-1);
+ font-family: var(--font-mono);
+}
+
+.mapping-workspace__group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.mapping-workspace__group-head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+
+.mapping-workspace__group-title {
+ font-size: var(--text-lg);
+}
+
+.mapping-workspace__group-hint {
+ flex: 1;
+}
+
+.mapping-workspace__rows {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.mapping-workspace__empty {
+ padding: var(--space-6);
+ text-align: center;
+ color: var(--color-text-3);
+ font-size: var(--text-sm);
+ border: 1px dashed var(--color-border-1);
+ border-radius: var(--radius-md);
+}
+
+.mapping-workspace__summary {
+ cursor: pointer;
+ list-style: none;
+}
+
+.mapping-workspace__summary::-webkit-details-marker {
+ display: none;
+}
+
+.mapping-workspace__summary::before {
+ content: '▸';
+ color: var(--color-text-3);
+ transition: transform var(--dur-fast) var(--ease-standard);
+ display: inline-block;
+}
+
+.mapping-workspace__group--collapsible[open] .mapping-workspace__summary::before {
+ transform: rotate(90deg);
+}
+
+.mapping-workspace__normalization {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-6) var(--space-8);
+ padding: var(--space-4) var(--space-5);
+ background: var(--color-surface-0);
+ border: 1px solid var(--color-border-0);
+ border-radius: var(--radius-md);
+}
+
+.mapping-workspace__actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-4);
+ padding-top: var(--space-6);
+ border-top: 1px solid var(--color-border-0);
+}
+
+@media (max-width: 720px) {
+ .mapping-workspace__col-heads {
+ grid-template-columns: 1fr;
+ }
+ .mapping-workspace__col-head--spacer {
+ display: none;
+ }
+ .mapping-workspace__actions {
+ flex-direction: column-reverse;
+ }
+ .mapping-workspace__actions .btn {
+ width: 100%;
+ }
+}
+
+/* ==========================================================================
+ Mapping row
+ ========================================================================== */
+.mapping-row {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding: var(--space-4);
+ background: var(--color-surface-0);
+ border: 1px solid var(--color-border-0);
+ border-radius: var(--radius-md);
+ transition:
+ border-color var(--dur-base) var(--ease-standard),
+ background-color var(--dur-base) var(--ease-standard);
+ animation: fade-rise var(--dur-base) var(--ease-out) both;
+}
+
+.mapping-row:hover {
+ border-color: var(--color-border-1);
+}
+
+.mapping-row[data-role='key'] {
+ border-left: 3px solid var(--color-accent);
+}
+
+.mapping-row[data-role='compare'] {
+ border-left: 3px solid var(--color-info);
+}
+
+.mapping-row__role {
+ display: flex;
+ align-items: center;
+}
+
+.mapping-row__role-chip {
+ font-size: 10px;
+ font-weight: var(--weight-bold);
+ letter-spacing: 0.08em;
+ padding: 2px var(--space-2);
+ border-radius: var(--radius-xs);
+ text-transform: uppercase;
+}
+
+.mapping-row__role-chip--key {
+ color: var(--color-accent);
+ background: rgba(110, 231, 168, 0.10);
+}
+
+.mapping-row__role-chip--compare {
+ color: var(--color-info);
+ background: var(--tint-info);
+}
+
+.mapping-row__role-chip--display {
+ color: var(--color-text-2);
+ background: var(--color-surface-2);
+}
+
+.mapping-row__body {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr auto auto;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.mapping-row__arrow {
+ color: var(--color-text-3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--color-surface-1);
+}
+
+.mapping-row__tolerance {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.mapping-row__remove {
+ opacity: 0.5;
+ transition: opacity var(--dur-fast) var(--ease-standard);
+}
+
+.mapping-row:hover .mapping-row__remove {
+ opacity: 1;
+}
+
+@media (max-width: 720px) {
+ .mapping-row__body {
+ grid-template-columns: 1fr;
+ gap: var(--space-2);
+ }
+ .mapping-row__arrow {
+ transform: rotate(90deg);
+ justify-self: center;
+ }
+ .mapping-row__remove {
+ align-self: flex-end;
+ }
+}
+
+/* ==========================================================================
+ Comparing
+ ========================================================================== */
+.comparing {
+ max-width: 560px;
+ margin: var(--space-16) auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-4);
+ text-align: center;
+}
+
+.comparing__spinner {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ border: 2px solid var(--color-border-1);
+ border-top-color: var(--color-accent);
+ animation: spin 900ms linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .comparing__spinner { animation: none; }
+}
+
+.comparing__title {
+ font-size: var(--text-xl);
+}
+
+.comparing__subtitle {
+ font-size: var(--text-sm);
+}
+
+.comparing__bar {
+ width: 100%;
+ height: 6px;
+ border-radius: var(--radius-pill);
+ background: var(--color-surface-2);
+ overflow: hidden;
+ position: relative;
+}
+
+.comparing__bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--color-accent), #60a5fa);
+ border-radius: var(--radius-pill);
+ transition: width var(--dur-slow) var(--ease-standard);
+}
+
+.comparing__bar-fill[data-indeterminate='true'] {
+ width: 40%;
+ animation: indeterminate 1.6s var(--ease-standard) infinite;
+}
+
+@keyframes indeterminate {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(250%); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .comparing__bar-fill[data-indeterminate='true'] {
+ animation: none;
+ width: 100%;
+ opacity: 0.4;
+ }
+}
+
+.comparing__phases {
+ list-style: none;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: var(--space-2) var(--space-4);
+ margin-top: var(--space-4);
+}
+
+.comparing__phase {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--text-sm);
+ color: var(--color-text-3);
+ transition: color var(--dur-base) var(--ease-standard);
+}
+
+.comparing__phase[data-state='active'] { color: var(--color-text-0); }
+.comparing__phase[data-state='done'] { color: var(--color-match); }
+
+.comparing__phase-dot {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ border: 1.5px solid currentColor;
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+}
+
+.comparing__phase[data-state='active'] .comparing__phase-dot {
+ background: var(--color-accent);
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 4px rgba(110, 231, 168, 0.15);
+}
+
+.comparing__phase[data-state='done'] .comparing__phase-dot {
+ background: var(--color-match);
+ border-color: var(--color-match);
+ color: #0b0d10;
+}
+
+@keyframes fade-rise {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* ==========================================================================
+ Results dashboard
+ ========================================================================== */
+.results-header {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--space-4);
+ flex-wrap: wrap;
+ margin-bottom: var(--space-6);
+}
+
+.results-header__title {
+ font-size: var(--text-2xl);
+}
+
+.results-header__sub {
+ font-size: var(--text-sm);
+ margin-top: var(--space-1);
+}
+
+.results-header__actions {
+ display: flex;
+ gap: var(--space-2);
+}
+
+/* ---------- Summary grid ---------- */
+.summary-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: var(--space-3);
+ margin-bottom: var(--space-6);
+}
+
+.summary-card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space-1);
+ padding: var(--space-4);
+ background: var(--color-surface-0);
+ border: 1px solid var(--color-border-0);
+ border-radius: var(--radius-md);
+ text-align: left;
+ overflow: hidden;
+ transition:
+ border-color var(--dur-base) var(--ease-standard),
+ background-color var(--dur-base) var(--ease-standard),
+ transform var(--dur-base) var(--ease-standard);
+}
+
+.summary-card:hover {
+ border-color: var(--color-border-1);
+ transform: translateY(-1px);
+}
+
+.summary-card[data-active='true'] {
+ background: var(--color-surface-1);
+ border-color: var(--color-border-2);
+}
+
+.summary-card__count {
+ font-size: var(--text-2xl);
+ font-weight: var(--weight-semi);
+ font-variant-numeric: tabular-nums;
+ color: var(--color-text-0);
+ line-height: 1;
+}
+
+.summary-card__label {
+ font-size: var(--text-xs);
+ font-weight: var(--weight-semi);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--color-text-2);
+}
+
+.summary-card__bar {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 2px;
+ background: transparent;
+}
+
+.summary-card[data-status='MISMATCH'] .summary-card__bar { background: var(--color-mismatch); }
+.summary-card[data-status='ONLY_A'] .summary-card__bar { background: var(--color-only-a); }
+.summary-card[data-status='ONLY_B'] .summary-card__bar { background: var(--color-only-b); }
+.summary-card[data-status='DUPLICATE'] .summary-card__bar { background: var(--color-dup); }
+.summary-card[data-status='AMBIGUOUS'] .summary-card__bar { background: var(--color-ambig); }
+.summary-card[data-status='MATCHED'] .summary-card__bar { background: var(--color-match); }
+.summary-card[data-status='ALL'] .summary-card__bar { background: var(--color-text-3); }
+
+.summary-card[data-active='true'][data-status='MISMATCH'] { border-color: rgba(248, 113, 113, 0.55); }
+.summary-card[data-active='true'][data-status='ONLY_A'] { border-color: rgba(251, 191, 36, 0.55); }
+.summary-card[data-active='true'][data-status='ONLY_B'] { border-color: rgba(251, 146, 60, 0.55); }
+.summary-card[data-active='true'][data-status='DUPLICATE'] { border-color: rgba(167, 139, 250, 0.55); }
+.summary-card[data-active='true'][data-status='AMBIGUOUS'] { border-color: rgba(250, 204, 21, 0.55); }
+.summary-card[data-active='true'][data-status='MATCHED'] { border-color: rgba(74, 222, 128, 0.55); }
+
+/* ---------- Results table ---------- */
+.results-table {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ background: var(--color-surface-0);
+ border: 1px solid var(--color-border-0);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+.results-table__scroll {
+ overflow: auto;
+ max-height: 60vh;
+}
+
+.results-table__table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--text-base);
+}
+
+.results-table__table th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ text-align: left;
+ padding: var(--space-3) var(--space-4);
+ font-size: var(--text-xs);
+ font-weight: var(--weight-semi);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--color-text-3);
+ background: var(--color-bg-2);
+ border-bottom: 1px solid var(--color-border-0);
+}
+
+.results-table__table td {
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--color-border-0);
+ color: var(--color-text-1);
+}
+
+.results-table__row {
+ cursor: pointer;
+ transition: background-color var(--dur-fast) var(--ease-standard);
+}
+
+.results-table__row:hover,
+.results-table__row:focus-visible {
+ background: var(--color-surface-1);
+ outline: none;
+}
+
+.results-table__row[data-status='MISMATCH'] { box-shadow: inset 3px 0 0 var(--color-mismatch); }
+.results-table__row[data-status='ONLY_A'] { box-shadow: inset 3px 0 0 var(--color-only-a); }
+.results-table__row[data-status='ONLY_B'] { box-shadow: inset 3px 0 0 var(--color-only-b); }
+.results-table__row[data-status='DUPLICATE'] { box-shadow: inset 3px 0 0 var(--color-dup); }
+.results-table__row[data-status='AMBIGUOUS'] { box-shadow: inset 3px 0 0 var(--color-ambig); }
+.results-table__row[data-status='MATCHED'] { box-shadow: inset 3px 0 0 var(--color-match); }
+
+.results-table__key {
+ color: var(--color-text-0);
+ font-weight: var(--weight-medium);
+}
+
+.results-table__chevron {
+ color: var(--color-text-3);
+ text-align: right;
+ width: 40px;
+}
+
+.results-table__row:hover .results-table__chevron {
+ color: var(--color-accent);
+}
+
+.results-table__pager {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ border-top: 1px solid var(--color-border-0);
+ background: var(--color-surface-0);
+}
+
+.results-table__empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: var(--space-10);
+ color: var(--color-text-3);
+ font-size: var(--text-sm);
+}
+
+/* ==========================================================================
+ Record drawer
+ ========================================================================== */
+.record-drawer {
+ position: fixed;
+ inset: 0;
+ z-index: var(--z-drawer);
+ display: flex;
+ justify-content: flex-end;
+}
+
+.record-drawer__backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(2px);
+ animation: fade-in var(--dur-base) var(--ease-standard) both;
+}
+
+.record-drawer__panel {
+ position: relative;
+ width: min(640px, 100%);
+ height: 100%;
+ background: var(--color-bg-1);
+ border-left: 1px solid var(--color-border-1);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5);
+ padding: var(--space-6);
+ overflow-y: auto;
+ animation: slide-in var(--dur-slow) var(--ease-out) both;
+}
+
+@keyframes fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slide-in {
+ from { transform: translateX(16px); opacity: 0; }
+ to { transform: translateX(0); opacity: 1; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .record-drawer__backdrop,
+ .record-drawer__panel { animation: none; }
+}
+
+.record-drawer__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+}
+
+.record-drawer__head-main {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ min-width: 0;
+}
+
+.record-drawer__key {
+ font-size: var(--text-md);
+ font-weight: var(--weight-medium);
+ color: var(--color-text-0);
+}
+
+.record-drawer__explain {
+ font-size: var(--text-sm);
+ color: var(--color-text-2);
+ padding: var(--space-3) var(--space-4);
+ background: var(--color-surface-0);
+ border: 1px solid var(--color-border-0);
+ border-radius: var(--radius-md);
+}
+
+.record-drawer__grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-4);
+}
+
+@media (max-width: 640px) {
+ .record-drawer__grid { grid-template-columns: 1fr; }
+ .record-drawer__panel { padding: var(--space-4); }
+}
+
+.record-drawer__side {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.record-drawer__side-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--space-2);
+ padding-bottom: var(--space-2);
+ border-bottom: 1px solid var(--color-border-0);
+}
+
+.record-drawer__side-title {
+ font-size: var(--text-sm);
+ font-weight: var(--weight-semi);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--color-text-2);
+}
+
+.record-drawer__fields {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.record-drawer__field {
+ display: grid;
+ grid-template-columns: 40% 1fr;
+ gap: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface-0);
+ border: 1px solid transparent;
+}
+
+.record-drawer__field[data-diff='true'] {
+ background: var(--tint-mismatch);
+ border-color: rgba(248, 113, 113, 0.30);
+}
+
+.record-drawer__field-label {
+ font-size: var(--text-xs);
+ color: var(--color-text-3);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ font-weight: var(--weight-semi);
+ align-self: center;
+}
+
+.record-drawer__field-value {
+ font-size: var(--text-sm);
+ color: var(--color-text-0);
+ word-break: break-word;
+}
+
+.record-drawer__diffs {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding-top: var(--space-4);
+ border-top: 1px solid var(--color-border-0);
+}
+
+.record-drawer__diffs-title {
+ font-size: var(--text-sm);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-text-3);
+}
+
+.record-drawer__diffs-list {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.record-drawer__diff {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ background: var(--color-surface-0);
+ border: 1px solid var(--color-border-0);
+ border-radius: var(--radius-md);
+ flex-wrap: wrap;
+}
+
+.record-drawer__diff-field {
+ font-size: var(--text-sm);
+ color: var(--color-text-2);
+ min-width: 80px;
+}
+
+.record-drawer__diff-values {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+}
+
+.record-drawer__diff-a { color: var(--color-mismatch); }
+.record-drawer__diff-b { color: var(--color-match); }
+.record-drawer__diff-arrow { color: var(--color-text-3); }
+
+.record-drawer__diff-delta {
+ margin-left: auto;
+ font-size: var(--text-xs);
+ color: var(--color-text-3);
+}
+
+/* ==========================================================================
+ Skip link
+ ========================================================================== */
+.skip-link {
+ position: absolute;
+ top: -100px;
+ left: var(--space-4);
+ padding: var(--space-2) var(--space-4);
+ background: var(--color-accent);
+ color: #0b0d10;
+ border-radius: var(--radius-md);
+ font-weight: var(--weight-semi);
+ text-decoration: none;
+ z-index: 1000;
+ transition: top var(--dur-base) var(--ease-standard);
+}
+
+.skip-link:focus {
+ top: var(--space-4);
+}
+
+/* ==========================================================================
+ Responsive refinements
+ ========================================================================== */
+@media (max-width: 860px) {
+ .landing__title { font-size: 1.75rem; }
+ .results-header { flex-direction: column; align-items: stretch; }
+ .results-header__actions { width: 100%; }
+ .results-header__actions .btn { flex: 1; }
+ .summary-grid { grid-template-columns: repeat(2, 1fr); }
+}
+
+@media (max-width: 480px) {
+ .app-header__brand span:last-child { font-size: var(--text-sm); }
+ .summary-grid { grid-template-columns: 1fr; }
+}
+
+/* ==========================================================================
+ Language switcher
+ ========================================================================== */
+.language-switcher {
+ display: inline-flex;
+ align-items: center;
+}
+
+.language-switcher__select {
+ appearance: none;
+ min-width: 54px;
+ height: 30px;
+ padding: 0 22px 0 9px;
+ border: 1px solid var(--color-border-1);
+ border-radius: var(--radius-sm);
+ background-color: var(--color-surface-0);
+ color: var(--color-text-1);
+ font: inherit;
+ font-size: var(--text-xs);
+ font-weight: var(--weight-semi);
+ cursor: pointer;
+ background-image:
+ linear-gradient(45deg, transparent 50%, var(--color-text-3) 50%),
+ linear-gradient(135deg, var(--color-text-3) 50%, transparent 50%);
+ background-position:
+ calc(100% - 10px) 12px,
+ calc(100% - 6px) 12px;
+ background-size: 4px 4px, 4px 4px;
+ background-repeat: no-repeat;
+}
+
+.language-switcher__select:hover {
+ border-color: var(--color-border-2);
+}
+
+.language-switcher__select:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+}
+
+
+/* ==========================================================================
+ Visual diff — primary product experience
+ ========================================================================== */
+.visual-results {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+
+.visual-results__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: var(--space-6);
+ flex-wrap: wrap;
+}
+
+.visual-results__heading {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.visual-results__auto {
+ width: fit-content;
+ padding: 3px var(--space-2);
+ border-radius: var(--radius-pill);
+ background: var(--tint-info);
+ color: var(--color-info);
+ font-size: var(--text-xs);
+ font-weight: var(--weight-semi);
+}
+
+.visual-results__title {
+ font-size: var(--text-3xl);
+}
+
+.visual-results__subtitle {
+ color: var(--color-text-2);
+ max-width: 720px;
+}
+
+.visual-results__actions {
+ display: flex;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+
+.visual-diff {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.visual-diff__pair {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 120px minmax(0, 1fr);
+ gap: var(--space-3);
+ align-items: stretch;
+}
+
+.visual-diff__side {
+ min-width: 0;
+ padding: var(--space-4);
+ border: 1px solid var(--color-border-0);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface-0);
+}
+
+.visual-diff__pair[data-tone='matched'] .visual-diff__side {
+ border-color: rgba(96, 165, 250, 0.18);
+ background: rgba(96, 165, 250, 0.035);
+}
+
+.visual-diff__pair[data-tone='attention'] .visual-diff__side {
+ border-color: rgba(248, 113, 113, 0.36);
+ background: rgba(248, 113, 113, 0.055);
+}
+
+.visual-diff__side--missing {
+ display: grid;
+ place-items: center;
+ min-height: 130px;
+ border-style: dashed;
+ color: var(--color-mismatch);
+ background: rgba(248, 113, 113, 0.04);
+}
+
+.visual-diff__missing-label {
+ font-size: var(--text-sm);
+ font-weight: var(--weight-semi);
+}
+
+.visual-diff__side-head {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--space-3);
+ color: var(--color-text-3);
+ font-size: var(--text-xs);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.visual-diff__record-key {
+ margin-top: var(--space-2);
+ color: var(--color-text-0);
+ font-family: var(--font-mono);
+ font-weight: var(--weight-semi);
+}
+
+.visual-diff__fields {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: var(--space-2);
+ margin-top: var(--space-4);
+}
+
+.visual-diff__field {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface-1);
+}
+
+.visual-diff__field[data-changed='true'] {
+ background: rgba(248, 113, 113, 0.14);
+ box-shadow: inset 0 0 0 1px rgba(248, 113, 113, 0.38);
+}
+
+.visual-diff__field-name {
+ color: var(--color-text-3);
+ font-size: var(--text-xs);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.visual-diff__field-value {
+ color: var(--color-text-0);
+ font-family: var(--font-mono);
+ word-break: break-word;
+}
+
+.visual-diff__status {
+ align-self: center;
+ justify-self: stretch;
+ padding: var(--space-2);
+ border-radius: var(--radius-pill);
+ text-align: center;
+ font-size: var(--text-xs);
+ font-weight: var(--weight-semi);
+ color: var(--color-text-2);
+ background: var(--color-surface-1);
+}
+
+.visual-diff__status[data-status='MATCHED'] {
+ color: var(--color-info);
+ background: var(--tint-info);
+}
+
+.visual-diff__status[data-status='MISMATCH'],
+.visual-diff__status[data-status='ONLY_A'],
+.visual-diff__status[data-status='ONLY_B'] {
+ color: var(--color-mismatch);
+ background: var(--tint-mismatch);
+}
+
+.visual-diff__status[data-status='DUPLICATE'],
+.visual-diff__status[data-status='AMBIGUOUS'] {
+ color: var(--color-ambig);
+ background: var(--tint-ambig);
+}
+
+@media (max-width: 860px) {
+ .visual-diff__pair {
+ grid-template-columns: 1fr;
+ }
+
+ .visual-diff__status {
+ width: fit-content;
+ justify-self: center;
+ }
+
+ .visual-results__actions {
+ width: 100%;
+ }
+}
+
+@media (max-width: 560px) {
+ .visual-results__actions .btn {
+ flex: 1 1 100%;
+ }
+
+ .visual-diff__fields {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
index e69de29..332894c 100644
--- a/src/styles/tokens.css
+++ b/src/styles/tokens.css
@@ -0,0 +1,164 @@
+/* ==========================================================================
+ Design Tokens — Two-Export Comparator
+ Dark-first, premium, calm. Graphite foundation, restrained accents.
+ ========================================================================== */
+
+:root {
+ /* ---------- Color: Graphite foundation ---------- */
+ --color-bg-0: #0b0d10;
+ --color-bg-1: #101317;
+ --color-bg-2: #161a20;
+ --color-bg-3: #1c2128;
+ --color-bg-4: #232a33;
+
+ --color-surface-0: rgba(255, 255, 255, 0.02);
+ --color-surface-1: rgba(255, 255, 255, 0.04);
+ --color-surface-2: rgba(255, 255, 255, 0.06);
+ --color-surface-3: rgba(255, 255, 255, 0.09);
+
+ --color-border-0: rgba(255, 255, 255, 0.06);
+ --color-border-1: rgba(255, 255, 255, 0.10);
+ --color-border-2: rgba(255, 255, 255, 0.16);
+
+ /* ---------- Text ---------- */
+ --color-text-0: #f4f6f8;
+ --color-text-1: #c8cdd4;
+ --color-text-2: #8b939e;
+ --color-text-3: #5c6470;
+
+ /* ---------- Semantic accents ---------- */
+ --color-match: #4ade80;
+ --color-mismatch: #f87171;
+ --color-only-a: #fbbf24;
+ --color-only-b: #fb923c;
+ --color-dup: #a78bfa;
+ --color-ambig: #facc15;
+ --color-info: #60a5fa;
+
+ --color-accent: #6ee7a8;
+ --color-accent-hi: #8cf0bd;
+
+ /* Soft tints for backgrounds */
+ --tint-match: rgba(74, 222, 128, 0.10);
+ --tint-mismatch: rgba(248, 113, 113, 0.10);
+ --tint-only-a: rgba(251, 191, 36, 0.10);
+ --tint-only-b: rgba(251, 146, 60, 0.10);
+ --tint-dup: rgba(167, 139, 250, 0.10);
+ --tint-ambig: rgba(250, 204, 21, 0.10);
+ --tint-info: rgba(96, 165, 250, 0.10);
+
+ /* ---------- Spacing scale (4px base) ---------- */
+ --space-0: 0;
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+ --space-16: 4rem;
+ --space-20: 5rem;
+
+ /* ---------- Radius ---------- */
+ --radius-xs: 4px;
+ --radius-sm: 6px;
+ --radius-md: 10px;
+ --radius-lg: 14px;
+ --radius-xl: 20px;
+ --radius-pill: 999px;
+
+ /* ---------- Typography ---------- */
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
+ "Helvetica Neue", Arial, "Noto Sans", sans-serif;
+ --font-mono: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono",
+ Menlo, Consolas, monospace;
+
+ --text-xs: 0.75rem;
+ --text-sm: 0.8125rem;
+ --text-base: 0.875rem;
+ --text-md: 0.9375rem;
+ --text-lg: 1.0625rem;
+ --text-xl: 1.25rem;
+ --text-2xl: 1.5rem;
+ --text-3xl: 1.875rem;
+ --text-4xl: 2.25rem;
+
+ --weight-regular: 400;
+ --weight-medium: 500;
+ --weight-semi: 600;
+ --weight-bold: 700;
+
+ --leading-tight: 1.2;
+ --leading-snug: 1.35;
+ --leading-base: 1.5;
+ --leading-loose: 1.65;
+
+ --tracking-tight: -0.01em;
+ --tracking-normal: 0;
+ --tracking-wide: 0.02em;
+
+ /* ---------- Shadows ---------- */
+ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.25);
+ --shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.30);
+ --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.35);
+ --shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.45);
+ --shadow-glow-accent: 0 0 0 1px rgba(110, 231, 168, 0.25),
+ 0 8px 32px rgba(110, 231, 168, 0.12);
+
+ /* ---------- Motion ---------- */
+ --ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
+ --ease-emphasis: cubic-bezier(0.2, 0.9, 0.1, 1.05);
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
+
+ --dur-fast: 120ms;
+ --dur-base: 200ms;
+ --dur-slow: 360ms;
+ --dur-slower: 600ms;
+
+ /* ---------- Layout ---------- */
+ --container-max: 1200px;
+ --container-narrow: 880px;
+ --header-height: 60px;
+
+ /* ---------- Z-index ---------- */
+ --z-base: 1;
+ --z-dropdown: 100;
+ --z-drawer: 200;
+ --z-modal: 300;
+ --z-toast: 400;
+}
+
+/* ==========================================================================
+ Light theme (optional — scoped)
+ ========================================================================== */
+[data-theme='light'] {
+ --color-bg-0: #fafbfc;
+ --color-bg-1: #ffffff;
+ --color-bg-2: #f4f6f8;
+ --color-bg-3: #eceff3;
+ --color-bg-4: #e2e6ec;
+
+ --color-surface-0: rgba(15, 17, 21, 0.02);
+ --color-surface-1: rgba(15, 17, 21, 0.04);
+ --color-surface-2: rgba(15, 17, 21, 0.06);
+ --color-surface-3: rgba(15, 17, 21, 0.09);
+
+ --color-border-0: rgba(15, 17, 21, 0.08);
+ --color-border-1: rgba(15, 17, 21, 0.12);
+ --color-border-2: rgba(15, 17, 21, 0.20);
+
+ --color-text-0: #0f1115;
+ --color-text-1: #2d333c;
+ --color-text-2: #5c6470;
+ --color-text-3: #8b939e;
+
+ --color-accent: #10a37f;
+ --color-accent-hi: #0d8a6a;
+
+ --shadow-xs: 0 1px 2px rgba(15, 17, 21, 0.06);
+ --shadow-sm: 0 2px 6px rgba(15, 17, 21, 0.08);
+ --shadow-md: 0 8px 24px rgba(15, 17, 21, 0.10);
+ --shadow-lg: 0 20px 48px rgba(15, 17, 21, 0.14);
+}
\ No newline at end of file
diff --git a/src/styles/utilities.css b/src/styles/utilities.css
index e69de29..34b8b8c 100644
--- a/src/styles/utilities.css
+++ b/src/styles/utilities.css
@@ -0,0 +1,53 @@
+/* ==========================================================================
+ Utilities — small, composable helpers
+ ========================================================================== */
+
+.u-flex { display: flex; }
+.u-flex-col { display: flex; flex-direction: column; }
+.u-items-center { align-items: center; }
+.u-items-start { align-items: flex-start; }
+.u-justify-between { justify-content: space-between; }
+.u-justify-center { justify-content: center; }
+.u-gap-1 { gap: var(--space-1); }
+.u-gap-2 { gap: var(--space-2); }
+.u-gap-3 { gap: var(--space-3); }
+.u-gap-4 { gap: var(--space-4); }
+.u-gap-6 { gap: var(--space-6); }
+
+.u-text-xs { font-size: var(--text-xs); }
+.u-text-sm { font-size: var(--text-sm); }
+.u-text-lg { font-size: var(--text-lg); }
+.u-text-muted { color: var(--color-text-2); }
+.u-text-faint { color: var(--color-text-3); }
+.u-text-mono { font-family: var(--font-mono); }
+.u-text-center { text-align: center; }
+
+.u-weight-medium { font-weight: var(--weight-medium); }
+.u-weight-semi { font-weight: var(--weight-semi); }
+
+.u-mt-2 { margin-top: var(--space-2); }
+.u-mt-4 { margin-top: var(--space-4); }
+.u-mt-6 { margin-top: var(--space-6); }
+.u-mb-2 { margin-bottom: var(--space-2); }
+.u-mb-4 { margin-bottom: var(--space-4); }
+.u-mb-6 { margin-bottom: var(--space-6); }
+
+.u-hidden { display: none !important; }
+.u-truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.u-container {
+ width: 100%;
+ max-width: var(--container-max);
+ margin-inline: auto;
+ padding-inline: var(--space-6);
+}
+
+@media (max-width: 640px) {
+ .u-container {
+ padding-inline: var(--space-4);
+ }
+}
\ No newline at end of file
diff --git a/src/ui/components/file-panel.js b/src/ui/components/file-panel.js
new file mode 100644
index 0000000..5676333
--- /dev/null
+++ b/src/ui/components/file-panel.js
@@ -0,0 +1,253 @@
+import { el, replaceChildren, icon, Icons } from '../renderers/dom.js';
+import { AppEvents } from '../../app/app-events.js';
+import { formatBytes, formatCount, orDash, delimiterLabel } from '../renderers/format.js';
+import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js';
+
+export class FilePanel {
+ constructor({ slot }) {
+ this.slot = slot;
+ this.fileEvent = slot === 'A' ? AppEvents.FILE_A_SELECTED : AppEvents.FILE_B_SELECTED;
+ this.removeEvent = slot === 'A' ? AppEvents.FILE_A_REMOVED : AppEvents.FILE_B_REMOVED;
+
+ this.root = el('section', {
+ class: 'file-panel',
+ dataset: { slot, state: 'empty' },
+ });
+
+ this.inspection = null;
+ this.state = 'empty';
+ this.errorMessage = null;
+
+ this.input = el('input', {
+ type: 'file',
+ accept: '.csv,.tsv,text/csv,text/tab-separated-values',
+ class: 'visually-hidden',
+ });
+
+ this.input.addEventListener('change', () => {
+ const file = this.input.files?.[0];
+ if (file) this._emitSelect(file);
+ this.input.value = '';
+ });
+
+ window.addEventListener(LOCALE_CHANGE_EVENT, () => this._render());
+ this._render();
+ }
+
+ setInspection(inspection) {
+ this.inspection = inspection;
+ if (!inspection) {
+ this.state = 'empty';
+ this.errorMessage = null;
+ } else if (inspection.warnings?.some((warning) => warning.severity === 'error')) {
+ this.state = 'error';
+ this.errorMessage = inspection.warnings.find((warning) => warning.severity === 'error')?.title ?? t('file.genericError');
+ } else if (inspection.warnings?.length) {
+ this.state = 'warning';
+ } else {
+ this.state = 'ready';
+ }
+ this._render();
+ }
+
+ setLoading() {
+ this.state = 'loading';
+ this._render();
+ }
+
+ setError(message) {
+ this.state = 'error';
+ this.errorMessage = message;
+ this._render();
+ }
+
+ _emitSelect(file) {
+ window.dispatchEvent(new CustomEvent(this.fileEvent, { detail: { file } }));
+ }
+
+ _emitRemove() {
+ window.dispatchEvent(new CustomEvent(this.removeEvent));
+ }
+
+ _openPicker() {
+ this.input.click();
+ }
+
+ _render() {
+ this.root.dataset.state = this.state;
+ this.root.setAttribute('aria-label', t('file.label', { slot: this.slot }));
+ this.input.setAttribute('aria-label', t('file.choose', { slot: this.slot }));
+
+ replaceChildren(this.root, [
+ this.input,
+ this._renderHeader(),
+ this._renderBody(),
+ ]);
+ }
+
+ _renderHeader() {
+ return el('header', { class: 'file-panel__header' }, [
+ el('div', { class: 'file-panel__label' }, [
+ el('span', { class: 'file-panel__slot', text: t('file.label', { slot: this.slot }).toUpperCase() }),
+ this.inspection
+ ? el('span', {
+ class: 'file-panel__name u-truncate',
+ text: this.inspection.name,
+ title: this.inspection.name,
+ })
+ : el('span', { class: 'file-panel__name file-panel__name--empty', text: t('file.none') }),
+ ]),
+ this.inspection
+ ? el('button', {
+ type: 'button',
+ class: 'btn btn--ghost btn--icon',
+ 'aria-label': t('file.removeSlot', { slot: this.slot }),
+ title: t('file.remove'),
+ onclick: () => this._emitRemove(),
+ }, [icon(Icons.x, { size: 14 })])
+ : null,
+ ]);
+ }
+
+ _renderBody() {
+ if (this.state === 'loading') return this._renderLoading();
+ if (this.state === 'ready' || this.state === 'warning') return this._renderReady();
+ if (this.state === 'error') return this._renderError();
+ return this._renderDropzone();
+ }
+
+ _renderDropzone() {
+ const zone = el('button', {
+ type: 'button',
+ class: 'file-panel__dropzone',
+ 'aria-label': t('file.chooseCsv', { slot: this.slot }),
+ onclick: () => this._openPicker(),
+ }, [
+ el('div', { class: 'file-panel__dropzone-icon' }, [icon(Icons.upload, { size: 22 })]),
+ el('div', { class: 'file-panel__dropzone-text' }, [
+ el('span', { class: 'file-panel__dropzone-primary', text: t('file.drop') }),
+ el('span', { class: 'file-panel__dropzone-secondary', text: t('file.staysLocal') }),
+ ]),
+ ]);
+
+ zone.addEventListener('dragover', (event) => {
+ event.preventDefault();
+ zone.dataset.dragover = 'true';
+ });
+
+ zone.addEventListener('dragleave', () => {
+ delete zone.dataset.dragover;
+ });
+
+ zone.addEventListener('drop', (event) => {
+ event.preventDefault();
+ delete zone.dataset.dragover;
+ const file = event.dataTransfer?.files?.[0];
+ if (file) this._emitSelect(file);
+ });
+
+ return zone;
+ }
+
+ _renderLoading() {
+ return el('div', { class: 'file-panel__body file-panel__body--loading' }, [
+ el('div', { class: 'skeleton skeleton--line skeleton--w60' }),
+ el('div', { class: 'skeleton skeleton--line skeleton--w40' }),
+ el('div', { class: 'skeleton skeleton--line skeleton--w80' }),
+ el('div', { class: 'skeleton skeleton--block' }),
+ ]);
+ }
+
+ _renderReady() {
+ const inspection = this.inspection;
+ const children = [];
+
+ children.push(el('dl', { class: 'file-panel__meta' }, [
+ this._metaItem(t('file.rows'), formatCount(inspection.rowCount)),
+ this._metaItem(t('file.columns'), formatCount(inspection.columns.length)),
+ this._metaItem(t('file.delimiter'), delimiterLabel(inspection.delimiter)),
+ this._metaItem(t('file.encoding'), orDash(inspection.encoding)),
+ this._metaItem(t('file.size'), formatBytes(inspection.sizeBytes)),
+ ]));
+
+ if (inspection.warnings?.length) {
+ children.push(el('div', { class: 'file-panel__warnings' },
+ inspection.warnings.slice(0, 3).map((warning) =>
+ el('div', {
+ class: 'toast',
+ dataset: { severity: warning.severity },
+ role: warning.severity === 'error' ? 'alert' : 'status',
+ }, [
+ icon(Icons.alert, { size: 14 }),
+ el('div', {}, [
+ el('div', { class: 'u-weight-semi', text: warning.title }),
+ warning.message ? el('div', { class: 'u-text-sm u-text-muted', text: warning.message }) : null,
+ ]),
+ ])
+ )
+ ));
+ }
+
+ if (inspection.previewRows?.length && inspection.columns?.length) {
+ children.push(this._renderPreview(inspection));
+ }
+
+ return el('div', { class: 'file-panel__body' }, children);
+ }
+
+ _metaItem(label, value) {
+ return el('div', { class: 'file-panel__meta-item' }, [
+ el('dt', { class: 'file-panel__meta-label', text: label }),
+ el('dd', { class: 'file-panel__meta-value', text: value }),
+ ]);
+ }
+
+ _renderPreview(inspection) {
+ const columns = inspection.columns.slice(0, 5);
+
+ const thead = el('thead', {}, [
+ el('tr', {}, columns.map((column) =>
+ el('th', { scope: 'col', text: column.label, title: column.label })
+ )),
+ ]);
+
+ const tbody = el('tbody', {}, inspection.previewRows.slice(0, 5).map((row) =>
+ el('tr', {}, columns.map((column) =>
+ el('td', { text: orDash(row[column.id]) })
+ ))
+ ));
+
+ return el('div', { class: 'file-panel__preview' }, [
+ el('div', { class: 'file-panel__preview-label u-text-xs u-text-faint', text: t('file.preview') }),
+ el('div', { class: 'file-panel__preview-scroll' }, [
+ el('table', { class: 'file-panel__preview-table' }, [thead, tbody]),
+ ]),
+ inspection.rowCount && inspection.rowCount > inspection.previewRows.length
+ ? el('div', {
+ class: 'file-panel__preview-foot u-text-xs u-text-faint',
+ text: t('file.showingRows', {
+ shown: Math.min(inspection.previewRows.length, 5),
+ total: formatCount(inspection.rowCount),
+ }),
+ })
+ : null,
+ ]);
+ }
+
+ _renderError() {
+ return el('div', { class: 'file-panel__body' }, [
+ el('div', { class: 'toast', dataset: { severity: 'error' }, role: 'alert' }, [
+ icon(Icons.alert, { size: 16 }),
+ el('div', {}, [
+ el('div', { class: 'u-weight-semi', text: t('file.readError') }),
+ el('div', { class: 'u-text-sm u-text-muted', text: this.errorMessage ?? t('file.tryDifferent') }),
+ ]),
+ ]),
+ el('button', {
+ type: 'button',
+ class: 'btn btn--secondary btn--sm u-mt-2',
+ onclick: () => this._openPicker(),
+ }, [icon(Icons.refresh, { size: 14 }), t('file.chooseAnother')]),
+ ]);
+ }
+}
diff --git a/src/ui/components/language-switcher.js b/src/ui/components/language-switcher.js
new file mode 100644
index 0000000..2cbf924
--- /dev/null
+++ b/src/ui/components/language-switcher.js
@@ -0,0 +1,34 @@
+import { el } from '../renderers/dom.js';
+import {
+ getLocale,
+ getSupportedLocales,
+ LOCALE_CHANGE_EVENT,
+ setLocale,
+ t,
+} from '../../i18n/index.js';
+
+export function LanguageSwitcher() {
+ const select = el('select', {
+ class: 'language-switcher__select',
+ onchange: (event) => setLocale(event.target.value),
+ });
+
+ for (const locale of getSupportedLocales()) {
+ select.appendChild(el('option', {
+ value: locale.code,
+ text: locale.shortLabel,
+ title: locale.nativeName,
+ }));
+ }
+
+ const sync = () => {
+ select.value = getLocale();
+ select.setAttribute('aria-label', t('language.label'));
+ select.title = t('language.label');
+ };
+
+ window.addEventListener(LOCALE_CHANGE_EVENT, sync);
+ sync();
+
+ return el('div', { class: 'language-switcher' }, [select]);
+}
diff --git a/src/ui/components/mapping-editor.js b/src/ui/components/mapping-editor.js
new file mode 100644
index 0000000..81b6905
--- /dev/null
+++ b/src/ui/components/mapping-editor.js
@@ -0,0 +1,281 @@
+import { el, replaceChildren, icon, Icons } from '../renderers/dom.js';
+import { MappingRow } from './mapping-row.js';
+import { AppEvents } from '../../app/app-events.js';
+import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js';
+
+export class MappingEditor {
+ constructor(store, service) {
+ this.store = store;
+ this.service = service;
+ this.mapping = this._emptyMapping();
+
+ this.root = el('section', {
+ class: 'mapping-workspace',
+ 'aria-label': t('mapping.aria'),
+ });
+
+ this._render();
+ this.store.addEventListener('change', () => this._onStoreChange());
+ this.store.addEventListener('state', () => this._onStoreChange());
+ window.addEventListener(LOCALE_CHANGE_EVENT, () => this._render());
+ }
+
+ _emptyMapping() {
+ return {
+ keys: [],
+ comparisons: [],
+ displayOnly: { fileA: [], fileB: [] },
+ normalization: {
+ trimText: true,
+ caseInsensitive: true,
+ collapseWhitespace: true,
+ numberLocale: 'auto',
+ dateFormatA: null,
+ dateFormatB: null,
+ },
+ };
+ }
+
+ async _onStoreChange() {
+ if (this.store.fileA && this.store.fileB && !this._suggestionsFetched) {
+ this._suggestionsFetched = true;
+ try {
+ const suggestions = await this.service.suggestMappings(this.store.fileA, this.store.fileB);
+ if (suggestions?.length) {
+ this.mapping.keys = suggestions
+ .filter((_, index) => index === 0)
+ .map((suggestion) => ({
+ columnA: suggestion.columnA,
+ columnB: suggestion.columnB,
+ type: 'text',
+ }));
+
+ this.mapping.comparisons = suggestions
+ .slice(1)
+ .map((suggestion) => ({
+ columnA: suggestion.columnA,
+ columnB: suggestion.columnB,
+ type: this._guessType(suggestion.columnA, suggestion.columnB),
+ tolerance: { mode: 'absolute', value: 0.01 },
+ }));
+ }
+ } catch {
+ // Suggestions are optional.
+ }
+
+ this._render();
+ this._emitUpdate();
+ }
+
+ if (!this.store.fileA || !this.store.fileB) {
+ this._suggestionsFetched = false;
+ this.mapping = this._emptyMapping();
+ this._render();
+ }
+ }
+
+ _guessType(columnA, columnB) {
+ const typeA = this.store.fileA?.columns.find((column) => column.id === columnA)?.inferredType;
+ const typeB = this.store.fileB?.columns.find((column) => column.id === columnB)?.inferredType;
+ if (typeA === 'number' || typeB === 'number') return 'number';
+ if (typeA === 'date' || typeB === 'date') return 'date';
+ return 'text';
+ }
+
+ _emitUpdate() {
+ this.store.patch({ mapping: this.mapping });
+ this._validate();
+ window.dispatchEvent(new CustomEvent(AppEvents.MAPPING_UPDATED, { detail: this.mapping }));
+ }
+
+ _emitNormalization() {
+ this.store.patch({ mapping: this.mapping });
+ window.dispatchEvent(new CustomEvent(AppEvents.NORMALIZATION_UPDATED, {
+ detail: this.mapping.normalization,
+ }));
+ }
+
+ _addRow(role) {
+ const item = { columnA: null, columnB: null, type: 'text' };
+ if (role === 'key') this.mapping.keys.push(item);
+ if (role === 'compare') {
+ this.mapping.comparisons.push({
+ ...item,
+ tolerance: { mode: 'absolute', value: 0.01 },
+ });
+ }
+ this._emitUpdate();
+ this._render();
+ }
+
+ _removeRow(role, index) {
+ if (role === 'key') this.mapping.keys.splice(index, 1);
+ else this.mapping.comparisons.splice(index, 1);
+ this._emitUpdate();
+ this._render();
+ }
+
+ _validate() {
+ const validKeys = this.mapping.keys.filter((key) => key.columnA && key.columnB);
+ const valid = validKeys.length > 0 &&
+ this.mapping.keys.every((key) => key.columnA && key.columnB);
+
+ this.store.patch({ mappingValid: valid });
+
+ if (valid && this.store.state === 'MAPPING') {
+ this.store.setState('READY_TO_COMPARE');
+ } else if (!valid && this.store.state === 'READY_TO_COMPARE') {
+ this.store.setState('MAPPING');
+ }
+ }
+
+ _render() {
+ const columnsA = this.store.fileA?.columns ?? [];
+ const columnsB = this.store.fileB?.columns ?? [];
+ this.root.setAttribute('aria-label', t('mapping.aria'));
+
+ replaceChildren(this.root, [
+ el('header', { class: 'mapping-workspace__header' }, [
+ el('div', {}, [
+ el('h2', { class: 'mapping-workspace__title', text: t('mapping.title') }),
+ el('p', { class: 'mapping-workspace__hint', text: t('mapping.hint') }),
+ ]),
+ ]),
+ this._renderColumnHeaders(),
+ this._renderKeys(columnsA, columnsB),
+ this._renderComparisons(columnsA, columnsB),
+ this._renderNormalization(),
+ this._renderActions(),
+ ]);
+ }
+
+ _renderColumnHeaders() {
+ return el('div', { class: 'mapping-workspace__col-heads' }, [
+ el('div', { class: 'mapping-workspace__col-head' }, [
+ el('span', { class: 'mapping-workspace__col-label', text: t('file.label', { slot: 'A' }).toUpperCase() }),
+ el('span', { class: 'mapping-workspace__col-name u-truncate', text: this.store.fileA?.name ?? '—' }),
+ ]),
+ el('div', { class: 'mapping-workspace__col-head mapping-workspace__col-head--spacer', 'aria-hidden': 'true' }),
+ el('div', { class: 'mapping-workspace__col-head' }, [
+ el('span', { class: 'mapping-workspace__col-label', text: t('file.label', { slot: 'B' }).toUpperCase() }),
+ el('span', { class: 'mapping-workspace__col-name u-truncate', text: this.store.fileB?.name ?? '—' }),
+ ]),
+ ]);
+ }
+
+ _renderKeys(columnsA, columnsB) {
+ return el('div', { class: 'mapping-workspace__group' }, [
+ el('div', { class: 'mapping-workspace__group-head' }, [
+ el('h3', { class: 'mapping-workspace__group-title', text: t('mapping.matchKeys') }),
+ el('span', { class: 'mapping-workspace__group-hint u-text-xs u-text-muted', text: t('mapping.matchKeysHint') }),
+ el('button', {
+ type: 'button',
+ class: 'btn btn--ghost btn--sm',
+ onclick: () => this._addRow('key'),
+ }, [t('mapping.addKey')]),
+ ]),
+ this.mapping.keys.length === 0
+ ? el('div', { class: 'mapping-workspace__empty', text: t('mapping.noKeys') })
+ : el('div', { class: 'mapping-workspace__rows' },
+ this.mapping.keys.map((key, index) =>
+ new MappingRow({
+ role: 'key',
+ columnsA,
+ columnsB,
+ value: key,
+ onChange: (value) => {
+ this.mapping.keys[index] = value;
+ this._emitUpdate();
+ },
+ onRemove: () => this._removeRow('key', index),
+ }).root
+ )
+ ),
+ ]);
+ }
+
+ _renderComparisons(columnsA, columnsB) {
+ return el('div', { class: 'mapping-workspace__group' }, [
+ el('div', { class: 'mapping-workspace__group-head' }, [
+ el('h3', { class: 'mapping-workspace__group-title', text: t('mapping.compareFields') }),
+ el('span', { class: 'mapping-workspace__group-hint u-text-xs u-text-muted', text: t('mapping.compareHint') }),
+ el('button', {
+ type: 'button',
+ class: 'btn btn--ghost btn--sm',
+ onclick: () => this._addRow('compare'),
+ }, [t('mapping.addCompare')]),
+ ]),
+ this.mapping.comparisons.length === 0
+ ? el('div', { class: 'mapping-workspace__empty', text: t('mapping.noCompare') })
+ : el('div', { class: 'mapping-workspace__rows' },
+ this.mapping.comparisons.map((comparison, index) =>
+ new MappingRow({
+ role: 'compare',
+ columnsA,
+ columnsB,
+ value: comparison,
+ onChange: (value) => {
+ this.mapping.comparisons[index] = value;
+ this._emitUpdate();
+ },
+ onRemove: () => this._removeRow('compare', index),
+ }).root
+ )
+ ),
+ ]);
+ }
+
+ _renderNormalization() {
+ const normalization = this.mapping.normalization;
+
+ const toggle = (label, key) =>
+ el('label', { class: 'toggle' }, [
+ el('input', {
+ type: 'checkbox',
+ checked: normalization[key] ? 'checked' : null,
+ onchange: (event) => {
+ this.mapping.normalization[key] = event.target.checked;
+ this._emitNormalization();
+ },
+ }),
+ el('span', { class: 'toggle__track' }, [el('span', { class: 'toggle__thumb' })]),
+ el('span', { class: 'toggle__label', text: label }),
+ ]);
+
+ return el('details', { class: 'mapping-workspace__group mapping-workspace__group--collapsible' }, [
+ el('summary', { class: 'mapping-workspace__group-head mapping-workspace__summary' }, [
+ el('h3', { class: 'mapping-workspace__group-title', text: t('mapping.normalization') }),
+ el('span', { class: 'mapping-workspace__group-hint u-text-xs u-text-muted', text: t('mapping.normalizationHint') }),
+ ]),
+ el('div', { class: 'mapping-workspace__normalization' }, [
+ toggle(t('mapping.trim'), 'trimText'),
+ toggle(t('mapping.ignoreCase'), 'caseInsensitive'),
+ toggle(t('mapping.collapseWhitespace'), 'collapseWhitespace'),
+ ]),
+ ]);
+ }
+
+ _renderActions() {
+ const disabled = !this.store.mappingValid;
+
+ return el('div', { class: 'mapping-workspace__actions' }, [
+ el('button', {
+ type: 'button',
+ class: 'btn btn--ghost',
+ onclick: () => window.dispatchEvent(new CustomEvent(AppEvents.RESET_REQUESTED)),
+ }, [icon(Icons.refresh, { size: 14 }), t('mapping.startOver')]),
+ el('button', {
+ type: 'button',
+ class: 'btn btn--primary btn--lg',
+ disabled: disabled ? 'true' : null,
+ 'aria-disabled': disabled ? 'true' : null,
+ onclick: () => {
+ if (disabled) return;
+ window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_REQUESTED, {
+ detail: { mapping: this.mapping },
+ }));
+ },
+ }, [icon(Icons.zap, { size: 16 }), t('mapping.compareFiles')]),
+ ]);
+ }
+}
diff --git a/src/ui/components/mapping-row.js b/src/ui/components/mapping-row.js
new file mode 100644
index 0000000..2a74f66
--- /dev/null
+++ b/src/ui/components/mapping-row.js
@@ -0,0 +1,135 @@
+import { el, icon, Icons } from '../renderers/dom.js';
+import { t } from '../../i18n/index.js';
+
+export class MappingRow {
+ constructor(config) {
+ this.config = config;
+ this.value = { ...config.value };
+ this.root = el('div', {
+ class: 'mapping-row',
+ dataset: { role: config.role },
+ });
+ this._render();
+ }
+
+ _render() {
+ const { role, columnsA, columnsB } = this.config;
+
+ const roleBadge = el('div', { class: 'mapping-row__role' }, [
+ el('span', {
+ class: 'mapping-row__role-chip mapping-row__role-chip--' + role,
+ text: this._roleLabel(role),
+ }),
+ ]);
+
+ const selectA = this._buildSelect(
+ columnsA,
+ this.value.columnA,
+ t('mapping.fileAColumn'),
+ (value) => {
+ this.value.columnA = value;
+ this.config.onChange({ ...this.value });
+ }
+ );
+
+ const selectB = this._buildSelect(
+ columnsB,
+ this.value.columnB,
+ t('mapping.fileBColumn'),
+ (value) => {
+ this.value.columnB = value;
+ this.config.onChange({ ...this.value });
+ }
+ );
+
+ const arrow = el('div', { class: 'mapping-row__arrow', 'aria-hidden': 'true' }, [
+ icon(Icons.arrowRight, { size: 14 }),
+ ]);
+
+ const extras = [];
+ if (role === 'compare' && this.value.type === 'number') {
+ extras.push(this._renderTolerance());
+ }
+
+ const removeButton = el('button', {
+ type: 'button',
+ class: 'btn btn--ghost btn--icon btn--sm mapping-row__remove',
+ 'aria-label': t('mapping.remove'),
+ title: t('mapping.remove'),
+ onclick: () => this.config.onRemove(),
+ }, [icon(Icons.x, { size: 12 })]);
+
+ this.root.replaceChildren(
+ roleBadge,
+ el('div', { class: 'mapping-row__body' }, [
+ el('div', { class: 'mapping-row__cell mapping-row__cell--a' }, [selectA]),
+ arrow,
+ el('div', { class: 'mapping-row__cell mapping-row__cell--b' }, [selectB]),
+ ...extras,
+ removeButton,
+ ])
+ );
+ }
+
+ _roleLabel(role) {
+ if (role === 'key') return t('mapping.role.key');
+ if (role === 'compare') return t('mapping.role.compare');
+ if (role === 'display') return t('mapping.role.display');
+ return t('mapping.role.ignore');
+ }
+
+ _buildSelect(columns, value, label, onChange) {
+ const select = el('select', {
+ class: 'select',
+ 'aria-label': label,
+ onchange: (event) => onChange(event.target.value || null),
+ });
+
+ select.appendChild(el('option', { value: '', text: t('mapping.select') }));
+
+ for (const column of columns) {
+ const option = el('option', { value: column.id, text: column.label });
+ select.appendChild(option);
+ }
+
+ select.value = value ?? '';
+ return select;
+ }
+
+ _renderTolerance() {
+ const tolerance = this.value.tolerance ?? { mode: 'absolute', value: 0.01 };
+
+ const modeSelect = el('select', {
+ class: 'select select--sm',
+ 'aria-label': t('mapping.toleranceMode'),
+ onchange: (event) => {
+ this.value.tolerance = { ...tolerance, mode: event.target.value };
+ this.config.onChange({ ...this.value });
+ },
+ }, [
+ this._option('absolute', '±', tolerance.mode === 'absolute'),
+ this._option('percentage', '%', tolerance.mode === 'percentage'),
+ ]);
+
+ const valueInput = el('input', {
+ type: 'number',
+ class: 'input input--sm',
+ value: String(tolerance.value),
+ step: '0.01',
+ min: '0',
+ 'aria-label': t('mapping.toleranceValue'),
+ oninput: (event) => {
+ this.value.tolerance = { ...tolerance, value: Number(event.target.value) };
+ this.config.onChange({ ...this.value });
+ },
+ });
+
+ return el('div', { class: 'mapping-row__tolerance' }, [modeSelect, valueInput]);
+ }
+
+ _option(value, label, selected) {
+ const option = el('option', { value, text: label });
+ if (selected) option.selected = true;
+ return option;
+ }
+}
diff --git a/src/ui/components/privacy-note.js b/src/ui/components/privacy-note.js
new file mode 100644
index 0000000..7f60e8c
--- /dev/null
+++ b/src/ui/components/privacy-note.js
@@ -0,0 +1,12 @@
+import { el, icon, Icons } from '../renderers/dom.js';
+import { t } from '../../i18n/index.js';
+
+/**
+ * PrivacyNote — accurately states local processing. No legal guarantees.
+ */
+export function PrivacyNote() {
+ return el('div', { class: 'privacy-note', role: 'note' }, [
+ icon(Icons.shield, { size: 14 }),
+ el('span', { text: t('privacy.local') }),
+ ]);
+}
diff --git a/src/ui/components/record-detail-drawer.js b/src/ui/components/record-detail-drawer.js
new file mode 100644
index 0000000..e45a1f5
--- /dev/null
+++ b/src/ui/components/record-detail-drawer.js
@@ -0,0 +1,191 @@
+import { el, replaceChildren, icon, Icons } from '../renderers/dom.js';
+import { orDash } from '../renderers/format.js';
+import { AppEvents } from '../../app/app-events.js';
+import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js';
+
+const STATUS_KEY = {
+ MATCHED: 'results.matched',
+ ONLY_A: 'results.onlyA',
+ ONLY_B: 'results.onlyB',
+ MISMATCH: 'results.mismatch',
+ DUPLICATE: 'results.duplicate',
+ AMBIGUOUS: 'results.ambiguous',
+};
+
+export class RecordDetailDrawer {
+ constructor(store) {
+ this.store = store;
+
+ this.root = el('aside', {
+ class: 'record-drawer',
+ role: 'dialog',
+ 'aria-modal': 'true',
+ hidden: '',
+ });
+
+ store.addEventListener('change', () => this._sync());
+ store.addEventListener('state', () => this._sync());
+ window.addEventListener(LOCALE_CHANGE_EVENT, () => this._sync());
+
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape' && this.store.openRecordId) this._close();
+ });
+
+ this._sync();
+ }
+
+ _close() {
+ this.store.patch({ openRecordId: null });
+ window.dispatchEvent(new CustomEvent(AppEvents.RESULT_RECORD_CLOSED));
+ }
+
+ _sync() {
+ this.root.setAttribute('aria-label', t('record.detail'));
+
+ const id = this.store.openRecordId;
+ if (!id || this.store.state !== 'RESULTS') {
+ this.root.hidden = true;
+ replaceChildren(this.root, []);
+ return;
+ }
+
+ const record = this.store.result?.records.find((item) => item.id === id);
+ if (!record) {
+ this.root.hidden = true;
+ return;
+ }
+
+ this.root.hidden = false;
+ this._render(record);
+ }
+
+ _render(record) {
+ const backdrop = el('div', {
+ class: 'record-drawer__backdrop',
+ onclick: () => this._close(),
+ });
+
+ const panel = el('div', { class: 'record-drawer__panel' }, [
+ this._renderHead(record),
+ this._renderExplain(record),
+ this._renderSideBySide(record),
+ record.differences?.length ? this._renderDifferences(record) : null,
+ ]);
+
+ replaceChildren(this.root, [backdrop, panel]);
+ }
+
+ _renderHead(record) {
+ const statusKey = STATUS_KEY[record.status];
+
+ return el('header', { class: 'record-drawer__head' }, [
+ el('div', { class: 'record-drawer__head-main' }, [
+ el('span', {
+ class: 'badge',
+ dataset: { status: record.status },
+ text: statusKey ? t(statusKey) : record.status,
+ }),
+ el('span', { class: 'record-drawer__key u-text-mono', text: record.keyLabel }),
+ ]),
+ el('button', {
+ type: 'button',
+ class: 'btn btn--ghost btn--icon',
+ 'aria-label': t('record.close'),
+ title: t('record.close'),
+ onclick: () => this._close(),
+ }, [icon(Icons.x, { size: 16 })]),
+ ]);
+ }
+
+ _renderExplain(record) {
+ return el('p', {
+ class: 'record-drawer__explain',
+ text: t('record.explain.' + record.status),
+ });
+ }
+
+ _renderSideBySide(record) {
+ const fields = new Set([
+ ...Object.keys(record.displayA ?? {}),
+ ...Object.keys(record.displayB ?? {}),
+ ]);
+
+ return el('div', { class: 'record-drawer__grid' }, [
+ this._renderSide(
+ t('record.fileA'),
+ record.source?.rowA,
+ record.displayA ?? {},
+ fields,
+ record.differences
+ ),
+ this._renderSide(
+ t('record.fileB'),
+ record.source?.rowB,
+ record.displayB ?? {},
+ fields,
+ record.differences
+ ),
+ ]);
+ }
+
+ _renderSide(title, rowNumber, data, fields, differences) {
+ const differenceFields = new Set(
+ (differences ?? []).map((difference) => String(difference.field).toLowerCase())
+ );
+
+ return el('div', { class: 'record-drawer__side' }, [
+ el('div', { class: 'record-drawer__side-head' }, [
+ el('span', { class: 'record-drawer__side-title', text: title }),
+ el('span', {
+ class: 'record-drawer__side-row u-text-mono u-text-muted',
+ text: rowNumber != null ? t('record.row', { row: rowNumber }) : '—',
+ }),
+ ]),
+ el('dl', { class: 'record-drawer__fields' }, [...fields].map((field) => {
+ const isDifferent = differenceFields.has(String(field).toLowerCase());
+
+ return el('div', {
+ class: 'record-drawer__field',
+ dataset: { diff: isDifferent ? 'true' : 'false' },
+ }, [
+ el('dt', { class: 'record-drawer__field-label', text: field }),
+ el('dd', {
+ class: 'record-drawer__field-value u-text-mono',
+ text: orDash(data[field]),
+ }),
+ ]);
+ })),
+ ]);
+ }
+
+ _renderDifferences(record) {
+ return el('div', { class: 'record-drawer__diffs' }, [
+ el('h4', { class: 'record-drawer__diffs-title', text: t('record.differences') }),
+ el('ul', { class: 'record-drawer__diffs-list' }, record.differences.map((difference) =>
+ el('li', { class: 'record-drawer__diff' }, [
+ el('span', {
+ class: 'record-drawer__diff-field u-text-mono',
+ text: difference.field,
+ }),
+ el('span', { class: 'record-drawer__diff-values' }, [
+ el('span', {
+ class: 'record-drawer__diff-a',
+ text: String(difference.valueA ?? '—'),
+ }),
+ el('span', { class: 'record-drawer__diff-arrow', text: '→' }),
+ el('span', {
+ class: 'record-drawer__diff-b',
+ text: String(difference.valueB ?? '—'),
+ }),
+ ]),
+ difference.delta != null
+ ? el('span', {
+ class: 'record-drawer__diff-delta u-text-mono',
+ text: 'Δ ' + difference.delta,
+ })
+ : null,
+ ])
+ )),
+ ]);
+ }
+}
diff --git a/src/ui/components/results-table.js b/src/ui/components/results-table.js
new file mode 100644
index 0000000..0dca97e
--- /dev/null
+++ b/src/ui/components/results-table.js
@@ -0,0 +1,166 @@
+import { el, replaceChildren, icon, Icons } from '../renderers/dom.js';
+import { formatCount } from '../renderers/format.js';
+import { AppEvents } from '../../app/app-events.js';
+import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js';
+
+const STATUS_KEY = {
+ MATCHED: 'results.matched',
+ ONLY_A: 'results.onlyA',
+ ONLY_B: 'results.onlyB',
+ MISMATCH: 'results.mismatch',
+ DUPLICATE: 'results.duplicate',
+ AMBIGUOUS: 'results.ambiguous',
+};
+
+const PAGE_SIZE = 50;
+
+export class ResultsTable {
+ constructor(store) {
+ this.store = store;
+ this.page = 0;
+ this.root = el('div', { class: 'results-table' });
+
+ store.addEventListener('change', () => this._render());
+ store.addEventListener('state', () => this._render());
+ window.addEventListener(LOCALE_CHANGE_EVENT, () => this._render());
+
+ this._render();
+ }
+
+ _filtered() {
+ const records = this.store.result?.records ?? [];
+ const filter = this.store.resultFilter;
+ return filter === 'ALL'
+ ? records
+ : records.filter((record) => record.status === filter);
+ }
+
+ _render() {
+ if (this.store.state !== 'RESULTS' || !this.store.result) {
+ replaceChildren(this.root, []);
+ return;
+ }
+
+ const rows = this._filtered();
+ const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
+
+ if (this.page >= totalPages) this.page = totalPages - 1;
+
+ const pageRows = rows.slice(
+ this.page * PAGE_SIZE,
+ this.page * PAGE_SIZE + PAGE_SIZE
+ );
+
+ if (rows.length === 0) {
+ replaceChildren(this.root, [
+ el('div', { class: 'results-table__empty' }, [
+ icon(Icons.check, { size: 20 }),
+ el('span', { text: t('results.nothing') }),
+ ]),
+ ]);
+ return;
+ }
+
+ const table = el('table', { class: 'results-table__table' }, [
+ el('thead', {}, [
+ el('tr', {}, [
+ el('th', { scope: 'col', text: t('results.status') }),
+ el('th', { scope: 'col', text: t('results.key') }),
+ el('th', { scope: 'col', class: 'u-text-mono', text: t('results.rowA') }),
+ el('th', { scope: 'col', class: 'u-text-mono', text: t('results.rowB') }),
+ el('th', { scope: 'col', text: '' }),
+ ]),
+ ]),
+ el('tbody', {}, pageRows.map((record) => this._renderRow(record))),
+ ]);
+
+ replaceChildren(this.root, [
+ el('div', { class: 'results-table__scroll' }, [table]),
+ this._renderPager(rows.length, totalPages),
+ ]);
+ }
+
+ _renderRow(record) {
+ const statusKey = STATUS_KEY[record.status];
+ const statusLabel = statusKey ? t(statusKey) : record.status;
+
+ 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', {
+ class: 'badge',
+ dataset: { status: record.status },
+ text: statusLabel,
+ }),
+ ]),
+ el('td', { class: 'u-text-mono results-table__key', text: record.keyLabel }),
+ 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 }),
+ ]),
+ ]);
+ }
+
+ _open(id) {
+ this.store.patch({ openRecordId: id });
+ window.dispatchEvent(new CustomEvent(AppEvents.RESULT_RECORD_OPENED, {
+ detail: { id },
+ }));
+ }
+
+ _renderPager(total, totalPages) {
+ if (totalPages <= 1) {
+ return el('div', { class: 'results-table__pager' }, [
+ el('span', {
+ class: 'u-text-sm u-text-muted',
+ text: t('results.records', { count: formatCount(total) }),
+ }),
+ ]);
+ }
+
+ const previous = el('button', {
+ type: 'button',
+ class: 'btn btn--ghost btn--sm',
+ disabled: this.page === 0 ? 'true' : null,
+ onclick: () => {
+ this.page = Math.max(0, this.page - 1);
+ this._render();
+ },
+ }, [t('results.prev')]);
+
+ const next = el('button', {
+ type: 'button',
+ class: 'btn btn--ghost btn--sm',
+ disabled: this.page >= totalPages - 1 ? 'true' : null,
+ onclick: () => {
+ this.page = Math.min(totalPages - 1, this.page + 1);
+ this._render();
+ },
+ }, [t('results.next')]);
+
+ return el('div', { class: 'results-table__pager' }, [
+ el('span', {
+ class: 'u-text-sm u-text-muted',
+ text: t('results.page', {
+ count: formatCount(total),
+ page: this.page + 1,
+ pages: totalPages,
+ }),
+ }),
+ el('div', { class: 'u-flex u-gap-2' }, [previous, next]),
+ ]);
+ }
+}
diff --git a/src/ui/components/skip-link.js b/src/ui/components/skip-link.js
new file mode 100644
index 0000000..4d5a80d
--- /dev/null
+++ b/src/ui/components/skip-link.js
@@ -0,0 +1,18 @@
+import { el } from '../renderers/dom.js';
+import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js';
+
+export function SkipLink() {
+ const link = el('a', {
+ href: '#main',
+ class: 'skip-link',
+ });
+
+ const sync = () => {
+ link.textContent = t('skip.content');
+ };
+
+ window.addEventListener(LOCALE_CHANGE_EVENT, sync);
+ sync();
+
+ return link;
+}
diff --git a/src/ui/components/summary-card.js b/src/ui/components/summary-card.js
new file mode 100644
index 0000000..2ae65cc
--- /dev/null
+++ b/src/ui/components/summary-card.js
@@ -0,0 +1,31 @@
+import { el } from '../renderers/dom.js';
+import { formatCount } from '../renderers/format.js';
+
+/**
+ * SummaryCard — one status card. Acts as a filter toggle.
+ */
+export class SummaryCard {
+ /**
+ * @param {{
+ * status: import('../../models/contracts.js').ResultStatus | 'ALL',
+ * label: string,
+ * count: number,
+ * active: boolean,
+ * onClick: () => void,
+ * }} config
+ */
+ constructor(config) {
+ this.config = config;
+ this.root = el('button', {
+ type: 'button',
+ class: 'summary-card',
+ dataset: { status: config.status, active: config.active ? 'true' : 'false' },
+ 'aria-pressed': config.active ? 'true' : 'false',
+ onclick: () => config.onClick(),
+ }, [
+ el('span', { class: 'summary-card__count', text: formatCount(config.count) }),
+ el('span', { class: 'summary-card__label', text: config.label }),
+ el('span', { class: 'summary-card__bar', 'aria-hidden': 'true' }),
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/ui/components/toast-host.js b/src/ui/components/toast-host.js
new file mode 100644
index 0000000..1a682ab
--- /dev/null
+++ b/src/ui/components/toast-host.js
@@ -0,0 +1,54 @@
+import { el, replaceChildren, icon, Icons } from '../renderers/dom.js';
+import { AppEvents } from '../../app/app-events.js';
+import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js';
+
+export class ToastHost {
+ constructor(store) {
+ this.store = store;
+
+ this.root = el('div', {
+ class: 'toast-host',
+ role: 'region',
+ });
+
+ store.addEventListener('change', () => this._render());
+ store.addEventListener(AppEvents.TOAST_SHOWN, () => this._render());
+ store.addEventListener(AppEvents.TOAST_DISMISSED, () => this._render());
+ window.addEventListener(LOCALE_CHANGE_EVENT, () => this._render());
+
+ this._render();
+ }
+
+ _render() {
+ this.root.setAttribute('aria-label', t('toast.notifications'));
+
+ replaceChildren(
+ this.root,
+ this.store.toasts.map((toast) => {
+ const title = toast.titleKey ? t(toast.titleKey) : toast.title;
+ const message = toast.messageKey ? t(toast.messageKey) : toast.message;
+
+ return el('div', {
+ class: 'toast toast--floating',
+ dataset: { severity: toast.severity },
+ role: toast.severity === 'error' ? 'alert' : 'status',
+ }, [
+ icon(toast.severity === 'error' ? Icons.alert : Icons.check, { size: 14 }),
+ el('div', { class: 'toast__body' }, [
+ el('div', { class: 'u-weight-semi', text: title }),
+ message
+ ? el('div', { class: 'u-text-sm u-text-muted', text: message })
+ : null,
+ ]),
+ el('button', {
+ type: 'button',
+ class: 'btn btn--ghost btn--icon btn--sm',
+ 'aria-label': t('toast.dismiss'),
+ title: t('toast.dismiss'),
+ onclick: () => this.store.dismissToast(toast.id),
+ }, [icon(Icons.x, { size: 12 })]),
+ ]);
+ })
+ );
+ }
+}
diff --git a/src/ui/components/visual-diff.js b/src/ui/components/visual-diff.js
new file mode 100644
index 0000000..b8ec593
--- /dev/null
+++ b/src/ui/components/visual-diff.js
@@ -0,0 +1,117 @@
+import { el } from '../renderers/dom.js';
+import { t } from '../../i18n/index.js';
+
+const ATTENTION_STATUSES = new Set([
+ 'MISMATCH',
+ 'ONLY_A',
+ 'ONLY_B',
+ 'DUPLICATE',
+ 'AMBIGUOUS',
+]);
+
+export class VisualDiff {
+ constructor(store) {
+ this.store = store;
+ this.root = el('div', { class: 'visual-diff' });
+ this.render();
+ }
+
+ render() {
+ const records = this.store.result?.records ?? [];
+ this.root.replaceChildren(
+ ...records.map((record) => this._renderPair(record))
+ );
+ }
+
+ _renderPair(record) {
+ const attention = ATTENTION_STATUSES.has(record.status);
+ const statusClass = attention ? 'attention' : 'matched';
+
+ return el('article', {
+ class: 'visual-diff__pair',
+ dataset: { status: record.status, tone: statusClass },
+ }, [
+ this._renderSide('A', record),
+ this._renderStatus(record),
+ this._renderSide('B', record),
+ ]);
+ }
+
+ _renderSide(side, record) {
+ const isA = side === 'A';
+ const data = isA ? record.displayA : record.displayB;
+ const rowNumber = isA ? record.source?.rowA : record.source?.rowB;
+ const missing =
+ (record.status === 'ONLY_A' && !isA) ||
+ (record.status === 'ONLY_B' && isA);
+
+ if (missing) {
+ return el('div', {
+ class: 'visual-diff__side visual-diff__side--missing',
+ }, [
+ el('div', {
+ class: 'visual-diff__missing-label',
+ text: t('visual.notInFile'),
+ }),
+ ]);
+ }
+
+ const entries = Object.entries(data ?? {});
+
+ return el('div', { class: 'visual-diff__side' }, [
+ el('div', { class: 'visual-diff__side-head' }, [
+ el('span', {
+ class: 'visual-diff__file-label',
+ text: isA ? t('visual.fileA') : t('visual.fileB'),
+ }),
+ el('span', {
+ class: 'visual-diff__row',
+ text: rowNumber != null ? t('visual.row', { row: rowNumber }) : '—',
+ }),
+ ]),
+ el('div', { class: 'visual-diff__record-key', text: record.keyLabel }),
+ el('div', { class: 'visual-diff__fields' },
+ entries.map(([field, value]) =>
+ el('div', {
+ class: 'visual-diff__field',
+ dataset: {
+ changed: this._fieldChanged(record, side, field) ? 'true' : 'false',
+ },
+ }, [
+ el('span', { class: 'visual-diff__field-name', text: field }),
+ el('span', { class: 'visual-diff__field-value', text: String(value ?? '—') }),
+ ])
+ )
+ ),
+ ]);
+ }
+
+ _fieldChanged(record, side, field) {
+ if (record.status !== 'MISMATCH') return false;
+
+ const target = String(field).toLowerCase();
+
+ return (record.differences ?? []).some((difference) => {
+ const explicit = side === 'A' ? difference.fieldA : difference.fieldB;
+ if (explicit) return String(explicit).toLowerCase() === target;
+
+ const generic = String(difference.field ?? '').toLowerCase();
+ return generic === target;
+ });
+ }
+
+ _renderStatus(record) {
+ let label = t('visual.matched');
+
+ if (record.status === 'MISMATCH') label = t('visual.changed');
+ else if (record.status === 'ONLY_A') label = t('visual.onlyA');
+ else if (record.status === 'ONLY_B') label = t('visual.onlyB');
+ else if (record.status === 'DUPLICATE' || record.status === 'AMBIGUOUS') label = t('visual.ambiguous');
+
+ return el('div', {
+ class: 'visual-diff__status',
+ dataset: { status: record.status },
+ text: label,
+ });
+ }
+}
diff --git a/src/ui/renderers/dom.js b/src/ui/renderers/dom.js
new file mode 100644
index 0000000..e9d6da5
--- /dev/null
+++ b/src/ui/renderers/dom.js
@@ -0,0 +1,106 @@
+/**
+ * @file Safe DOM helpers. All imported cell values MUST go through here.
+ * Rule: never assign untrusted content to innerHTML.
+ */
+
+/**
+ * Create an element with attributes and children.
+ * @param {string} tag
+ * @param {Record} [attrs]
+ * @param {Array} [children]
+ * @returns {HTMLElement}
+ */
+export function el(tag, attrs = {}, children = []) {
+ const node = document.createElement(tag);
+
+ for (const [key, value] of Object.entries(attrs)) {
+ if (value === null || value === undefined || value === false) continue;
+
+ if (key === 'class') {
+ node.className = String(value);
+ } else if (key === 'text') {
+ // SAFE: only textContent, never innerHTML
+ node.textContent = String(value);
+ } else if (key === 'dataset' && typeof value === 'object') {
+ for (const [dk, dv] of Object.entries(value)) {
+ if (dv !== null && dv !== undefined) node.dataset[dk] = String(dv);
+ }
+ } else if (key.startsWith('on') && typeof value === 'function') {
+ node.addEventListener(key.slice(2).toLowerCase(), value);
+ } else if (key === 'html') {
+ // Explicitly forbidden — throw early to catch mistakes in dev
+ throw new Error('el(): use "text", not "html". Untrusted content must never be HTML.');
+ } else {
+ node.setAttribute(key, String(value));
+ }
+ }
+
+ for (const child of children) {
+ if (child === null || child === undefined || child === false) continue;
+ if (typeof child === 'string' || typeof child === 'number') {
+ node.appendChild(document.createTextNode(String(child)));
+ } else if (child instanceof Node) {
+ node.appendChild(child);
+ }
+ }
+
+ return node;
+}
+
+/**
+ * Replace all children of a container safely.
+ * @param {HTMLElement} container
+ * @param {Array} children
+ */
+export function replaceChildren(container, children) {
+ container.replaceChildren();
+ for (const child of children) {
+ if (child === null || child === undefined || child === false) continue;
+ if (typeof child === 'string' || typeof child === 'number') {
+ container.appendChild(document.createTextNode(String(child)));
+ } else if (child instanceof Node) {
+ container.appendChild(child);
+ }
+ }
+}
+
+/**
+ * Inline SVG icon factory. No external network dependency.
+ * @param {string} pathData
+ * @param {{ size?: number, className?: string }} [options]
+ * @returns {SVGElement}
+ */
+export function icon(pathData, { size = 16, className = '' } = {}) {
+ const SVG_NS = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(SVG_NS, 'svg');
+ svg.setAttribute('viewBox', '0 0 24 24');
+ svg.setAttribute('width', String(size));
+ svg.setAttribute('height', String(size));
+ svg.setAttribute('fill', 'none');
+ svg.setAttribute('stroke', 'currentColor');
+ svg.setAttribute('stroke-width', '1.75');
+ svg.setAttribute('stroke-linecap', 'round');
+ svg.setAttribute('stroke-linejoin', 'round');
+ svg.setAttribute('aria-hidden', 'true');
+ if (className) svg.setAttribute('class', className);
+
+ const path = document.createElementNS(SVG_NS, 'path');
+ path.setAttribute('d', pathData);
+ svg.appendChild(path);
+
+ return svg;
+}
+
+/** @type {Record} */
+export const Icons = Object.freeze({
+ upload: 'M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2',
+ file: 'M14 3v5h5M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8l-5-5z',
+ x: 'M6 6l12 12M18 6L6 18',
+ check: 'M4 12l5 5L20 6',
+ alert: 'M12 9v4m0 4h.01M10.29 3.86l-8.02 13.9a1.5 1.5 0 001.3 2.24h16.06a1.5 1.5 0 001.3-2.24l-8.02-13.9a1.5 1.5 0 00-2.6 0z',
+ shield: 'M12 2l8 4v6c0 5-3.5 9-8 10-4.5-1-8-5-8-10V6l8-4z',
+ arrowRight: 'M5 12h14m0 0l-6-6m6 6l-6 6',
+ refresh: 'M4 4v6h6M20 20v-6h-6M4 10a8 8 0 0114-4m2 8a8 8 0 01-14 4',
+ table: 'M3 3h18v18H3zM3 9h18M3 15h18M9 3v18M15 3v18',
+ zap: 'M13 2L3 14h8l-1 8 10-12h-8l1-8z',
+});
\ No newline at end of file
diff --git a/src/ui/renderers/format.js b/src/ui/renderers/format.js
new file mode 100644
index 0000000..028bfe6
--- /dev/null
+++ b/src/ui/renderers/format.js
@@ -0,0 +1,31 @@
+import { getLocale, t } from '../../i18n/index.js';
+
+/**
+ * @file Formatting utilities for displaying metadata.
+ */
+
+export function formatBytes(bytes) {
+ if (!Number.isFinite(bytes) || bytes < 0) return '—';
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
+ if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
+ return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
+}
+
+export function formatCount(n) {
+ if (n === null || n === undefined || !Number.isFinite(n)) return '—';
+ return new Intl.NumberFormat(getLocale()).format(n);
+}
+
+export function orDash(s) {
+ return s === null || s === undefined || s === '' ? '—' : String(s);
+}
+
+export function delimiterLabel(d) {
+ if (!d) return '—';
+ if (d === ',') return t('delimiter.comma');
+ if (d === '\t') return t('delimiter.tab');
+ if (d === ';') return t('delimiter.semicolon');
+ if (d === '|') return t('delimiter.pipe');
+ return '"' + d + '"';
+}
diff --git a/src/ui/views/comparing-view.js b/src/ui/views/comparing-view.js
new file mode 100644
index 0000000..666d1ab
--- /dev/null
+++ b/src/ui/views/comparing-view.js
@@ -0,0 +1,88 @@
+import { el, replaceChildren, icon, Icons } from '../renderers/dom.js';
+import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js';
+
+export class ComparingView {
+ constructor(store) {
+ this.store = store;
+ this.root = el('section', { class: 'app-main', hidden: '' });
+ store.addEventListener('state', () => this._sync());
+ store.addEventListener('change', () => this._sync());
+ window.addEventListener(LOCALE_CHANGE_EVENT, () => this._sync());
+ this._sync();
+ }
+
+ _sync() {
+ const active = this.store.state === 'COMPARING';
+ this.root.hidden = !active;
+ if (active) this._render();
+ }
+
+ _render() {
+ const progress = this.store.progress;
+ const determinate = progress &&
+ Number.isFinite(progress.completed) &&
+ Number.isFinite(progress.total) &&
+ progress.total > 0;
+
+ const percent = determinate
+ ? Math.min(100, Math.round((progress.completed / progress.total) * 100))
+ : null;
+
+ const phases = [
+ { id: 'parse-a', label: t('comparing.phase.parse-a') },
+ { id: 'parse-b', label: t('comparing.phase.parse-b') },
+ { id: 'index', label: t('comparing.phase.index') },
+ { id: 'compare', label: t('comparing.phase.compare') },
+ { id: 'finalize', label: t('comparing.phase.finalize') },
+ ];
+
+ const currentIndex = progress
+ ? phases.findIndex((phase) => phase.id === progress.phase)
+ : -1;
+
+ const title = currentIndex >= 0
+ ? phases[currentIndex].label
+ : t('comparing.preparing');
+
+ replaceChildren(this.root, [
+ el('div', { class: 'u-container' }, [
+ el('section', { class: 'comparing' }, [
+ 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') }),
+ el('div', {
+ class: 'comparing__bar',
+ role: 'progressbar',
+ 'aria-label': t('comparing.progress'),
+ 'aria-valuemin': '0',
+ 'aria-valuemax': '100',
+ ...(determinate ? { 'aria-valuenow': String(percent) } : {}),
+ }, [
+ el('div', {
+ class: 'comparing__bar-fill',
+ style: determinate ? 'width:' + percent + '%' : '',
+ dataset: { indeterminate: determinate ? 'false' : 'true' },
+ }),
+ ]),
+ el('ol', { class: 'comparing__phases' }, phases.map((phase, index) =>
+ el('li', {
+ class: 'comparing__phase',
+ dataset: {
+ state: index < currentIndex
+ ? 'done'
+ : index === currentIndex
+ ? 'active'
+ : 'pending',
+ },
+ }, [
+ el('span', { class: 'comparing__phase-dot' }, [
+ index < currentIndex ? icon(Icons.check, { size: 12 }) : null,
+ ]),
+ el('span', { class: 'comparing__phase-label', text: phase.label }),
+ ])
+ )),
+ ]),
+ ]),
+ ]);
+ }
+}
diff --git a/src/ui/views/landing-view.js b/src/ui/views/landing-view.js
new file mode 100644
index 0000000..3945018
--- /dev/null
+++ b/src/ui/views/landing-view.js
@@ -0,0 +1,46 @@
+import { el, replaceChildren, icon, Icons } from '../renderers/dom.js';
+import { FilePanel } from '../components/file-panel.js';
+import { PrivacyNote } from '../components/privacy-note.js';
+import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js';
+
+export class LandingView {
+ constructor(store) {
+ this.store = store;
+ this.filePanelA = new FilePanel({ slot: 'A' });
+ this.filePanelB = new FilePanel({ slot: 'B' });
+ this.root = el('section', { class: 'landing' });
+
+ this._render();
+ this._syncFromStore();
+
+ store.addEventListener('change', () => this._syncFromStore());
+ store.addEventListener('state', () => this._syncFromStore());
+ window.addEventListener(LOCALE_CHANGE_EVENT, () => this._render());
+ }
+
+ _render() {
+ replaceChildren(this.root, [
+ el('section', { class: 'landing__hero' }, [
+ el('h1', { class: 'landing__title' }, [
+ t('landing.titlePrefix'),
+ el('span', { class: 'landing__title-accent', text: t('landing.titleAccent') }),
+ ]),
+ el('p', { class: 'landing__subtitle', text: t('landing.subtitle') }),
+ ]),
+ el('section', { class: 'landing__workflow', 'aria-label': t('landing.fileSelection') }, [
+ this.filePanelA.root,
+ el('div', { class: 'landing__connector', 'aria-hidden': 'true' }, [icon(Icons.arrowRight, { size: 18 })]),
+ this.filePanelB.root,
+ ]),
+ el('div', { class: 'landing__footer' }, [PrivacyNote()]),
+ ]);
+ }
+
+ _syncFromStore() {
+ if (this.store.fileLoadingA) this.filePanelA.setLoading();
+ else this.filePanelA.setInspection(this.store.fileA);
+
+ if (this.store.fileLoadingB) this.filePanelB.setLoading();
+ else this.filePanelB.setInspection(this.store.fileB);
+ }
+}
diff --git a/src/ui/views/mapping-view.js b/src/ui/views/mapping-view.js
new file mode 100644
index 0000000..8ae1fa0
--- /dev/null
+++ b/src/ui/views/mapping-view.js
@@ -0,0 +1,21 @@
+import { el } from '../renderers/dom.js';
+import { MappingEditor } from '../components/mapping-editor.js';
+
+export class MappingView {
+ constructor(store, service) {
+ this.store = store;
+ this.editor = new MappingEditor(store, service);
+
+ this.root = el('section', { class: 'app-main', hidden: '' }, [
+ el('div', { class: 'u-container' }, [this.editor.root]),
+ ]);
+
+ this._sync();
+ store.addEventListener('state', () => this._sync());
+ }
+
+ _sync() {
+ const active = ['MAPPING', 'READY_TO_COMPARE'].includes(this.store.state);
+ this.root.hidden = !active;
+ }
+}
diff --git a/src/ui/views/results-view.js b/src/ui/views/results-view.js
new file mode 100644
index 0000000..087ee62
--- /dev/null
+++ b/src/ui/views/results-view.js
@@ -0,0 +1,101 @@
+import { el, replaceChildren, icon, Icons } from '../renderers/dom.js';
+import { VisualDiff } from '../components/visual-diff.js';
+import { AppEvents } from '../../app/app-events.js';
+import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js';
+
+const ATTENTION_STATUSES = new Set([
+ 'MISMATCH',
+ 'ONLY_A',
+ 'ONLY_B',
+ 'DUPLICATE',
+ 'AMBIGUOUS',
+]);
+
+export class ResultsView {
+ constructor(store) {
+ this.store = store;
+ this.root = el('section', { class: 'app-main', hidden: '' });
+
+ store.addEventListener('state', () => this._sync());
+ store.addEventListener('change', () => this._sync());
+ window.addEventListener(LOCALE_CHANGE_EVENT, () => this._sync());
+
+ this._sync();
+ }
+
+ _sync() {
+ const active = this.store.state === 'RESULTS' && this.store.result;
+ this.root.hidden = !active;
+
+ if (!active) {
+ replaceChildren(this.root, []);
+ return;
+ }
+
+ this._render();
+ }
+
+ _render() {
+ const records = this.store.result.records ?? [];
+ const attentionCount = records.filter((record) =>
+ ATTENTION_STATUSES.has(record.status)
+ ).length;
+
+ const title = attentionCount > 0
+ ? t('visual.differencesFound')
+ : t('visual.filesMatch');
+
+ const subtitle = attentionCount > 0
+ ? t('visual.diffSubtitle', { count: attentionCount })
+ : t('visual.matchSubtitle');
+
+ const visualDiff = new VisualDiff(this.store);
+
+ replaceChildren(this.root, [
+ el('div', { class: 'u-container visual-results' }, [
+ el('header', { class: 'visual-results__header' }, [
+ el('div', { class: 'visual-results__heading' }, [
+ el('span', {
+ class: 'visual-results__auto',
+ text: t('visual.autoCompared'),
+ }),
+ el('h2', {
+ class: 'visual-results__title',
+ text: title,
+ }),
+ el('p', {
+ class: 'visual-results__subtitle',
+ text: subtitle,
+ }),
+ ]),
+ el('div', { class: 'visual-results__actions' }, [
+ el('button', {
+ type: 'button',
+ class: 'btn btn--ghost',
+ onclick: () => window.dispatchEvent(
+ new CustomEvent(AppEvents.EXPERT_MAPPING_REQUESTED)
+ ),
+ }, [t('visual.expert')]),
+ el('button', {
+ type: 'button',
+ class: 'btn btn--ghost',
+ onclick: () => window.dispatchEvent(
+ new CustomEvent(AppEvents.RESET_REQUESTED)
+ ),
+ }, [icon(Icons.refresh, { size: 14 }), t('results.startOver')]),
+ el('button', {
+ type: 'button',
+ class: 'btn btn--primary',
+ onclick: () => window.dispatchEvent(
+ new CustomEvent(AppEvents.EXPORT_REQUESTED, {
+ detail: { format: 'csv' },
+ })
+ ),
+ }, [icon(Icons.table, { size: 14 }), t('results.exportCsv')]),
+ ]),
+ ]),
+ visualDiff.root,
+ ]),
+ ]);
+ }
+}
diff --git a/tests/browser-comparison-service.test.js b/tests/browser-comparison-service.test.js
new file mode 100644
index 0000000..d697ac5
--- /dev/null
+++ b/tests/browser-comparison-service.test.js
@@ -0,0 +1,79 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+import { BrowserComparisonService } from '../src/services/browser-comparison-service.js';
+
+const here = dirname(fileURLToPath(import.meta.url));
+
+async function fixture(name) {
+ return readFile(resolve(here, 'fixtures', name), 'utf8');
+}
+
+function fakeFile(name, text) {
+ return {
+ name,
+ size: Buffer.byteLength(text),
+ text: async () => text,
+ };
+}
+
+test('real browser service inspects files, suggests mappings and compares actual rows', async () => {
+ const service = new BrowserComparisonService();
+ const textA = await fixture('file-a.csv');
+ const textB = await fixture('file-b.csv');
+
+ const fileA = await service.inspectFile(fakeFile('file-a.csv', textA), { slot: 'A' });
+ const fileB = await service.inspectFile(fakeFile('file-b.csv', textB), { slot: 'B' });
+
+ assert.equal(fileA.rowCount, 12);
+ assert.equal(fileB.rowCount, 12);
+
+ const suggestions = await service.suggestMappings(fileA, fileB);
+
+ assert.equal(suggestions[0].columnA, 'Reference');
+ assert.equal(suggestions[0].columnB, 'Payment Ref');
+ assert.ok(suggestions.some((item) => item.columnA === 'Amount' && item.columnB === 'Total'));
+ assert.ok(suggestions.some((item) => item.columnA === 'Date' && item.columnB === 'Paid Date'));
+
+ const key = suggestions[0];
+ const comparisons = suggestions.slice(1).map((item) => ({
+ columnA: item.columnA,
+ columnB: item.columnB,
+ type: fileA.columns.find((column) => column.id === item.columnA)?.inferredType ?? 'text',
+ tolerance: { mode: 'absolute', value: 0.01 },
+ }));
+
+ const result = await service.compare({
+ fileAId: fileA.id,
+ fileBId: fileB.id,
+ mapping: {
+ keys: [{ columnA: key.columnA, columnB: key.columnB, type: 'text' }],
+ comparisons,
+ displayOnly: { fileA: [], fileB: [] },
+ normalization: {
+ trimText: true,
+ caseInsensitive: true,
+ collapseWhitespace: true,
+ numberLocale: 'auto',
+ dateFormatA: null,
+ dateFormatB: null,
+ },
+ },
+ });
+
+ assert.ok(result.records.some((record) => record.status === 'MISMATCH' && record.keyLabel === 'inv-1002'));
+ assert.ok(result.records.some((record) => record.status === 'ONLY_A' && record.keyLabel === 'inv-1003'));
+ assert.ok(result.records.some((record) => record.status === 'ONLY_B' && record.keyLabel === 'inv-1004'));
+ assert.ok(result.records.some((record) => record.status === 'DUPLICATE' && record.keyLabel === 'inv-1005'));
+ assert.ok(result.records.some((record) => record.status === 'AMBIGUOUS' && record.keyLabel === 'inv-1006'));
+ assert.ok(result.records.some((record) => record.status === 'MATCHED' && record.keyLabel === 'inv-1007'));
+
+ const inv1010 = result.records.find((record) => record.keyLabel === 'inv-1010');
+ assert.equal(inv1010.status, 'MATCHED');
+
+ const inv1011 = result.records.find((record) => record.keyLabel === 'inv-1011');
+ assert.equal(inv1011.status, 'MISMATCH');
+});
diff --git a/tests/delimited-parser.test.js b/tests/delimited-parser.test.js
new file mode 100644
index 0000000..270e082
--- /dev/null
+++ b/tests/delimited-parser.test.js
@@ -0,0 +1,29 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ detectDelimiter,
+ parseDelimitedText,
+ tableFromText,
+} from '../src/engine/delimited-parser.js';
+
+test('detects semicolon delimiter', () => {
+ const text = 'a;b;c\n1;2;3\n4;5;6\n';
+ assert.equal(detectDelimiter(text), ';');
+});
+
+test('parses quoted commas and multiline values', () => {
+ const text = 'id,note\n1,"hello, world"\n2,"line one\nline two"\n';
+ const rows = parseDelimitedText(text, ',');
+
+ assert.deepEqual(rows[1], ['1', 'hello, world']);
+ assert.deepEqual(rows[2], ['2', 'line one\nline two']);
+});
+
+test('strips UTF-8 BOM and infers basic types', () => {
+ const table = tableFromText('\uFEFFid,date,amount\nA1,2026-09-26,12.50\n');
+
+ assert.equal(table.columns[1].inferredType, 'date');
+ assert.equal(table.columns[2].inferredType, 'number');
+ assert.equal(table.rows[0].id, 'A1');
+});
diff --git a/tests/fixtures/file-a.csv b/tests/fixtures/file-a.csv
new file mode 100644
index 0000000..aa6e64d
--- /dev/null
+++ b/tests/fixtures/file-a.csv
@@ -0,0 +1,13 @@
+Reference,Date,Amount,Description
+INV-1001,2026-09-15,125.00,Consulting
+INV-1002,2026-09-18,125.00,Design
+INV-1003,2026-09-20,89.50,Hosting
+INV-1005,2026-09-21,50.00,Duplicate A1
+INV-1005,2026-09-21,50.00,Duplicate A2
+INV-1006,2026-09-22,75.00,Ambiguous A1
+INV-1006,2026-09-22,75.00,Ambiguous A2
+ inv-1007 ,2026-09-23,199.99,Whitespace
+INV-1008,2026-09-24,49.90,Date mismatch
+INV-1009,2026-09-25,300.00,Exact match
+INV-1010,2026-09-25,100.00,Tolerance
+INV-1011,2026-09-25,100.00,Outside tolerance
diff --git a/tests/fixtures/file-b.csv b/tests/fixtures/file-b.csv
new file mode 100644
index 0000000..36f5776
--- /dev/null
+++ b/tests/fixtures/file-b.csv
@@ -0,0 +1,13 @@
+Payment Ref,Paid Date,Total,Customer
+INV-1001,2026-09-15,125.00,Acme
+INV-1002,2026-09-18,152.00,Acme
+INV-1004,2026-09-22,45.00,Beta
+INV-1005,2026-09-21,50.00,Duplicate B
+INV-1006,2026-09-22,75.00,Ambiguous B1
+INV-1006,2026-09-22,75.00,Ambiguous B2
+INV-1007,2026-09-23,199.99,Whitespace
+INV-1008,2026-09-25,49.90,Date mismatch
+INV-1009,2026-09-25,300.00,Exact match
+INV-1010,2026-09-25,100.005,Tolerance
+INV-1011,2026-09-25,100.05,Outside tolerance
+INV-1013,2026-09-26,18.40,Only B
diff --git a/vite.config.js b/vite.config.js
index 879d500..8bc6797 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,8 +1,16 @@
-import { defineConfig } from "vite";
+import { defineConfig } from 'vite';
export default defineConfig({
+ base: './',
build: {
- outDir: "dist",
- emptyOutDir: true
- }
-});
+ target: 'es2022',
+ outDir: 'dist',
+ assetsDir: 'assets',
+ sourcemap: false,
+ cssCodeSplit: false,
+ },
+ server: {
+ port: 5173,
+ open: true,
+ },
+});
\ No newline at end of file