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) + +![Two-Export Comparator — vizuāls salīdzinājums blakus](docs/assets/hero.svg) + +> **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-Export Comparator — side-by-side visual diff](docs/assets/hero.svg) + +> **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