diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..ccaced5
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,16 @@
+# EditorConfig — https://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{yml,yaml}]
+indent_size = 2
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 0000000..6081c86
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,6 @@
+# Revisions to ignore in `git blame` — bulk reformats with no logic changes.
+# Enable locally with: git config blame.ignoreRevsFile .git-blame-ignore-revs
+# (GitHub applies it automatically.)
+
+# Phase E2 — prettier --write across the repo
+91c11503a665307c4ce2f5bb5b6a15ef2e16fa75
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..c4dc6c4
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,25 @@
+---
+name: Bug report
+about: Something isn't working as expected
+title: ''
+labels: bug
+assignees: ''
+---
+
+**What happened**
+A clear description of the bug.
+
+**Steps to reproduce** 1. 2. 3.
+
+**Expected behaviour**
+What you expected to happen instead.
+
+**Environment**
+
+- Build SHA (from the About page):
+- Browser / OS:
+- Mode: local-only / signed-in
+
+**Notes**
+Screenshots, console errors, or anything else useful. Please do **not** paste
+confidential source text.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..245c5e5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for Verbalis
+title: ''
+labels: enhancement
+assignees: ''
+---
+
+**The problem**
+What are you trying to do that Verbalis makes hard today?
+
+**Proposed solution**
+What you'd like to see. Keep in mind Verbalis is local-first and privacy-first —
+features should respect that (nothing leaves the device unless the user asks).
+
+**Alternatives considered**
+Any workarounds or other approaches.
+
+**Notes**
+Anything else — prior art in memoQ / Trados / OmegaT, mockups, etc.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..eb6eddd
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,23 @@
+## Summary
+
+
+
+## Changes
+
+
+
+-
+
+## Testing
+
+
+
+- [ ] `pnpm typecheck`
+- [ ] `pnpm lint`
+- [ ] `pnpm test:unit`
+- [ ] `pnpm test:e2e` (for UI-facing changes)
+- [ ] `pnpm build`
+
+## Notes
+
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 64d7fc9..c8d82e9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,6 +29,8 @@ jobs:
- run: pnpm install
- run: pnpm generate-icons
- run: pnpm typecheck
+ - run: pnpm lint
+ - run: pnpm format:check
- run: pnpm test:unit
- run: pnpm build
# Regression guard for the cloud path (ROADMAP §3.1): build once with the
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..209e3ef
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+20
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..aa70a3f
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,8 @@
+dist
+dev-dist
+coverage
+node_modules
+pnpm-lock.yaml
+src-tauri/target
+public/dictionaries
+public/corpora
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..6c900fd
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,8 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "trailingComma": "all",
+ "printWidth": 100,
+ "tabWidth": 2,
+ "arrowParens": "always"
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 03adc8d..c5ee1a9 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,4 +1,4 @@
{
- "IDX.aI.enableInlineCompletion": true,
- "IDX.aI.enableCodebaseIndexing": true
-}
\ No newline at end of file
+ "IDX.aI.enableInlineCompletion": true,
+ "IDX.aI.enableCodebaseIndexing": true
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..7387e4f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,60 @@
+# Changelog
+
+Notable changes to Verbalis. Format loosely follows
+[Keep a Changelog](https://keepachangelog.com/).
+
+## [Unreleased] — polish pass ("Snow Leopard")
+
+A refinement release: correctness, coherence, and repo standards — no new
+features.
+
+### Fixed
+
+- Contact email corrected to `pedrobritx@gmail.com` across the app, README and
+ LICENSE.
+- Sidebar / status-filter active highlight now adapts to the light theme (it
+ hardcoded the dark-theme accent) via a new `--color-accent-subtle` token.
+- Top-bar breadcrumb no longer shows raw paths for `/corpora`, `/addons`,
+ `/guide`, `/about`.
+
+### Changed
+
+- Personal / portfolio links now point at the `britx.me` domain.
+- **Quick lookup follows the open project's languages.** Opening lookup from
+ inside a project defaults its source/target to that project instead of the
+ global lookup defaults; the import dialog seeds its pair from your history
+ (distinct `pt→en` fallback for this PT↔EN-oriented tool) instead of a fixed
+ `en→es`, and remembers the pair you import with.
+- **Signed-in users are attributed by their account name.** Tracked changes,
+ comments, collaboration presence and version sign-off use your account name
+ automatically; Settings no longer asks for a separate local name when signed
+ in. The device-local author id is unchanged.
+- The first-run welcome screen is a proper hero (brand, value props, three-step
+ flow); shared links and PWA installs get Open Graph / Twitter / theme-color
+ metadata.
+
+### Added
+
+- **Tooling the repo lacked:** ESLint (flat config) + Prettier + knip +
+ `.editorconfig` / `.nvmrc`, with `lint` and `format:check` wired into CI.
+- Contributor hygiene: `CONTRIBUTING.md`, `SECURITY.md`, PR + issue templates,
+ and a `docs/` index.
+
+### Removed
+
+- Dead code: an unused `tooltip` UI primitive, an orphaned `dictionary` feature,
+ an unused schema barrel, and the unused `jimp` / `@radix-ui/react-tooltip`
+ dependencies.
+
+### Internal
+
+- Name-fallback logic centralised in `features/account/displayName.ts`.
+- Repo-wide `prettier --write` isolated in one commit (see
+ `.git-blame-ignore-revs`).
+- Historical planning docs moved under `docs/history/`.
+
+---
+
+Earlier history (the six-milestone Verbalis 2.0 "Translation IDE" revamp —
+tracked changes, comments, accounts, real-time collaboration, roles, extensions
+& connectors) is recorded in [`docs/revamp/STATUS.md`](docs/revamp/STATUS.md).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..f795a4f
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,59 @@
+# Contributing to Verbalis
+
+Thanks for your interest in Verbalis. A few things to know before you start.
+
+## License context
+
+Verbalis is **source-available, not open source** (see [`LICENSE.md`](LICENSE.md)).
+The source is here so you can read it, self-host it, and use it as an individual
+for free. Contributions are welcome, but by opening a pull request you agree that
+your contribution is licensed under the same terms and that the maintainer may
+relicense it as part of the project. For anything substantial, please open an
+issue to discuss first.
+
+## Development
+
+Verbalis is a local-first React + TypeScript + Vite app. It runs 100% locally
+with no account or backend.
+
+```bash
+pnpm install
+pnpm dev # start the dev server
+pnpm test:unit # unit tests (vitest)
+pnpm test:e2e # end-to-end (playwright)
+pnpm typecheck # tsc, no emit
+pnpm lint # eslint
+pnpm format:check # prettier check
+pnpm build # production build
+```
+
+- **Node** 20 (see `.nvmrc`) and **pnpm** (pinned via `packageManager` in
+ `package.json`).
+- The optional signed-in mode is behind `VITE_SUPABASE_*` — leave it unset to
+ develop the local-only path. See [`docs/cloud.md`](docs/cloud.md).
+
+## Before you open a PR
+
+Every change must be green:
+
+```bash
+pnpm typecheck && pnpm lint && pnpm test:unit && pnpm build
+```
+
+Run `pnpm test:e2e` too for anything UI-facing. CI runs all of these.
+
+## Conventions
+
+- **Keep `core/*` pure.** Business logic goes in `src/core` as pure functions
+ with injected I/O (`fetchImpl`, clients); push side effects to the edges. This
+ is why the test suite is strong — keep it that way.
+- **The optional cloud must tree-shake to zero** when unconfigured. Load Supabase
+ only through the dynamic `getSupabase()` path; never import
+ `@supabase/supabase-js` statically.
+- **Match the surrounding code** — its naming, comment density, and idioms.
+- Architecture notes live in [`docs/architecture.md`](docs/architecture.md).
+
+## Reporting bugs / requesting features
+
+Use the issue templates. For anything security-related, follow
+[`SECURITY.md`](SECURITY.md) instead of opening a public issue.
diff --git a/LICENSE.md b/LICENSE.md
index 18aa994..d0ea845 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -33,8 +33,8 @@ licence to use, copy, modify, and self-host the Software for their own work,
> existing "Created by Pedro Brito" credit and the link to
> intact in the application's
> About/credits screen, and — where you publish work, a fork, or a derivative
-> built on the Software — state that it is *"powered by Verbalis, created by
-> Pedro Brito"* with a link back to this repository.
+> built on the Software — state that it is _"powered by Verbalis, created by
+> Pedro Brito"_ with a link back to this repository.
That attribution is the only thing asked of Individuals.
@@ -49,7 +49,7 @@ If you are an Organization (any entity larger than one person), please contact
the Author to arrange terms. Pricing is set individually and scaled to the size
and nature of your use:
-- **Email:** pedrohbrito@me.com
+- **Email:** pedrobritx@gmail.com
- **GitHub:**
- **LinkedIn:**
@@ -78,6 +78,6 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
-*Not sure which tier applies to you? If you are one person using Verbalis for
+_Not sure which tier applies to you? If you are one person using Verbalis for
your own translation work, you are covered for free under Section 2 — just keep
-the credit. Anything bigger, reach out and we'll sort out fair terms.*
+the credit. Anything bigger, reach out and we'll sort out fair terms._
diff --git a/README.md b/README.md
index 40d2cc6..6777d3c 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,7 @@ license — see [`LICENSE.md`](LICENSE.md):
**credit the developer** and keep the link back to this project.
- **Organizations — get in touch.** Any team or company larger than one person
needs a commercial license. Pricing is set individually and fairly —
- contact **pedrohbrito@me.com**.
+ contact **pedrobritx@gmail.com**.
---
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..92d84aa
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,34 @@
+# Security Policy
+
+Verbalis is a privacy-first, local-first CAT tool: by design, your files,
+translation memory and glossaries stay in your browser. Security reports are
+taken seriously.
+
+## Reporting a vulnerability
+
+**Please do not open a public issue for security vulnerabilities.**
+
+Instead, email **pedrobritx@gmail.com** with:
+
+- a description of the issue and its impact,
+- steps to reproduce (a proof of concept if you have one),
+- the affected version / build (the About page shows the build SHA).
+
+You can expect an acknowledgement within a few days. Please give a reasonable
+window to investigate and ship a fix before any public disclosure.
+
+## Scope
+
+Especially relevant for this project:
+
+- anything that could cause user data (source text, TM, glossaries) to **leave
+ the device** unexpectedly in local-only mode,
+- issues in the optional signed-in mode (Supabase auth, RLS, cloud sync,
+ collaboration) that could expose one user's data to another,
+- the storage-connector OAuth flows (Google Drive, OneDrive).
+
+## Out of scope
+
+- Vulnerabilities in third-party dependencies without a demonstrated exploit in
+ Verbalis (report those upstream).
+- Anything requiring physical access to an unlocked device.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..394122a
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,40 @@
+# Verbalis documentation
+
+An index of what lives here and whether it's current.
+
+## Living reference
+
+Keep these up to date as the app changes.
+
+- [`architecture.md`](architecture.md) — tech stack, folder map, storage schema
+ (Dexie v7), deployment, and a walk through the major subsystems.
+- [`cloud.md`](cloud.md) — the optional signed-in mode: Supabase setup, auth
+ providers, sync, cloud projects, collaboration, and the storage connectors
+ (Google Drive / OneDrive), with per-feature manual-verification matrices.
+
+## Working notes
+
+- [`refactor-audit.md`](refactor-audit.md) — a rolling health snapshot and
+ prioritised backlog of polish / refactor opportunities.
+
+## Revamp record (Verbalis 2.0)
+
+The six-milestone "Translation IDE" revamp that added tracked changes, comments,
+accounts, real-time collaboration, roles, and extensions. **Done** — kept as the
+build log, not a directive.
+
+- [`revamp/ROADMAP.md`](revamp/ROADMAP.md) — the canonical scope + locked
+ decisions (D1–D9).
+- [`revamp/STATUS.md`](revamp/STATUS.md) — the phase-by-phase build log.
+- [`revamp/prompts/`](revamp/prompts/) — the per-phase build prompts
+ (scaffolding; historical).
+
+## Historical / superseded
+
+Pre-build planning. Describes the _intended_ product; the shipped app is the
+source of truth now. Kept for context.
+
+- [`history/vision/`](history/vision/) — the original product/UX/architecture
+ vision documents.
+- [`history/roadmap-professional-features.md`](history/roadmap-professional-features.md)
+ — the pre-revamp "road to a professional CAT tool" plan (largely delivered).
diff --git a/docs/architecture.md b/docs/architecture.md
index d588438..278b7f4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -6,20 +6,20 @@ VERBALIS is a local-first, browser-native CAT (Computer-Assisted Translation) to
## Tech Stack
-| Layer | Choice | Notes |
-|---|---|---|
-| Framework | React 18 + TypeScript | Strict mode, full type safety |
-| Bundler | Vite 6 | base: '/' for the custom-domain root (override with `BASE_PATH` for subdirectory builds) |
-| Styling | Tailwind CSS 3 + shadcn/ui | Owned components, Radix primitives |
-| State | Zustand (UI) + TanStack Query v5 (async) | No Redux boilerplate |
-| Routing | React Router v6 HashRouter | GH Pages SPA compatibility |
-| Storage | Dexie.js v4 (IndexedDB) | Promise API, live hooks, migrations |
-| Parsing | unified + remark-parse + remark-gfm + sbd | AST-based, not regex |
-| DOCX | mammoth | Only viable browser-side parser |
-| Fuzzy search | Fuse.js (TM) + MiniSearch (terminology) | Different tools for different jobs |
-| Workers | Comlink | Makes Web Worker calls look like async functions |
-| PWA | vite-plugin-pwa (Workbox) | Offline support, installable |
-| Fonts | geist npm package | Self-hosted, works fully offline |
+| Layer | Choice | Notes |
+| ------------ | ----------------------------------------- | ---------------------------------------------------------------------------------------- |
+| Framework | React 18 + TypeScript | Strict mode, full type safety |
+| Bundler | Vite 6 | base: '/' for the custom-domain root (override with `BASE_PATH` for subdirectory builds) |
+| Styling | Tailwind CSS 3 + shadcn/ui | Owned components, Radix primitives |
+| State | Zustand (UI) + TanStack Query v5 (async) | No Redux boilerplate |
+| Routing | React Router v6 HashRouter | GH Pages SPA compatibility |
+| Storage | Dexie.js v4 (IndexedDB) | Promise API, live hooks, migrations |
+| Parsing | unified + remark-parse + remark-gfm + sbd | AST-based, not regex |
+| DOCX | mammoth | Only viable browser-side parser |
+| Fuzzy search | Fuse.js (TM) + MiniSearch (terminology) | Different tools for different jobs |
+| Workers | Comlink | Makes Web Worker calls look like async functions |
+| PWA | vite-plugin-pwa (Workbox) | Offline support, installable |
+| Fonts | geist npm package | Self-hosted, works fully offline |
## Key Folder Map
@@ -41,22 +41,22 @@ src/
## Storage Schema (v7)
-| Table | Indexes | Since |
-|---|---|---|
-| projects | id, name, updatedAt | v1 |
-| segments | id, projectId, index, status, [projectId+status], [projectId+index] | v1 (compound idx v4) |
-| tm | id, source, sourceLang, targetLang, projectId, corpusId, updatedAt | v1 |
-| glossary | id, term, projectId, updatedAt | v1 |
-| settings | &key | v2 |
-| embeddings | id, tmId, model, [tmId+model] | v2 |
-| corpusTerms | id, corpusId | v3 |
-| corpusPacks | id | v3 |
-| projectTemplates | projectId | v4 |
-| versions | id, projectId, createdAt, [projectId+createdAt] | v5 |
-| documents | id, projectId | v6 |
-| blocks | id, documentId, projectId, [documentId+index] | v6 |
-| assets | id, documentId, projectId | v6 |
-| syncTombstones | [resource+rowId], resource, deletedAt | v7 |
+| Table | Indexes | Since |
+| ---------------- | ------------------------------------------------------------------- | -------------------- |
+| projects | id, name, updatedAt | v1 |
+| segments | id, projectId, index, status, [projectId+status], [projectId+index] | v1 (compound idx v4) |
+| tm | id, source, sourceLang, targetLang, projectId, corpusId, updatedAt | v1 |
+| glossary | id, term, projectId, updatedAt | v1 |
+| settings | &key | v2 |
+| embeddings | id, tmId, model, [tmId+model] | v2 |
+| corpusTerms | id, corpusId | v3 |
+| corpusPacks | id | v3 |
+| projectTemplates | projectId | v4 |
+| versions | id, projectId, createdAt, [projectId+createdAt] | v5 |
+| documents | id, projectId | v6 |
+| blocks | id, documentId, projectId, [documentId+index] | v6 |
+| assets | id, documentId, projectId | v6 |
+| syncTombstones | [resource+rowId], resource, deletedAt | v7 |
Migrations are handled by Dexie's versioning system (`this.version(N).stores(...)`). Always increment, never modify existing version blocks. Notable steps: v3 adds the bundled-corpora tables + a `corpusId` index on `tm`; v4 adds compound segment indexes and moves the XLIFF template blob to its own table; v5 adds version snapshots; v6 adds the document/block model (backfilled from `sourceMeta.blockIndex`); v7 adds `updatedAt` indexes on `tm`/`glossary` and the `syncTombstones` table for the personal-resource cloud reconciler.
@@ -67,6 +67,7 @@ Migrations are handled by Dexie's versioning system (`this.version(N).stores(...
Static build → GitHub Actions → GitHub Pages at `https://verbalis.britx.me/`.
Critical GH Pages constraints:
+
- `base: '/'` in vite.config.ts, overridable via the `BASE_PATH` env var (e.g.
`BASE_PATH=/verbalis/ pnpm build`) for anyone forking this project into a
path-based GitHub Pages deployment instead
@@ -82,17 +83,17 @@ DOCX import uses `mammoth.convertToHtml`, then a small DOM walker (`src/core/seg
## Phase Roadmap
-| Phase | Scope |
-|---|---|
-| 0 | Foundation — scaffold, CI/CD, PWA, app shell ✅ |
-| 1 | TXT + MD import, segmentation, side-by-side editor ✅ |
-| 2 | Translation Memory — store, exact/fuzzy match, TMX import/export ✅ |
-| 3 | Terminology — glossary CRUD, CSV + TBX I/O, inline editor panel, Wiktionary adapter ✅ |
-| 4 | DOCX import, command palette, review modes ✅ |
-| 5 | PWA hardening, offline edge cases, update notification ✅ |
-| 6 | AI integrations (Ollama, Claude, LibreTranslate), semantic TM ✅ |
-| 7+ | Project-level exports, terminology extraction, collaborative TM |
-| 8+ | Professional CAT features — rich editor, segment handling, versioning, LAN collaboration, document standards. See [`roadmap-professional-features.md`](roadmap-professional-features.md) |
+| Phase | Scope |
+| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 0 | Foundation — scaffold, CI/CD, PWA, app shell ✅ |
+| 1 | TXT + MD import, segmentation, side-by-side editor ✅ |
+| 2 | Translation Memory — store, exact/fuzzy match, TMX import/export ✅ |
+| 3 | Terminology — glossary CRUD, CSV + TBX I/O, inline editor panel, Wiktionary adapter ✅ |
+| 4 | DOCX import, command palette, review modes ✅ |
+| 5 | PWA hardening, offline edge cases, update notification ✅ |
+| 6 | AI integrations (Ollama, Claude, LibreTranslate), semantic TM ✅ |
+| 7+ | Project-level exports, terminology extraction, collaborative TM |
+| 8+ | Professional CAT features — rich editor, segment handling, versioning, LAN collaboration, document standards. See [`history/roadmap-professional-features.md`](history/roadmap-professional-features.md) |
## Phase 5 — PWA Layer
diff --git a/docs/cloud.md b/docs/cloud.md
index 3301120..09da39c 100644
--- a/docs/cloud.md
+++ b/docs/cloud.md
@@ -15,10 +15,10 @@ never talks to a real Supabase instance.
The live Verbalis project is already provisioned:
-| | |
-| --- | --- |
-| Project ref | `qutcuzlppbjbsymowavc` (region `us-east-2`, Postgres 17) |
-| `VITE_SUPABASE_URL` | `https://qutcuzlppbjbsymowavc.supabase.co` |
+| | |
+| ------------------------ | ----------------------------------------------------------------------------------- |
+| Project ref | `qutcuzlppbjbsymowavc` (region `us-east-2`, Postgres 17) |
+| `VITE_SUPABASE_URL` | `https://qutcuzlppbjbsymowavc.supabase.co` |
| `VITE_SUPABASE_ANON_KEY` | `sb_publishable_c8YdrT2-abKdW1Kl7FDKyA_v5OwqLdY` (publishable — safe in the bundle) |
These two values are also in `.env.example` — copy it to `.env.local` for local
@@ -35,8 +35,8 @@ production, the repository Variables in §1.
Set these at build time (e.g. in the CI/deploy environment). Leaving
`VITE_SUPABASE_URL` or `VITE_SUPABASE_ANON_KEY` unset keeps the app local-only.
-**Production (GitHub Pages / `deploy.yml`)**: add them under *Settings → Secrets
-and variables → Actions*. A repository **Variable** is the natural home (all
+**Production (GitHub Pages / `deploy.yml`)**: add them under _Settings → Secrets
+and variables → Actions_. A repository **Variable** is the natural home (all
three are public — they end up in the client bundle), but `deploy.yml` accepts
either a Variable or a Secret via a fallback chain, and also accepts the URL
under the unprefixed name `SUPABASE_URL`:
@@ -51,11 +51,11 @@ build; the `sb_secret_...` key must never reach the client bundle.
**Local dev**: `cp .env.example .env.local` (git-ignored) and run `pnpm dev`.
-| Variable | Required | Purpose |
-| --- | --- | --- |
-| `VITE_SUPABASE_URL` | yes (to enable cloud) | Supabase project URL, e.g. `https://xxxx.supabase.co` |
-| `VITE_SUPABASE_ANON_KEY` | yes (to enable cloud) | The project's anon / publishable key (safe to ship in a client bundle) |
-| `VITE_AUTH_PROVIDERS` | no | Comma-separated OAuth providers to show, using **Supabase provider ids**: `google,azure,apple`. Defaults to `google`. Providers not configured server-side are simply hidden. |
+| Variable | Required | Purpose |
+| ------------------------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `VITE_SUPABASE_URL` | yes (to enable cloud) | Supabase project URL, e.g. `https://xxxx.supabase.co` |
+| `VITE_SUPABASE_ANON_KEY` | yes (to enable cloud) | The project's anon / publishable key (safe to ship in a client bundle) |
+| `VITE_AUTH_PROVIDERS` | no | Comma-separated OAuth providers to show, using **Supabase provider ids**: `google,azure,apple`. Defaults to `google`. Providers not configured server-side are simply hidden. |
> Note on provider ids: Microsoft is `azure` (not `microsoft`) in Supabase Auth.
@@ -92,7 +92,7 @@ and safe to re-run:
allowlist lives in `src/storage/cloud/settingsSync.ts`).
- `0004_personal_resources.sql` — the user's personal term bank + TM
(`personal_glossary` / `personal_tm`, each `id, user_id, updated_at, deleted,
- payload jsonb`) + owner-scoped RLS. A generic cursor-based reconciler
+payload jsonb`) + owner-scoped RLS. A generic cursor-based reconciler
(`src/storage/cloud/rowSync.ts`) syncs them with per-row LWW and soft-delete
tombstones. Backs Phase 3.4; **bundled corpora never sync** (corpus-seeded TM
carries a `corpusId` and is filtered out).
@@ -105,32 +105,32 @@ and safe to re-run:
`has_project_role` RLS helpers into a non-API `private` schema so they are not
reachable as PostgREST RPCs (clears the database linter). Apply after `0005`.
- `0007_compaction.sql` — the `claim_compaction(project_id, expected_seq, state,
- up_to_id)` RPC (SECURITY DEFINER, membership-checked): optimistically installs
+up_to_id)` RPC (SECURITY DEFINER, membership-checked): optimistically installs
a fresh compacted `ydoc_state` snapshot (guarded by the `seq` generation) and
prunes the subsumed `ydoc_updates` rows, so the append log stays bounded. Backs
- Phase 4.3. Apply after `0006`. *(Numbering note: ROADMAP §4.3 sketched
+ Phase 4.3. Apply after `0006`. _(Numbering note: ROADMAP §4.3 sketched
`0005_compaction.sql`, but `0005`/`0006` were taken by the 4.1 project
migrations, so it ships as `0007`; and the RPC signature is widened from the
sketch's `(project_id, expected_seq)` to also carry the new snapshot + the
pruned-through id, so the whole compaction is one atomic, RLS-safe
- transaction.)*
+ transaction.)_
- `0008_member_policies.sql` — member management: adds `profiles.email` (backfilled
- + seeded on signup) and a co-member `profiles` SELECT policy (`private.shares_project`);
- switches `project_members` update/delete to **project_manager-only** (insert still
- allows the owner's publish self-seed); a `enforce_min_one_pm` trigger that blocks
- removing or demoting a project's **last** manager; and the `invite_member(project_id,
- email, role)` RPC (SECURITY DEFINER, PM-checked) that resolves the email to a user id
- and upserts the membership (returns NULL when no account has that email — no
- enumeration surface). Backs Phase 5.1. Apply after `0007`. *(Numbering note: ROADMAP
- §5.1 sketched `0006_member_policies.sql`, but `0006`/`0007` were taken, so it ships as
- `0008`; invite resolves the email inside the RPC rather than via a client-side profiles
- lookup, keeping emails/ids server-side.)*
+ - seeded on signup) and a co-member `profiles` SELECT policy (`private.shares_project`);
+ switches `project_members` update/delete to **project_manager-only** (insert still
+ allows the owner's publish self-seed); a `enforce_min_one_pm` trigger that blocks
+ removing or demoting a project's **last** manager; and the `invite_member(project_id,
+email, role)` RPC (SECURITY DEFINER, PM-checked) that resolves the email to a user id
+ and upserts the membership (returns NULL when no account has that email — no
+ enumeration surface). Backs Phase 5.1. Apply after `0007`. _(Numbering note: ROADMAP
+ §5.1 sketched `0006_member_policies.sql`, but `0006`/`0007` were taken, so it ships as
+ `0008`; invite resolves the email inside the RPC rather than via a client-side profiles
+ lookup, keeping emails/ids server-side.)_
- `0009_workflow.sql` — adds a `project_stage` enum + `projects.stage`
(default `translation`) and `projects.deadline`, gating role-based editing (§5.2).
Writes are project_manager-only through the existing `projects_update` policy —
- no new policy. Backs Phase 5.2. Apply after `0008`. *(Numbering note: ROADMAP
+ no new policy. Backs Phase 5.2. Apply after `0008`. _(Numbering note: ROADMAP
§5.2 sketched `0007_workflow.sql`, but `0007`/`0008` were taken, so it ships as
- `0009`.)*
+ `0009`.)_
> Note: the linter's "Leaked Password Protection Disabled" warning is unrelated
> to these migrations — it's an optional **Authentication → Policies** toggle
@@ -153,7 +153,7 @@ In the Supabase dashboard under **Authentication → Providers**:
### Redirect URL allow-list (critical)
Verbalis is a **hash-routed static site**, so we pin Supabase's **PKCE** flow:
-the `?code=` lands in the query string *before* the `#/route` fragment, and a
+the `?code=` lands in the query string _before_ the `#/route` fragment, and a
pre-router bootstrap (`src/storage/cloud/authBootstrap.ts`) exchanges it and
strips the query before React mounts. For this to work, add every origin you
serve from to **Authentication → URL Configuration → Redirect URLs**:
@@ -284,7 +284,7 @@ project card (`ManageMembersDialog` → `src/storage/cloud/members.ts`). Apply
a second signed-in account:
- [ ] **PM invites**: as the manager, open Members → invite the second account's
- email as *translator*, then again as *revisor* → they appear in the roster
+ email as _translator_, then again as _revisor_ → they appear in the roster
with the chosen role, and can open the project.
- [ ] **Unknown email**: inviting an email with no Verbalis account yet shows the
"no account uses that email" notice (the RPC returns NULL) — no row added.
@@ -308,13 +308,13 @@ first. With a project_manager, a translator, and a revisor on one cloud project:
- [ ] **PM sets the stage**: in Members, the manager moves the project through
Translation → Review → Final; a non-PM sees the stage read-only.
-- [ ] **Translator forced to suggest in review**: with the stage on *Review*, the
+- [ ] **Translator forced to suggest in review**: with the stage on _Review_, the
translator's edit-mode toggle is locked to **Suggesting** (disabled) and
typing produces tracked suggestions, not direct edits.
- [ ] **Accept/reject is reviewer-only**: the translator sees suggestions but **no**
Accept/Reject (panel buttons hidden, hover card suppressed); the revisor and
the PM see and can resolve them.
-- [ ] **Final locks editing**: in *Final*, translator + revisor can't edit; only the
+- [ ] **Final locks editing**: in _Final_, translator + revisor can't edit; only the
PM can (e.g. to reopen by moving the stage back).
- [ ] **Local-only unchanged**: a project with no cloud link behaves as
"PM-of-self" — direct editing, suggesting, and accept/reject all available,
@@ -336,7 +336,7 @@ verification burden. The access token lives in memory only (gone on reload).
testing; add yourself as a test user). Add the scope
`https://www.googleapis.com/auth/drive.file`.
4. **APIs & Services → Credentials → Create credentials → OAuth client ID →
- Web application**. Under *Authorised JavaScript origins* add the exact
+ Web application**. Under _Authorised JavaScript origins_ add the exact
origins the app runs on, e.g. `http://localhost:5173` and
`https://verbalis.britx.me`. (No redirect URI is needed — GIS uses the token
flow, not a redirect.)
@@ -350,8 +350,8 @@ script is loaded and the connector code stays out of the initial bundle.
With `VITE_GOOGLE_CLIENT_ID` set:
-- [ ] **Addon listed**: the Add-ons page shows **Google Drive** under *Storage
- connectors* with Network / API key / Files permission chips and a working
+- [ ] **Addon listed**: the Add-ons page shows **Google Drive** under _Storage
+ connectors_ with Network / API key / Files permission chips and a working
on/off toggle.
- [ ] **Import from Drive**: in the Import dialog, **From Google Drive** opens the
Google consent popup, then a file list; picking a `.docx`/`.txt`/`.md`/XLIFF
@@ -396,8 +396,8 @@ never loaded and the connector code stays out of the initial bundle.
With `VITE_MS_CLIENT_ID` set:
-- [ ] **Addon listed**: the Add-ons page shows **OneDrive** under *Storage
- connectors* with Network / API key / Files permission chips and a working
+- [ ] **Addon listed**: the Add-ons page shows **OneDrive** under _Storage
+ connectors_ with Network / API key / Files permission chips and a working
on/off toggle.
- [ ] **Import from OneDrive**: in the Import dialog, **From OneDrive** opens the
Microsoft login popup, then a file list; picking a
diff --git a/docs/roadmap-professional-features.md b/docs/history/roadmap-professional-features.md
similarity index 75%
rename from docs/roadmap-professional-features.md
rename to docs/history/roadmap-professional-features.md
index 3678331..52ed9bd 100644
--- a/docs/roadmap-professional-features.md
+++ b/docs/history/roadmap-professional-features.md
@@ -14,17 +14,17 @@ below.
memoQ is powerful and dense — a ribbon with ~120 commands, modal resource
consoles, and a learning curve measured in weeks. Verbalis copies memoQ's
-*capabilities*, not its *surface*. Every feature below is restated as "what
+_capabilities_, not its _surface_. Every feature below is restated as "what
memoQ does" → "the Verbalis‑native, simpler treatment" → "where it plugs into
the current code".
The plan is built on three decisions already made with the maintainer:
-| Decision | Choice |
-|---|---|
-| LAN collaboration | **Tauri desktop peers** with mDNS auto‑discovery + CRDT sync — every install is a real peer, no cloud, no central server |
-| Rich editor core | **Lexical** (Meta) replaces the plain `
)}
{!loading && error && (
-
+
{error}
)}
@@ -151,7 +155,11 @@ export function ConnectorFilePicker({
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--color-fill)] disabled:opacity-50"
style={{ color: 'var(--color-text)' }}
>
-
+
{f.name}
{downloadingId === f.id && (
diff --git a/src/extensions/connectors/gdrive/config.ts b/src/extensions/connectors/gdrive/config.ts
index a601ac6..b466d2f 100644
--- a/src/extensions/connectors/gdrive/config.ts
+++ b/src/extensions/connectors/gdrive/config.ts
@@ -31,5 +31,7 @@ export function isGdriveConfigured(): boolean {
*/
export function isGdriveAvailable(): boolean {
if (!isGdriveConfigured()) return false
- return !extensionRegistry.has(CONNECTOR_GDRIVE_ID) || extensionRegistry.isEnabled(CONNECTOR_GDRIVE_ID)
+ return (
+ !extensionRegistry.has(CONNECTOR_GDRIVE_ID) || extensionRegistry.isEnabled(CONNECTOR_GDRIVE_ID)
+ )
}
diff --git a/src/extensions/connectors/gdrive/driveApi.ts b/src/extensions/connectors/gdrive/driveApi.ts
index a16131f..1c965c7 100644
--- a/src/extensions/connectors/gdrive/driveApi.ts
+++ b/src/extensions/connectors/gdrive/driveApi.ts
@@ -59,7 +59,7 @@ export async function listDriveFiles(
options: ListFilesOptions = {},
fetchImpl: typeof fetch = fetch,
): Promise {
- const q = ["trashed = false", "mimeType != 'application/vnd.google-apps.folder'"]
+ const q = ['trashed = false', "mimeType != 'application/vnd.google-apps.folder'"]
if (options.query?.trim()) {
q.push(`name contains '${options.query.trim().replace(/'/g, "\\'")}'`)
}
@@ -74,7 +74,11 @@ export async function listDriveFiles(
try {
resp = await fetchImpl(`${FILES_URL}?${params.toString()}`, { headers: authHeaders(token) })
} catch (err) {
- throw new ConnectorError(CONNECTOR_GDRIVE_ID, 'network', err instanceof Error ? err.message : 'fetch failed')
+ throw new ConnectorError(
+ CONNECTOR_GDRIVE_ID,
+ 'network',
+ err instanceof Error ? err.message : 'fetch failed',
+ )
}
if (!resp.ok) throw await driveError(resp)
const body = (await resp.json()) as { files?: DriveFileResource[] }
@@ -94,7 +98,11 @@ export async function downloadDriveFile(
headers: authHeaders(token),
})
} catch (err) {
- throw new ConnectorError(CONNECTOR_GDRIVE_ID, 'network', err instanceof Error ? err.message : 'fetch failed')
+ throw new ConnectorError(
+ CONNECTOR_GDRIVE_ID,
+ 'network',
+ err instanceof Error ? err.message : 'fetch failed',
+ )
}
if (!resp.ok) throw await driveError(resp)
return resp.arrayBuffer()
@@ -130,7 +138,11 @@ export async function uploadDriveFile(
body,
})
} catch (err) {
- throw new ConnectorError(CONNECTOR_GDRIVE_ID, 'network', err instanceof Error ? err.message : 'fetch failed')
+ throw new ConnectorError(
+ CONNECTOR_GDRIVE_ID,
+ 'network',
+ err instanceof Error ? err.message : 'fetch failed',
+ )
}
if (!resp.ok) throw await driveError(resp)
return mapFile((await resp.json()) as DriveFileResource)
diff --git a/src/extensions/connectors/gdrive/gis.ts b/src/extensions/connectors/gdrive/gis.ts
index f5747ff..e432c55 100644
--- a/src/extensions/connectors/gdrive/gis.ts
+++ b/src/extensions/connectors/gdrive/gis.ts
@@ -57,11 +57,29 @@ function loadGis(): Promise {
const onReady = () => {
const oauth2 = window.google?.accounts?.oauth2
if (oauth2) resolve(oauth2)
- else reject(new ConnectorError(CONNECTOR_GDRIVE_ID, 'network', 'Google Identity Services failed to initialise'))
+ else
+ reject(
+ new ConnectorError(
+ CONNECTOR_GDRIVE_ID,
+ 'network',
+ 'Google Identity Services failed to initialise',
+ ),
+ )
}
if (existing) {
existing.addEventListener('load', onReady, { once: true })
- existing.addEventListener('error', () => reject(new ConnectorError(CONNECTOR_GDRIVE_ID, 'network', 'Failed to load Google Identity Services')), { once: true })
+ existing.addEventListener(
+ 'error',
+ () =>
+ reject(
+ new ConnectorError(
+ CONNECTOR_GDRIVE_ID,
+ 'network',
+ 'Failed to load Google Identity Services',
+ ),
+ ),
+ { once: true },
+ )
return
}
const script = document.createElement('script')
@@ -69,7 +87,14 @@ function loadGis(): Promise {
script.async = true
script.defer = true
script.onload = onReady
- script.onerror = () => reject(new ConnectorError(CONNECTOR_GDRIVE_ID, 'network', 'Failed to load Google Identity Services'))
+ script.onerror = () =>
+ reject(
+ new ConnectorError(
+ CONNECTOR_GDRIVE_ID,
+ 'network',
+ 'Failed to load Google Identity Services',
+ ),
+ )
document.head.appendChild(script)
})
return scriptPromise
@@ -81,7 +106,8 @@ function loadGis(): Promise {
*/
export async function requestDriveToken(forceConsent = false): Promise {
const clientId = googleClientId()
- if (!clientId) throw new ConnectorError(CONNECTOR_GDRIVE_ID, 'auth', 'Google client id not configured')
+ if (!clientId)
+ throw new ConnectorError(CONNECTOR_GDRIVE_ID, 'auth', 'Google client id not configured')
if (!forceConsent && cachedToken && cachedToken.expiresAt - EXPIRY_SKEW_MS > Date.now()) {
return cachedToken.value
}
@@ -92,7 +118,13 @@ export async function requestDriveToken(forceConsent = false): Promise {
scope: DRIVE_SCOPE,
callback: (resp) => {
if (resp.error || !resp.access_token) {
- reject(new ConnectorError(CONNECTOR_GDRIVE_ID, 'auth', resp.error_description || resp.error || 'Authorisation failed'))
+ reject(
+ new ConnectorError(
+ CONNECTOR_GDRIVE_ID,
+ 'auth',
+ resp.error_description || resp.error || 'Authorisation failed',
+ ),
+ )
return
}
cachedToken = {
@@ -102,7 +134,9 @@ export async function requestDriveToken(forceConsent = false): Promise {
resolve(resp.access_token)
},
error_callback: (err) => {
- reject(new ConnectorError(CONNECTOR_GDRIVE_ID, 'auth', err.message || 'Authorisation cancelled'))
+ reject(
+ new ConnectorError(CONNECTOR_GDRIVE_ID, 'auth', err.message || 'Authorisation cancelled'),
+ )
},
})
client.requestAccessToken({ prompt: forceConsent ? 'consent' : '' })
diff --git a/src/extensions/connectors/gdrive/index.ts b/src/extensions/connectors/gdrive/index.ts
index b837a15..fa27ba5 100644
--- a/src/extensions/connectors/gdrive/index.ts
+++ b/src/extensions/connectors/gdrive/index.ts
@@ -1,14 +1,14 @@
-import type {
- ConnectorFile,
- ConnectorUpload,
- ListFilesOptions,
- StorageConnector,
-} from '../types'
+import type { ConnectorFile, ConnectorUpload, ListFilesOptions, StorageConnector } from '../types'
import { CONNECTOR_GDRIVE_ID, isGdriveConfigured } from './config'
import { downloadDriveFile, listDriveFiles, uploadDriveFile } from './driveApi'
import { requestDriveToken } from './gis'
-export { CONNECTOR_GDRIVE_ID, isGdriveConfigured, isGdriveAvailable, googleClientId } from './config'
+export {
+ CONNECTOR_GDRIVE_ID,
+ isGdriveConfigured,
+ isGdriveAvailable,
+ googleClientId,
+} from './config'
/**
* The Google Drive storage connector (Phase 6.3) — pairs the GIS token client
diff --git a/src/extensions/connectors/onedrive/config.ts b/src/extensions/connectors/onedrive/config.ts
index a3fefa8..2179208 100644
--- a/src/extensions/connectors/onedrive/config.ts
+++ b/src/extensions/connectors/onedrive/config.ts
@@ -31,5 +31,8 @@ export function isOnedriveConfigured(): boolean {
*/
export function isOnedriveAvailable(): boolean {
if (!isOnedriveConfigured()) return false
- return !extensionRegistry.has(CONNECTOR_ONEDRIVE_ID) || extensionRegistry.isEnabled(CONNECTOR_ONEDRIVE_ID)
+ return (
+ !extensionRegistry.has(CONNECTOR_ONEDRIVE_ID) ||
+ extensionRegistry.isEnabled(CONNECTOR_ONEDRIVE_ID)
+ )
}
diff --git a/src/extensions/connectors/onedrive/graphApi.ts b/src/extensions/connectors/onedrive/graphApi.ts
index 93350e7..54743fd 100644
--- a/src/extensions/connectors/onedrive/graphApi.ts
+++ b/src/extensions/connectors/onedrive/graphApi.ts
@@ -68,7 +68,11 @@ export async function listOnedriveFiles(
try {
resp = await fetchImpl(url, { headers: authHeaders(token) })
} catch (err) {
- throw new ConnectorError(CONNECTOR_ONEDRIVE_ID, 'network', err instanceof Error ? err.message : 'fetch failed')
+ throw new ConnectorError(
+ CONNECTOR_ONEDRIVE_ID,
+ 'network',
+ err instanceof Error ? err.message : 'fetch failed',
+ )
}
if (!resp.ok) throw await graphError(resp)
const body = (await resp.json()) as { value?: GraphDriveItem[] }
@@ -87,7 +91,11 @@ export async function downloadOnedriveFile(
headers: authHeaders(token),
})
} catch (err) {
- throw new ConnectorError(CONNECTOR_ONEDRIVE_ID, 'network', err instanceof Error ? err.message : 'fetch failed')
+ throw new ConnectorError(
+ CONNECTOR_ONEDRIVE_ID,
+ 'network',
+ err instanceof Error ? err.message : 'fetch failed',
+ )
}
if (!resp.ok) throw await graphError(resp)
return resp.arrayBuffer()
@@ -111,7 +119,11 @@ export async function uploadOnedriveFile(
body,
})
} catch (err) {
- throw new ConnectorError(CONNECTOR_ONEDRIVE_ID, 'network', err instanceof Error ? err.message : 'fetch failed')
+ throw new ConnectorError(
+ CONNECTOR_ONEDRIVE_ID,
+ 'network',
+ err instanceof Error ? err.message : 'fetch failed',
+ )
}
if (!resp.ok) throw await graphError(resp)
return mapItem((await resp.json()) as GraphDriveItem)
diff --git a/src/extensions/connectors/onedrive/index.ts b/src/extensions/connectors/onedrive/index.ts
index a479795..795cce7 100644
--- a/src/extensions/connectors/onedrive/index.ts
+++ b/src/extensions/connectors/onedrive/index.ts
@@ -1,9 +1,4 @@
-import type {
- ConnectorFile,
- ConnectorUpload,
- ListFilesOptions,
- StorageConnector,
-} from '../types'
+import type { ConnectorFile, ConnectorUpload, ListFilesOptions, StorageConnector } from '../types'
import { CONNECTOR_ONEDRIVE_ID, isOnedriveConfigured } from './config'
import { downloadOnedriveFile, listOnedriveFiles, uploadOnedriveFile } from './graphApi'
import { requestGraphToken } from './msal'
diff --git a/src/extensions/connectors/onedrive/msal.ts b/src/extensions/connectors/onedrive/msal.ts
index f6b02b4..366c3e2 100644
--- a/src/extensions/connectors/onedrive/msal.ts
+++ b/src/extensions/connectors/onedrive/msal.ts
@@ -19,7 +19,8 @@ let appPromise: Promise | null = null
async function getApp(): Promise {
const clientId = msalClientId()
- if (!clientId) throw new ConnectorError(CONNECTOR_ONEDRIVE_ID, 'auth', 'Microsoft client id not configured')
+ if (!clientId)
+ throw new ConnectorError(CONNECTOR_ONEDRIVE_ID, 'auth', 'Microsoft client id not configured')
if (appPromise) return appPromise
appPromise = (async () => {
const { PublicClientApplication } = await import('@azure/msal-browser')
diff --git a/src/extensions/mt/index.ts b/src/extensions/mt/index.ts
index b3fef15..bc13ea1 100644
--- a/src/extensions/mt/index.ts
+++ b/src/extensions/mt/index.ts
@@ -21,10 +21,12 @@ const EXT_TO_PROVIDER: Record = Object.fromEntries(
)
/** Every MT provider addon, manifest paired to its core provider. */
-export const mtProviderExtensions: MTProviderExtension[] = MT_EXTENSION_MANIFESTS.map((manifest) => ({
- manifest,
- provider: MT_PROVIDERS[EXT_TO_PROVIDER[manifest.id]],
-}))
+export const mtProviderExtensions: MTProviderExtension[] = MT_EXTENSION_MANIFESTS.map(
+ (manifest) => ({
+ manifest,
+ provider: MT_PROVIDERS[EXT_TO_PROVIDER[manifest.id]],
+ }),
+)
/** The MT provider ids whose addon is currently enabled in the registry. */
export function registryEnabledMtProviderIds(): MTProviderId[] {
diff --git a/src/features/about/index.tsx b/src/features/about/index.tsx
index 31a0e9f..0a6ac9d 100644
--- a/src/features/about/index.tsx
+++ b/src/features/about/index.tsx
@@ -1,13 +1,7 @@
import { Shield, Lock, Repeat, Scale, ArrowUpRight, User, Building2 } from 'lucide-react'
import { PageHeader } from '@/components/layout/PageHeader'
import { VerbalisMark } from '@/components/brand/VerbalisMark'
-import {
- BRAND_LINKS,
- REPO_URL,
- LICENSE_URL,
- AUTHOR_NAME,
- AUTHOR_EMAIL,
-} from './links'
+import { BRAND_LINKS, REPO_URL, LICENSE_URL, AUTHOR_NAME, AUTHOR_EMAIL } from './links'
export default function AboutPage() {
return (
@@ -41,23 +35,21 @@ export default function AboutPage() {
style={{ color: 'var(--color-text)' }}
>
- Verbalis started with a stubborn problem: I needed to translate
- sensitive, security-critical material and there was no tool I could
- fully trust. The mainstream options wanted my documents on their
- servers. The privacy-respecting ones couldn't speak the formats real
- translation work runs on — XLIFF, TMX, TBX — so they never fit a
+ Verbalis started with a stubborn problem: I needed to translate sensitive,
+ security-critical material and there was no tool I could fully trust. The mainstream
+ options wanted my documents on their servers. The privacy-respecting ones couldn't speak
+ the formats real translation work runs on — XLIFF, TMX, TBX — so they never fit a
professional CAT workflow.
- So I built the tool I wanted: local-first by default {' '}
- — your files, translation memory and glossaries never leave your
- browser — yet fluent in the industry's interchange
- formats , so it slots into the same pipelines as memoQ,
- Trados and OmegaT.
+ So I built the tool I wanted: local-first by default — your files,
+ translation memory and glossaries never leave your browser — yet{' '}
+ fluent in the industry's interchange formats , so it slots into the same
+ pipelines as memoQ, Trados and OmegaT.
- Verbalis is a CAT tool for people who handle confidential text.
- Privacy here isn't a setting you toggle — it's the architecture.
+ Verbalis is a CAT tool for people who handle confidential text. Privacy here isn't a
+ setting you toggle — it's the architecture.
@@ -97,8 +89,8 @@ export default function AboutPage() {
style={{ borderColor: 'var(--color-border)', background: 'var(--color-surface)' }}
>
- Verbalis is source-available {' '}
- — the code is public to read and learn from, under a two-tier license:
+ Verbalis is source-available —
+ the code is public to read and learn from, under a two-tier license:
Read the full license
-
- Contact for commercial use
-
+ Contact for commercial use
@@ -183,15 +173,7 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
)
}
-function Principle({
- icon,
- title,
- body,
-}: {
- icon: React.ReactNode
- title: string
- body: string
-}) {
+function Principle({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
return (
{icon}
-
+
{tier}
diff --git a/src/features/about/links.ts b/src/features/about/links.ts
index 5a48244..5f587af 100644
--- a/src/features/about/links.ts
+++ b/src/features/about/links.ts
@@ -11,7 +11,7 @@ export interface BrandLink {
/** Canonical project + author links, shared by the About page and footer. */
export const REPO_URL = 'https://github.com/pedrobritx/verbalis'
export const AUTHOR_NAME = 'Pedro Brito'
-export const AUTHOR_EMAIL = 'pedrohbrito@me.com'
+export const AUTHOR_EMAIL = 'pedrobritx@gmail.com'
export const LICENSE_URL = `${REPO_URL}/blob/main/LICENSE.md`
export const BRAND_LINKS: BrandLink[] = [
@@ -29,8 +29,8 @@ export const BRAND_LINKS: BrandLink[] = [
},
{
label: 'Website',
- description: 'pedrobritx — portfolio',
- href: 'https://pedrobritx.github.io/EwP/',
+ description: 'Pedro Brito — britx.me',
+ href: 'https://britx.me',
icon: Globe,
},
{
diff --git a/src/features/account/AccountMenu.tsx b/src/features/account/AccountMenu.tsx
index c41b351..bc77eb5 100644
--- a/src/features/account/AccountMenu.tsx
+++ b/src/features/account/AccountMenu.tsx
@@ -86,10 +86,7 @@ export function AccountMenu() {
data-testid="account-menu"
>
-
+
{user.displayName || 'Signed in'}
{user.email ? (
diff --git a/src/features/account/AccountSettingsSection.tsx b/src/features/account/AccountSettingsSection.tsx
index fcc9229..d4b310d 100644
--- a/src/features/account/AccountSettingsSection.tsx
+++ b/src/features/account/AccountSettingsSection.tsx
@@ -61,8 +61,8 @@ export function AccountSettingsSection() {
return (
- Sign in to sync your preferences, term bank, and cloud projects across
- devices. Verbalis stays 100% local until you sign in.
+ Sign in to sync your preferences, term bank, and cloud projects across devices. Verbalis
+ stays 100% local until you sign in.
- The name shown on your account, comments, and collaboration presence.
- Synced to the cloud and shared across your devices.
+ The name shown on your account, comments, and collaboration presence. Synced to the cloud
+ and shared across your devices.
-
+
Linked sign-in methods
{identities === null ? (
diff --git a/src/features/account/SignInDialog.tsx b/src/features/account/SignInDialog.tsx
index 10783b7..0034f73 100644
--- a/src/features/account/SignInDialog.tsx
+++ b/src/features/account/SignInDialog.tsx
@@ -69,8 +69,8 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
Sign in to Verbalis
- Sync your preferences, term bank, and cloud projects across devices. Verbalis stays
- 100% local until you sign in.
+ Sync your preferences, term bank, and cloud projects across devices. Verbalis stays 100%
+ local until you sign in.
diff --git a/src/features/account/displayName.ts b/src/features/account/displayName.ts
new file mode 100644
index 0000000..b63ab65
--- /dev/null
+++ b/src/features/account/displayName.ts
@@ -0,0 +1,49 @@
+import { useAuthStore, type AuthUser, type AuthStatus } from './useAuthStore'
+
+/**
+ * The effective display name for authored actions — tracked changes, comments,
+ * collaboration presence, version sign-off. When signed in, the account name is
+ * used automatically, so a signed-in user never has to enter a separate local
+ * name. Otherwise the device-local name is used, and only when neither exists
+ * does a friendly fallback show.
+ *
+ * Two fallbacks, because the word differs by audience: your own unnamed
+ * attribution reads as "You"; an unnamed peer shown to *others* reads as
+ * "Anonymous".
+ */
+export const SELF_NAME_FALLBACK = 'You'
+export const PEER_NAME_FALLBACK = 'Anonymous'
+
+/** The auth fields the resolver reads — a slice, so it is pure and testable. */
+export interface AuthNameSlice {
+ status: AuthStatus
+ user: AuthUser | null
+ profileDisplayName: string | null
+}
+
+/**
+ * Resolve the effective name: signed-in account name → device-local name →
+ * `fallback`. Pure over the auth slice.
+ */
+export function resolveDisplayName(
+ local: string | null | undefined,
+ auth: AuthNameSlice,
+ fallback: string = SELF_NAME_FALLBACK,
+): string {
+ if (auth.status === 'authenticated') {
+ const account = (auth.profileDisplayName ?? auth.user?.displayName ?? '').trim()
+ if (account) return account
+ }
+ return (local ?? '').trim() || fallback
+}
+
+/** Reactive `resolveDisplayName` bound to the auth store. */
+export function useDisplayName(
+ local: string | null | undefined,
+ fallback: string = SELF_NAME_FALLBACK,
+): string {
+ const status = useAuthStore((s) => s.status)
+ const user = useAuthStore((s) => s.user)
+ const profileDisplayName = useAuthStore((s) => s.profileDisplayName)
+ return resolveDisplayName(local, { status, user, profileDisplayName }, fallback)
+}
diff --git a/src/features/account/useAuthStore.ts b/src/features/account/useAuthStore.ts
index 4e70276..14d742e 100644
--- a/src/features/account/useAuthStore.ts
+++ b/src/features/account/useAuthStore.ts
@@ -141,8 +141,7 @@ export const useAuthStore = create((set, get) => ({
supabase.from('profiles').select('display_name').eq('id', user.id).maybeSingle(),
supabase.auth.getUserIdentities(),
])
- const displayName =
- (profileResult.data?.display_name as string | null | undefined) ?? null
+ const displayName = (profileResult.data?.display_name as string | null | undefined) ?? null
const rawIdentities = (identityResult.data?.identities ?? []) as SupabaseIdentity[]
set((s) => ({
profileDisplayName: displayName,
@@ -163,9 +162,7 @@ export const useAuthStore = create((set, get) => ({
set({ error: null })
try {
const supabase = await getSupabase()
- const { error } = await supabase
- .from('profiles')
- .upsert({ id: user.id, display_name: value })
+ const { error } = await supabase.from('profiles').upsert({ id: user.id, display_name: value })
if (error) {
set({ error: error.message })
return
diff --git a/src/features/addons/AddonsPage.tsx b/src/features/addons/AddonsPage.tsx
index ca15f4a..ed483d4 100644
--- a/src/features/addons/AddonsPage.tsx
+++ b/src/features/addons/AddonsPage.tsx
@@ -2,11 +2,7 @@ import { useSyncExternalStore } from 'react'
import { Puzzle } from 'lucide-react'
import { PageHeader } from '@/components/layout/PageHeader'
import { extensionRegistry } from '@/core/extensions/registry'
-import type {
- ExtensionKind,
- ExtensionManifest,
- ExtensionPermission,
-} from '@/core/extensions/types'
+import type { ExtensionKind, ExtensionManifest, ExtensionPermission } from '@/core/extensions/types'
/**
* The Add-ons catalogue (ROADMAP §6.2). Lists everything pluggable — MT
@@ -66,13 +62,20 @@ export default function AddonsPage() {
return (
-
+
{SECTIONS.map((section) => {
const items = all.filter((m) => m.kinds.some((k) => section.kinds.includes(k)))
if (items.length === 0) return null
return (
-
+
{section.title}
@@ -110,7 +113,10 @@ function AddonRow({ manifest }: { manifest: ExtensionManifest }) {
-
+
{manifest.name}
) : (
- manifest.permissions.map((p) => )
+ manifest.permissions.map((p) => (
+
+ ))
)}
{toggleable ? (
-
+
- run(() => togglePanel())}
- data-testid="cmd-toggle-sidebar"
- >
+ run(() => togglePanel())} data-testid="cmd-toggle-sidebar">
Toggle sidebar panel
@@ -142,10 +139,7 @@ export function CommandPalette() {
Switch sidebar to Glossary
- run(() => goToSidebar('mt'))}
- data-testid="cmd-sidebar-mt"
- >
+ run(() => goToSidebar('mt'))} data-testid="cmd-sidebar-mt">
Switch sidebar to Machine translation
@@ -184,7 +178,9 @@ export function CommandPalette() {
data-testid="cmd-toggle-suggesting"
>
- {editMode === 'suggesting' ? 'Switch to direct editing' : 'Switch to suggesting mode'}
+
+ {editMode === 'suggesting' ? 'Switch to direct editing' : 'Switch to suggesting mode'}
+
Find & replace…
⌃H
- run(() => goToSidebar('qa'))}
- data-testid="cmd-run-qa"
- >
+ run(() => goToSidebar('qa'))} data-testid="cmd-run-qa">
Run quality assurance
@@ -287,10 +280,7 @@ export function CommandPalette() {
Show analysis
- run(() => openAddTerm(''))}
- data-testid="cmd-add-term"
- >
+ run(() => openAddTerm(''))} data-testid="cmd-add-term">
Add glossary term…
⌃E
@@ -303,10 +293,7 @@ export function CommandPalette() {
Concordance search…
⌃⇧K
- run(() => openLookup(''))}
- data-testid="cmd-lookup"
- >
+ run(() => openLookup(''))} data-testid="cmd-lookup">
Quick lookup…
⌃L
@@ -331,7 +318,9 @@ export function CommandPalette() {
run(() => currentProjectId && openProjectDialog('edit', currentProjectId))}
+ onSelect={() =>
+ run(() => currentProjectId && openProjectDialog('edit', currentProjectId))
+ }
data-testid="cmd-edit-project"
>
@@ -398,17 +387,11 @@ export function CommandPalette() {
No matching commands.
- run(() => navigate('/'))}
- data-testid="cmd-nav-projects"
- >
+ run(() => navigate('/'))} data-testid="cmd-nav-projects">
Go to Projects
- run(() => navigate('/tm'))}
- data-testid="cmd-nav-tm"
- >
+ run(() => navigate('/tm'))} data-testid="cmd-nav-tm">
Go to Translation Memory
@@ -428,10 +411,7 @@ export function CommandPalette() {
- run(() => openImport(true))}
- data-testid="cmd-import"
- >
+ run(() => openImport(true))} data-testid="cmd-import">
Import file…
@@ -439,10 +419,7 @@ export function CommandPalette() {
{theme === 'dark' ? : }
Toggle theme (currently {theme})
- run(() => openShortcuts(true))}
- data-testid="cmd-shortcuts"
- >
+ run(() => openShortcuts(true))} data-testid="cmd-shortcuts">
Show keyboard shortcuts
?
diff --git a/src/features/corpora/CorpusImportButton.tsx b/src/features/corpora/CorpusImportButton.tsx
index bc6d6c4..64cbd33 100644
--- a/src/features/corpora/CorpusImportButton.tsx
+++ b/src/features/corpora/CorpusImportButton.tsx
@@ -69,7 +69,13 @@ export function CorpusImportButton({
let rows: Array> = []
if (format === 'csv') rows = mergeRows(parseCSV(text))
else if (format === 'tsv') rows = mergeRows(parseTSV(text, sourceLang, targetLang))
- else rows = parseTBX(text).map((t) => ({ term: t.term, definition: t.definition, translations: t.translations, notes: t.notes }))
+ else
+ rows = parseTBX(text).map((t) => ({
+ term: t.term,
+ definition: t.definition,
+ translations: t.translations,
+ notes: t.notes,
+ }))
const id = slugifyCorpusId(name)
const pack = rowsToCorpusFile(rows, { id, langSource: sourceLang, langTarget: targetLang })
@@ -111,8 +117,8 @@ export function CorpusImportButton({
Import a corpus
- Upload a CSV, TSV (OmegaT) or TBX termbase as a custom field corpus. It
- feeds glossary matching and quick lookup like the bundled packs.
+ Upload a CSV, TSV (OmegaT) or TBX termbase as a custom field corpus. It feeds glossary
+ matching and quick lookup like the bundled packs.
@@ -174,7 +180,11 @@ export function CorpusImportButton({
{error && (
-
+
{error}
)}
diff --git a/src/features/corpora/CorpusPackCard.tsx b/src/features/corpora/CorpusPackCard.tsx
index b56bc14..6aa3596 100644
--- a/src/features/corpora/CorpusPackCard.tsx
+++ b/src/features/corpora/CorpusPackCard.tsx
@@ -19,12 +19,7 @@ function sourcesLabel(sources: Record): string {
.join(' · ')
}
-export function CorpusPackCard({
- pack,
- langSource,
- langTarget,
- installed,
-}: CorpusPackCardProps) {
+export function CorpusPackCard({ pack, langSource, langTarget, installed }: CorpusPackCardProps) {
const [busy, setBusy] = useState(false)
const [seedTm, setSeedTm] = useState(false)
const [error, setError] = useState(null)
diff --git a/src/features/dictionary/index.tsx b/src/features/dictionary/index.tsx
deleted file mode 100644
index 9e26da0..0000000
--- a/src/features/dictionary/index.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-export default function DictionaryPage() {
- return (
-
-
- Dictionary
-
-
- )
-}
diff --git a/src/features/editor/EditModeToggle.tsx b/src/features/editor/EditModeToggle.tsx
index 81e675a..6f6d697 100644
--- a/src/features/editor/EditModeToggle.tsx
+++ b/src/features/editor/EditModeToggle.tsx
@@ -34,7 +34,11 @@ export function EditModeToggle({ forced = false }: { forced?: boolean }) {
data-forced={forced ? 'true' : undefined}
title={forced ? 'Your role must suggest changes at this workflow stage' : undefined}
className="inline-flex items-center gap-1 self-start rounded-full border p-1"
- style={{ borderColor: 'var(--color-border)', background: 'var(--color-surface)', opacity: forced ? 0.7 : 1 }}
+ style={{
+ borderColor: 'var(--color-border)',
+ background: 'var(--color-surface)',
+ opacity: forced ? 0.7 : 1,
+ }}
>
{MODES.map(({ id, label, icon: Icon, hint }) => {
const isActive = editMode === id
diff --git a/src/features/editor/EditorPage.tsx b/src/features/editor/EditorPage.tsx
index 36a9661..10cc4b6 100644
--- a/src/features/editor/EditorPage.tsx
+++ b/src/features/editor/EditorPage.tsx
@@ -1,7 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useLiveQuery } from 'dexie-react-hooks'
-import { ChevronLeft, PanelRight, PanelRightClose, Pencil, Trash2, BookOpenText } from 'lucide-react'
+import {
+ ChevronLeft,
+ PanelRight,
+ PanelRightClose,
+ Pencil,
+ Trash2,
+ BookOpenText,
+} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { projectRepo } from '@/storage/repositories/projectRepo'
import { segmentRepo, tallyStatus } from '@/storage/repositories/segmentRepo'
@@ -34,6 +41,7 @@ import { AddTermDialog } from './glossary/AddTermDialog'
import { GlossaryEditController } from './glossary/GlossaryEditController'
import { ConcordanceDialog } from './concordance/ConcordanceDialog'
import { useProjectDialogsStore } from '@/features/projects/useProjectDialogsStore'
+import { useQuickLookupStore } from '@/features/lookup/useQuickLookupStore'
import { translateWith, resolveDefaultProvider, MTError } from '@/core/mt'
import {
getMTSettings,
@@ -108,6 +116,7 @@ export default function EditorPage() {
const openFindReplace = useFindReplaceStore((s) => s.setOpen)
const openAnalysis = useAnalysisStore((s) => s.setOpen)
const openProjectDialog = useProjectDialogsStore((s) => s.open)
+ const setLookupProjectLangs = useQuickLookupStore((s) => s.setProjectLangs)
const [toolMsg, setToolMsg] = useState(null)
const registerHandle = useCallback((index: number, handle: SegmentEditorHandle | null) => {
@@ -127,6 +136,15 @@ export default function EditorPage() {
return () => resetWorkflow()
}, [project?.cloud?.role, project?.cloud?.stage, setWorkflow, resetWorkflow])
+ // Publish the project's languages so every quick-lookup trigger (top bar,
+ // command palette, Ctrl-selection) defaults to them instead of the global
+ // lookup defaults. Cleared on unmount so lookups outside a project fall back.
+ useEffect(() => {
+ if (!project) return
+ setLookupProjectLangs({ source: project.sourceLang, target: project.targetLang })
+ return () => setLookupProjectLangs(null)
+ }, [project?.sourceLang, project?.targetLang, setLookupProjectLangs])
+
// A translator in the review stage may only suggest — pin the edit mode.
useEffect(() => {
if (forceSuggest) setEditMode('suggesting')
@@ -163,10 +181,7 @@ export default function EditorPage() {
// Derive status counts from the segments already in memory so the filter bar
// doesn't re-scan the same rows from the database.
- const statusCounts = useMemo(
- () => (segments ? tallyStatus(segments) : undefined),
- [segments],
- )
+ const statusCounts = useMemo(() => (segments ? tallyStatus(segments) : undefined), [segments])
const visibleSegments = useMemo(() => {
if (!segments) return [] as Array<{ seg: Segment; originalIndex: number }>
diff --git a/src/features/editor/EditorTips.tsx b/src/features/editor/EditorTips.tsx
index 983a408..17839c0 100644
--- a/src/features/editor/EditorTips.tsx
+++ b/src/features/editor/EditorTips.tsx
@@ -9,13 +9,31 @@ interface Tip {
}
const TIPS: Tip[] = [
- { label: 'Confirm', body: 'Press Ctrl/⌘+Enter (or the ✓ button) to confirm a segment and jump to the next.' },
- { label: 'Review', body: 'Ctrl/⌘+Shift+Enter marks a segment reviewed; Ctrl/⌘+Shift+R toggles review mode.' },
- { label: 'Comments', body: 'Use the 💬 button on a row to leave a note for yourself or a reviewer.' },
- { label: 'Edit source', body: 'The “Source” pencil under the source text lets you fix a mis-segmented original.' },
+ {
+ label: 'Confirm',
+ body: 'Press Ctrl/⌘+Enter (or the ✓ button) to confirm a segment and jump to the next.',
+ },
+ {
+ label: 'Review',
+ body: 'Ctrl/⌘+Shift+Enter marks a segment reviewed; Ctrl/⌘+Shift+R toggles review mode.',
+ },
+ {
+ label: 'Comments',
+ body: 'Use the 💬 button on a row to leave a note for yourself or a reviewer.',
+ },
+ {
+ label: 'Edit source',
+ body: 'The “Source” pencil under the source text lets you fix a mis-segmented original.',
+ },
{ label: 'Segments', body: 'The ⋯ menu on a row splits, joins (Ctrl/⌘+J), or locks a segment.' },
- { label: 'Formatting', body: 'Enable “Rich text editing” in Settings for bold/italic/sub-sup and case changes.' },
- { label: 'Everything', body: 'Press Ctrl/⌘+K to open the command palette and search every action.' },
+ {
+ label: 'Formatting',
+ body: 'Enable “Rich text editing” in Settings for bold/italic/sub-sup and case changes.',
+ },
+ {
+ label: 'Everything',
+ body: 'Press Ctrl/⌘+K to open the command palette and search every action.',
+ },
]
function readDismissed(): boolean {
@@ -81,7 +99,10 @@ export function EditorTips() {
role="note"
>
-
+
{TIPS.map((t) => (
diff --git a/src/features/editor/MobileSidebarSheet.tsx b/src/features/editor/MobileSidebarSheet.tsx
index 351c083..4a67c5d 100644
--- a/src/features/editor/MobileSidebarSheet.tsx
+++ b/src/features/editor/MobileSidebarSheet.tsx
@@ -43,8 +43,14 @@ export function MobileSidebarSheet({
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
-
-
+
+
Tools
{reviewMode ? : }
diff --git a/src/features/editor/analysis/AnalysisDialog.tsx b/src/features/editor/analysis/AnalysisDialog.tsx
index 90518d6..0f8a96a 100644
--- a/src/features/editor/analysis/AnalysisDialog.tsx
+++ b/src/features/editor/analysis/AnalysisDialog.tsx
@@ -7,11 +7,7 @@ import {
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
-import {
- analyzeProject,
- LEVERAGE_BUCKETS,
- LEVERAGE_LABELS,
-} from '@/core/analysis/report'
+import { analyzeProject, LEVERAGE_BUCKETS, LEVERAGE_LABELS } from '@/core/analysis/report'
import { tmRepo } from '@/storage/repositories/tmRepo'
import type { Project } from '@/core/types'
import { useProjectSegments } from '../useProjectSegments'
@@ -102,10 +98,7 @@ export function AnalysisDialog({ project }: AnalysisDialogProps) {
function Stat({ label, value }: { label: string; value: number }) {
return (
-
+
{value.toLocaleString()}
diff --git a/src/features/editor/changes/ChangesPanel.tsx b/src/features/editor/changes/ChangesPanel.tsx
index a2791d8..ffcaa40 100644
--- a/src/features/editor/changes/ChangesPanel.tsx
+++ b/src/features/editor/changes/ChangesPanel.tsx
@@ -182,7 +182,11 @@ function SuggestionRow({
#{change.segmentIndex + 1}
-
+
{change.authorName || 'Unknown'}
@@ -295,7 +299,11 @@ function HistoryView({ projectId }: ChangesPanelProps) {
style={{ color }}
data-testid="changes-author"
>
-
+
{c.author || 'Unknown'}
@@ -305,7 +313,10 @@ function HistoryView({ projectId }: ChangesPanelProps) {
{showDiff ? (
) : (
-
+
{c.after}
)}
diff --git a/src/features/editor/changes/resolveOps.ts b/src/features/editor/changes/resolveOps.ts
index f852f62..ee3350b 100644
--- a/src/features/editor/changes/resolveOps.ts
+++ b/src/features/editor/changes/resolveOps.ts
@@ -1,10 +1,6 @@
import { segmentRepo } from '@/storage/repositories/segmentRepo'
import { extractChanges, type ExtractedChange } from '@/core/changes/extract'
-import {
- resolveChangeInRich,
- resolveAllInRich,
- type ResolveAction,
-} from '../rich/resolve'
+import { resolveChangeInRich, resolveAllInRich, type ResolveAction } from '../rich/resolve'
/**
* Orchestrates accept/reject on stored segments. Kept in the feature layer (not
@@ -34,10 +30,7 @@ export async function resolveSegmentChange(
}
/** Resolve every pending change in one segment. Returns the count applied. */
-export async function resolveSegmentAll(
- segmentId: string,
- action: ResolveAction,
-): Promise {
+export async function resolveSegmentAll(segmentId: string, action: ResolveAction): Promise {
const seg = await segmentRepo.getById(segmentId)
if (!seg?.targetRich) return 0
const next = resolveAllInRich(seg.targetRich, action)
@@ -60,10 +53,7 @@ export async function listPendingChanges(projectId: string): Promise {
+export async function resolveProjectAll(projectId: string, action: ResolveAction): Promise {
const segments = await segmentRepo.byProject(projectId)
let total = 0
for (const seg of segments) {
diff --git a/src/features/editor/comments/CommentsPanel.tsx b/src/features/editor/comments/CommentsPanel.tsx
index 3a1d582..e56a906 100644
--- a/src/features/editor/comments/CommentsPanel.tsx
+++ b/src/features/editor/comments/CommentsPanel.tsx
@@ -4,6 +4,7 @@ import { MessageSquare, Check, RotateCcw } from 'lucide-react'
import { relativeTime } from '../history/relativeTime'
import { useEditorActionsStore } from '../useEditorActionsStore'
import { listProjectThreads, setThreadResolved, type ProjectThread } from './commentOps'
+import { SELF_NAME_FALLBACK } from '@/features/account/displayName'
type Filter = 'open' | 'resolved' | 'all'
@@ -68,7 +69,11 @@ export function CommentsPanel({ projectId }: { projectId: string }) {
) : (
{visible.map((t) => (
- actions?.jumpToSegment(t.segmentIndex + 1)} />
+ actions?.jumpToSegment(t.segmentIndex + 1)}
+ />
))}
)}
@@ -95,7 +100,7 @@ function ThreadCard({ thread, onJump }: { thread: ProjectThread; onJump: () => v
#{segmentIndex + 1}
- {root.author || 'You'}
+ {root.author || SELF_NAME_FALLBACK}
{relativeTime(root.createdAt)}
diff --git a/src/features/editor/comments/SegmentComments.tsx b/src/features/editor/comments/SegmentComments.tsx
index db25029..5558792 100644
--- a/src/features/editor/comments/SegmentComments.tsx
+++ b/src/features/editor/comments/SegmentComments.tsx
@@ -5,6 +5,7 @@ import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import type { SegmentComment } from '@/core/types'
import { relativeTime } from '../history/relativeTime'
+import { SELF_NAME_FALLBACK } from '@/features/account/displayName'
import {
groupThreads,
addRootComment,
@@ -48,7 +49,12 @@ export function SegmentComments({ segmentId, comments, author }: SegmentComments
{threads.length > 0 && (
{threads.map((thread) => (
-
+
))}
)}
@@ -86,14 +92,11 @@ function CommentBubble({ c }: { c: SegmentComment }) {
className="flex items-center gap-2 text-[10px] uppercase tracking-wider"
style={{ color: 'var(--color-muted)' }}
>
- {c.author || 'You'}
+ {c.author || SELF_NAME_FALLBACK}
·
{relativeTime(c.createdAt)}
-
+
{c.body}
@@ -177,7 +180,10 @@ function ThreadView({
{replies.length > 0 && (
-
+
{replies.map((r) => (
diff --git a/src/features/editor/comments/commentOps.ts b/src/features/editor/comments/commentOps.ts
index 4030668..507954a 100644
--- a/src/features/editor/comments/commentOps.ts
+++ b/src/features/editor/comments/commentOps.ts
@@ -24,7 +24,9 @@ export interface ProjectThread extends CommentThread {
function newId(): string {
const c = globalThis.crypto as Crypto | undefined
- return c?.randomUUID ? c.randomUUID() : `cmt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
+ return c?.randomUUID
+ ? c.randomUUID()
+ : `cmt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
/** Group a segment's flat comments into threads (roots + their replies), in order. */
@@ -93,10 +95,7 @@ export async function setThreadResolved(
}
/** Delete a whole thread (root + replies) and its anchor highlight. */
-export async function deleteThread(
- segmentId: string,
- thread: CommentThread,
-): Promise {
+export async function deleteThread(segmentId: string, thread: CommentThread): Promise {
for (const reply of thread.replies) await segmentRepo.deleteComment(segmentId, reply.id)
await segmentRepo.deleteComment(segmentId, thread.root.id)
if (thread.root.anchorId) await dropAnchor(segmentId, thread.root.anchorId)
@@ -107,7 +106,8 @@ export async function dropAnchor(segmentId: string, anchorId: string): Promise
- {query.trim() ? `${matches.length} match${matches.length === 1 ? '' : 'es'}` : 'Enter a phrase.'}
+ {query.trim()
+ ? `${matches.length} match${matches.length === 1 ? '' : 'es'}`
+ : 'Enter a phrase.'}
@@ -88,14 +90,20 @@ export function ConcordanceDialog({ project }: ConcordanceDialogProps) {
style={{ borderColor: 'var(--color-border)' }}
data-testid={`concordance-hit-${i}`}
>
-
+
{m.field === 'source' ? (
) : (
m.entry.source
)}
-
+
{m.field === 'target' ? (
) : (
diff --git a/src/features/editor/findReplace/FindReplaceDialog.tsx b/src/features/editor/findReplace/FindReplaceDialog.tsx
index cfbd32e..810c7b0 100644
--- a/src/features/editor/findReplace/FindReplaceDialog.tsx
+++ b/src/features/editor/findReplace/FindReplaceDialog.tsx
@@ -49,7 +49,10 @@ export function FindReplaceDialog({ projectId }: FindReplaceDialogProps) {
const seg = segments?.find((s) => s.id === row.segmentId)
const status =
seg && seg.status === 'untranslated' && row.after.trim() ? 'draft' : undefined
- await segmentRepo.update(row.segmentId, status ? { target: row.after, status } : { target: row.after })
+ await segmentRepo.update(
+ row.segmentId,
+ status ? { target: row.after, status } : { target: row.after },
+ )
}
})
setDone(preview.length)
@@ -94,7 +97,10 @@ export function FindReplaceDialog({ projectId }: FindReplaceDialogProps) {
}}
data-testid="fr-replace"
/>
-
+
-
+
{done !== null
? `Replaced in ${done} segment${done === 1 ? '' : 's'}.`
: find
diff --git a/src/features/editor/glossary/GlossaryPanel.tsx b/src/features/editor/glossary/GlossaryPanel.tsx
index a44e5d0..9d6d719 100644
--- a/src/features/editor/glossary/GlossaryPanel.tsx
+++ b/src/features/editor/glossary/GlossaryPanel.tsx
@@ -140,9 +140,17 @@ function HitCard({
)}
{rest.length > 0 && (
-
+
{rest.map((c) => (
-
+
))}
)}
@@ -262,7 +270,8 @@ export function GlossaryPanel({
}, [hits, targetLang, focusedSource])
const candidatesFor = useCallback(
- (entryId: string): TranslationCandidate[] => aiByEntry[entryId] ?? localCandidates[entryId] ?? [],
+ (entryId: string): TranslationCandidate[] =>
+ aiByEntry[entryId] ?? localCandidates[entryId] ?? [],
[aiByEntry, localCandidates],
)
@@ -364,7 +373,11 @@ export function GlossaryPanel({
{header}
{aiState === 'error' && (
-
+
AI ranking failed — showing local ranking.
)}
diff --git a/src/features/editor/history/SegmentHistoryDialog.tsx b/src/features/editor/history/SegmentHistoryDialog.tsx
index 6063b36..5df9f45 100644
--- a/src/features/editor/history/SegmentHistoryDialog.tsx
+++ b/src/features/editor/history/SegmentHistoryDialog.tsx
@@ -75,7 +75,10 @@ export function SegmentHistoryDialog() {
No saved versions include this segment yet.
) : (
-
+
{entries.map((entry, i) => {
// Diff against the next-older entry (or empty for the oldest).
const prev = entries[i + 1]?.target ?? ''
diff --git a/src/features/editor/history/VersionHistoryPanel.tsx b/src/features/editor/history/VersionHistoryPanel.tsx
index ad09532..8da47b6 100644
--- a/src/features/editor/history/VersionHistoryPanel.tsx
+++ b/src/features/editor/history/VersionHistoryPanel.tsx
@@ -4,6 +4,7 @@ import { History, RotateCcw, Save, BadgeCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { versionRepo } from '@/storage/repositories/versionRepo'
import { useCanReview } from '../workflow/useWorkflowStore'
+import { useLocalAuthor } from '../useLocalAuthor'
import { relativeTime } from './relativeTime'
interface VersionHistoryPanelProps {
@@ -26,11 +27,14 @@ export function VersionHistoryPanel({ projectId }: VersionHistoryPanelProps) {
// Sign-off is a review action — revisor / project_manager only (§5.3). A
// local-only project resolves to PM-of-self, so it's always available there.
const canReview = useCanReview()
+ // Account-aware name (account name when signed in, else the device-local one)
+ // so versions and sign-offs are attributed consistently across the app.
+ const { authorName } = useLocalAuthor()
async function handleSave() {
setSaving(true)
try {
- await versionRepo.saveNamed(projectId, label)
+ await versionRepo.saveNamed(projectId, label, undefined, authorName)
setLabel('')
} finally {
setSaving(false)
@@ -40,7 +44,7 @@ export function VersionHistoryPanel({ projectId }: VersionHistoryPanelProps) {
async function handleSignOff() {
setSigningOff(true)
try {
- await versionRepo.signOff(projectId)
+ await versionRepo.signOff(projectId, undefined, authorName)
} finally {
setSigningOff(false)
}
diff --git a/src/features/editor/layoutSync.ts b/src/features/editor/layoutSync.ts
index 7d6a325..bba69d7 100644
--- a/src/features/editor/layoutSync.ts
+++ b/src/features/editor/layoutSync.ts
@@ -1,11 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { isCloudConfigured, getSupabase } from '@/storage/cloud/supabaseClient'
import { useAuthStore } from '@/features/account/useAuthStore'
-import {
- useSidebarPanelStore,
- defaultLayout,
- type SidebarTab,
-} from './useSidebarPanelStore'
+import { useSidebarPanelStore, defaultLayout, type SidebarTab } from './useSidebarPanelStore'
import type { EditorStage } from './useEditorModeStore'
/**
diff --git a/src/features/editor/lookup/LookupPanel.tsx b/src/features/editor/lookup/LookupPanel.tsx
index 3c43e17..fce6d7e 100644
--- a/src/features/editor/lookup/LookupPanel.tsx
+++ b/src/features/editor/lookup/LookupPanel.tsx
@@ -30,12 +30,7 @@ export function LookupPanel({ sourceLang, targetLang }: LookupPanelProps) {
)}
-
+
{enabledProviders.length > 0 && (
diff --git a/src/features/editor/mt/MTPanel.tsx b/src/features/editor/mt/MTPanel.tsx
index 7a46e45..d859983 100644
--- a/src/features/editor/mt/MTPanel.tsx
+++ b/src/features/editor/mt/MTPanel.tsx
@@ -158,10 +158,7 @@ export function MTPanel({ focusedSource, sourceLang, targetLang, onApply }: MTPa
Apply
-
diff --git a/src/features/editor/mt/useMTSettings.ts b/src/features/editor/mt/useMTSettings.ts
index 15e4e4b..6c8b1a5 100644
--- a/src/features/editor/mt/useMTSettings.ts
+++ b/src/features/editor/mt/useMTSettings.ts
@@ -1,11 +1,7 @@
import { useMemo, useSyncExternalStore } from 'react'
import { useLiveQuery } from 'dexie-react-hooks'
import type { MTProviderId, MTSettings } from '@/core/types'
-import {
- MT_SETTINGS_KEY,
- mergeMTSettings,
- settingsRepo,
-} from '@/storage/repositories/settingsRepo'
+import { MT_SETTINGS_KEY, mergeMTSettings, settingsRepo } from '@/storage/repositories/settingsRepo'
import { resolveDefaultProvider, isProviderExtensionEnabled } from '@/core/mt'
import { extensionRegistry } from '@/core/extensions/registry'
@@ -14,10 +10,7 @@ export function useMTSettings(): {
defaultProvider: MTProviderId | undefined
enabledIds: MTProviderId[]
} {
- const stored = useLiveQuery(
- () => settingsRepo.get>(MT_SETTINGS_KEY),
- [],
- )
+ const stored = useLiveQuery(() => settingsRepo.get>(MT_SETTINGS_KEY), [])
// Re-derive when an MT addon is enabled/disabled in the registry (§6.1), so
// disabling one removes it from the panel live.
const registryVersion = useSyncExternalStore(
diff --git a/src/features/editor/mt/useMTTranslate.ts b/src/features/editor/mt/useMTTranslate.ts
index 36f29d2..e3e17be 100644
--- a/src/features/editor/mt/useMTTranslate.ts
+++ b/src/features/editor/mt/useMTTranslate.ts
@@ -67,8 +67,7 @@ export function useMTTranslate(opts: UseMTTranslateOptions): UseMTTranslateState
} catch (err) {
if (controller.signal.aborted) return
if (err instanceof MTError) setError(err)
- else if (err instanceof Error)
- setError(new MTError(opts.providerId, 'invalid', err.message))
+ else if (err instanceof Error) setError(new MTError(opts.providerId, 'invalid', err.message))
else setError(new MTError(opts.providerId, 'invalid', 'Translation failed'))
} finally {
if (abortRef.current === controller) abortRef.current = null
diff --git a/src/features/editor/peers/PeersPanel.tsx b/src/features/editor/peers/PeersPanel.tsx
index 27337a7..2a523d6 100644
--- a/src/features/editor/peers/PeersPanel.tsx
+++ b/src/features/editor/peers/PeersPanel.tsx
@@ -2,6 +2,7 @@ import { Users, Wifi } from 'lucide-react'
import { shareRepo } from '@/storage/repositories/shareRepo'
import { isTauri } from '@/storage/sync/platform'
import { usePresenceStore } from './usePresenceStore'
+import { PEER_NAME_FALLBACK } from '@/features/account/displayName'
/**
* The Peers sidebar tab (Foundation F3, Phase 14).
@@ -71,7 +72,7 @@ export function PeersPanel({ projectId }: PeersPanelProps) {
style={{ background: peer.color }}
/>
- {peer.displayName || 'Anonymous'}
+ {peer.displayName || PEER_NAME_FALLBACK}
{peer.activeSegmentId && (
diff --git a/src/features/editor/peers/PeersPresenceChip.tsx b/src/features/editor/peers/PeersPresenceChip.tsx
index 03fb0bd..eca53b6 100644
--- a/src/features/editor/peers/PeersPresenceChip.tsx
+++ b/src/features/editor/peers/PeersPresenceChip.tsx
@@ -20,9 +20,7 @@ export function PeersPresenceChip({ onOpen }: PeersPresenceChipProps) {
type="button"
onClick={onOpen}
data-testid="peers-presence-chip"
- aria-label={
- shared ? `Peers — ${peers.length} on this network` : 'Peers — sharing off'
- }
+ aria-label={shared ? `Peers — ${peers.length} on this network` : 'Peers — sharing off'}
title={
shared
? `${peers.length} peer${peers.length === 1 ? '' : 's'} on this network`
diff --git a/src/features/editor/peers/useProjectSync.ts b/src/features/editor/peers/useProjectSync.ts
index a969fbe..9788726 100644
--- a/src/features/editor/peers/useProjectSync.ts
+++ b/src/features/editor/peers/useProjectSync.ts
@@ -17,10 +17,7 @@ import { usePresenceStore } from './usePresenceStore'
export function useProjectSync(projectId: string | undefined): {
setActiveSegment: (id: string | undefined, caret?: CaretRange) => void
} {
- const share = useLiveQuery(
- () => (projectId ? shareRepo.get(projectId) : undefined),
- [projectId],
- )
+ const share = useLiveQuery(() => (projectId ? shareRepo.get(projectId) : undefined), [projectId])
const shared = share?.shared ?? false
const setShared = usePresenceStore((s) => s.setShared)
diff --git a/src/features/editor/preview/DocumentPreview.tsx b/src/features/editor/preview/DocumentPreview.tsx
index 020312f..6987272 100644
--- a/src/features/editor/preview/DocumentPreview.tsx
+++ b/src/features/editor/preview/DocumentPreview.tsx
@@ -37,7 +37,10 @@ export function DocumentPreview({ projectId }: { projectId: string }) {
return (
-
+
Document preview
+
{(block.rows ?? []).map((row, ri) => (
@@ -143,10 +150,15 @@ function PreviewBlock({
style={{
borderColor: 'var(--color-border)',
cursor: cell.ref ? 'pointer' : undefined,
- color: cell.ref && !cell.ref.translated ? 'var(--color-muted)' : 'var(--color-text)',
+ color:
+ cell.ref && !cell.ref.translated ? 'var(--color-muted)' : 'var(--color-text)',
}}
>
- {mode === 'source' ? : cell.targetText}
+ {mode === 'source' ? (
+
+ ) : (
+ cell.targetText
+ )}
))}
@@ -205,6 +217,12 @@ function wrapperFor(block: RenderBlock): (props: BlockWrapperProps) => ReactNode
}
if (block.kind === 'code') return (props) =>
if (block.kind === 'blockquote') return (props) =>
- if (block.kind === 'list_item') return (props) =>
+ if (block.kind === 'list_item')
+ return (props) => (
+
+ )
return (props) =>
}
diff --git a/src/features/editor/rich/ChangeHoverCard.tsx b/src/features/editor/rich/ChangeHoverCard.tsx
index 93d942c..f49485c 100644
--- a/src/features/editor/rich/ChangeHoverCard.tsx
+++ b/src/features/editor/rich/ChangeHoverCard.tsx
@@ -96,7 +96,10 @@ export function ChangeHoverCard({
// re-open/close the card) or moving the editor selection.
onMouseDown={(e) => e.preventDefault()}
>
-
+
{card.type === 'insert' ? 'Insertion' : 'Deletion'}
{card.author ? ` · ${card.author}` : ''}
diff --git a/src/features/editor/rich/ChangeMarkNode.ts b/src/features/editor/rich/ChangeMarkNode.ts
index 8e2a159..47c1eab 100644
--- a/src/features/editor/rich/ChangeMarkNode.ts
+++ b/src/features/editor/rich/ChangeMarkNode.ts
@@ -175,9 +175,7 @@ export function $createChangeMarkNode(attrs: ChangeMarkAttrs): ChangeMarkNode {
return new ChangeMarkNode(attrs)
}
-export function $isChangeMarkNode(
- node: LexicalNode | null | undefined,
-): node is ChangeMarkNode {
+export function $isChangeMarkNode(node: LexicalNode | null | undefined): node is ChangeMarkNode {
return node instanceof ChangeMarkNode
}
diff --git a/src/features/editor/rich/CommentMarkNode.ts b/src/features/editor/rich/CommentMarkNode.ts
index 98e6186..d2e7b58 100644
--- a/src/features/editor/rich/CommentMarkNode.ts
+++ b/src/features/editor/rich/CommentMarkNode.ts
@@ -93,9 +93,7 @@ export function $createCommentMarkNode(anchorId: string): CommentMarkNode {
return new CommentMarkNode(anchorId)
}
-export function $isCommentMarkNode(
- node: LexicalNode | null | undefined,
-): node is CommentMarkNode {
+export function $isCommentMarkNode(node: LexicalNode | null | undefined): node is CommentMarkNode {
return node instanceof CommentMarkNode
}
diff --git a/src/features/editor/rich/FormatToolbar.tsx b/src/features/editor/rich/FormatToolbar.tsx
index fe4a652..4557e90 100644
--- a/src/features/editor/rich/FormatToolbar.tsx
+++ b/src/features/editor/rich/FormatToolbar.tsx
@@ -72,7 +72,9 @@ export function FormatToolbar({
const [commentOpen, setCommentOpen] = useState(false)
const [commentBody, setCommentBody] = useState('')
- const [commentAnchor, setCommentAnchor] = useState<{ anchorId: string; quote: string } | null>(null)
+ const [commentAnchor, setCommentAnchor] = useState<{ anchorId: string; quote: string } | null>(
+ null,
+ )
const commentRef = useRef(null)
useEffect(() => {
diff --git a/src/features/editor/rich/InlineTagNode.tsx b/src/features/editor/rich/InlineTagNode.tsx
index 6bbc7f3..4ddd896 100644
--- a/src/features/editor/rich/InlineTagNode.tsx
+++ b/src/features/editor/rich/InlineTagNode.tsx
@@ -76,7 +76,10 @@ export class InlineTagNode extends DecoratorNode {
}
}
-export function $createInlineTagNode(tagId: string, kind: InlineTagKind = 'generic'): InlineTagNode {
+export function $createInlineTagNode(
+ tagId: string,
+ kind: InlineTagKind = 'generic',
+): InlineTagNode {
return new InlineTagNode(tagId, kind)
}
diff --git a/src/features/editor/rich/RemoteCaretOverlay.tsx b/src/features/editor/rich/RemoteCaretOverlay.tsx
index 368a64a..8fd8dda 100644
--- a/src/features/editor/rich/RemoteCaretOverlay.tsx
+++ b/src/features/editor/rich/RemoteCaretOverlay.tsx
@@ -4,6 +4,7 @@ import { $getRoot } from 'lexical'
import { mapOffsetToNode } from '@/core/spell/offsets'
import { usePresenceStore } from '../peers/usePresenceStore'
import { buildLeafSpans } from './richOffsets'
+import { PEER_NAME_FALLBACK } from '@/features/account/displayName'
/**
* Live remote cursors for the rich target editor (ROADMAP §4.4.1).
@@ -55,7 +56,9 @@ export function RemoteCaretOverlay({
try {
const container = containerRef.current
if (!container) return
- const spans = editor.getEditorState().read(() => buildLeafSpans($getRoot().getFirstChild()))
+ const spans = editor
+ .getEditorState()
+ .read(() => buildLeafSpans($getRoot().getFirstChild()))
const crect = container.getBoundingClientRect()
const next: CaretPaint[] = []
for (const p of remotes) {
@@ -71,7 +74,7 @@ export function RemoteCaretOverlay({
const rect = range.getClientRects()[0] ?? range.getBoundingClientRect()
next.push({
peerId: p.peerId,
- name: p.displayName || 'Anonymous',
+ name: p.displayName || PEER_NAME_FALLBACK,
color: p.color,
left: rect.left - crect.left,
top: rect.top - crect.top,
@@ -98,7 +101,11 @@ export function RemoteCaretOverlay({
if (carets.length === 0) return null
return (
-
+
{carets.map((c) => (
u())
propsRef.current.registerHandle(null)
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
// When the DB target diverges from what we last saved, an external action
@@ -449,13 +448,7 @@ function rebuildFromPlain(
* the target at the caret; F9 inserts the next missing tag without leaving the
* keyboard. Shown only when the segment actually has source tags.
*/
-function TagStrip({
- source,
- inlineTags,
-}: {
- source: string
- inlineTags?: Record
-}) {
+function TagStrip({ source, inlineTags }: { source: string; inlineTags?: Record }) {
const [editor] = useLexicalComposerContext()
// Distinct source tag ids, in first-appearance order.
const ids = Array.from(new Set(listTagIds(source)))
@@ -473,9 +466,7 @@ function TagStrip({
id={id}
kind={classifyInlineTagKind(inlineTags?.[id])}
title={`Insert tag ${id} (F9 for next)`}
- onClick={() =>
- insertInlineTag(editor, id, classifyInlineTagKind(inlineTags?.[id]))
- }
+ onClick={() => insertInlineTag(editor, id, classifyInlineTagKind(inlineTags?.[id]))}
/>
))}
diff --git a/src/features/editor/rich/SpellUnderlinePlugin.tsx b/src/features/editor/rich/SpellUnderlinePlugin.tsx
index 282bc32..a2ef73c 100644
--- a/src/features/editor/rich/SpellUnderlinePlugin.tsx
+++ b/src/features/editor/rich/SpellUnderlinePlugin.tsx
@@ -91,8 +91,13 @@ export function SpellUnderlinePlugin({
editor.getEditorState().read(() => {
const para = $getRoot().getFirstChild()
const children =
- para && 'getChildren' in para ? (para as { getChildren: () => unknown[] }).getChildren() : []
- for (const child of children as Array<{ getKey: () => string; getTextContent: () => string }>) {
+ para && 'getChildren' in para
+ ? (para as { getChildren: () => unknown[] }).getChildren()
+ : []
+ for (const child of children as Array<{
+ getKey: () => string
+ getTextContent: () => string
+ }>) {
spans.push({
key: child.getKey(),
length: child.getTextContent().length,
diff --git a/src/features/editor/rich/TrackedChangesPlugin.tsx b/src/features/editor/rich/TrackedChangesPlugin.tsx
index 1f8fac8..df5b47b 100644
--- a/src/features/editor/rich/TrackedChangesPlugin.tsx
+++ b/src/features/editor/rich/TrackedChangesPlugin.tsx
@@ -98,7 +98,6 @@ export function TrackedChangesPlugin({ locked }: { locked?: boolean }) {
),
]
return () => unregister.forEach((u) => u())
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
return null
diff --git a/src/features/editor/rich/comments.ts b/src/features/editor/rich/comments.ts
index 111bf8e..a7064ce 100644
--- a/src/features/editor/rich/comments.ts
+++ b/src/features/editor/rich/comments.ts
@@ -63,7 +63,11 @@ export function removeCommentMark(
anchorId: string,
): { targetRich: string; target: string } | null {
if (!targetRich) return null
- const editor = createEditor({ namespace: RICH_NAMESPACE, nodes: getRichNodes(), onError: () => {} })
+ const editor = createEditor({
+ namespace: RICH_NAMESPACE,
+ nodes: getRichNodes(),
+ onError: () => {},
+ })
let found = false
try {
editor.setEditorState(editor.parseEditorState(targetRich))
diff --git a/src/features/editor/rich/resolve.ts b/src/features/editor/rich/resolve.ts
index c676831..3f58083 100644
--- a/src/features/editor/rich/resolve.ts
+++ b/src/features/editor/rich/resolve.ts
@@ -1,9 +1,4 @@
-import {
- createEditor,
- $getRoot,
- $isElementNode,
- type LexicalNode,
-} from 'lexical'
+import { createEditor, $getRoot, $isElementNode, type LexicalNode } from 'lexical'
import { RICH_NAMESPACE, getRichNodes, richStateToPlain } from '@/core/editor/richText'
import { $isChangeMarkNode, ChangeMarkNode } from './ChangeMarkNode'
diff --git a/src/features/editor/rich/richOffsets.ts b/src/features/editor/rich/richOffsets.ts
index 817ac90..e9f0aee 100644
--- a/src/features/editor/rich/richOffsets.ts
+++ b/src/features/editor/rich/richOffsets.ts
@@ -27,7 +27,8 @@ function offsetWithin(node: LexicalNode, point: Point): number | null {
if (node.getKey() === point.key) {
// Element point: `offset` is a child index → sum the plain text before it.
let acc = 0
- for (let i = 0; i < point.offset && i < kids.length; i += 1) acc += kids[i].getTextContent().length
+ for (let i = 0; i < point.offset && i < kids.length; i += 1)
+ acc += kids[i].getTextContent().length
return acc
}
if (kids.length === 0) return null // atomic node (tag / line break), point not on it
diff --git a/src/features/editor/rich/suggest.ts b/src/features/editor/rich/suggest.ts
index 9a8320e..c1e3f03 100644
--- a/src/features/editor/rich/suggest.ts
+++ b/src/features/editor/rich/suggest.ts
@@ -51,9 +51,7 @@ function newMark(type: ChangeType, author: SuggestionAuthor): ChangeMarkNode {
/** True when `mark` is a pending insertion owned by `author`. */
function isOwnInsert(mark: ChangeMarkNode | null, author: SuggestionAuthor): boolean {
- return (
- !!mark && mark.getChangeType() === 'insert' && mark.getAuthorId() === author.authorId
- )
+ return !!mark && mark.getChangeType() === 'insert' && mark.getAuthorId() === author.authorId
}
/**
diff --git a/src/features/editor/segmentAutosave.ts b/src/features/editor/segmentAutosave.ts
index ef9714a..96cbd7c 100644
--- a/src/features/editor/segmentAutosave.ts
+++ b/src/features/editor/segmentAutosave.ts
@@ -5,10 +5,7 @@ import type { SegmentStatus } from '@/core/types'
* identically: typing into an untranslated segment makes it a draft; clearing a
* draft returns it to untranslated. Any other status is left untouched.
*/
-export function autosaveStatus(
- target: string,
- status: SegmentStatus,
-): SegmentStatus | undefined {
+export function autosaveStatus(target: string, status: SegmentStatus): SegmentStatus | undefined {
const trimmed = target.trim()
if (status === 'untranslated' && trimmed.length > 0) return 'draft'
if (status === 'draft' && trimmed.length === 0) return 'untranslated'
diff --git a/src/features/editor/segments/SegmentActionsMenu.tsx b/src/features/editor/segments/SegmentActionsMenu.tsx
index 91e25f3..14da9ec 100644
--- a/src/features/editor/segments/SegmentActionsMenu.tsx
+++ b/src/features/editor/segments/SegmentActionsMenu.tsx
@@ -56,8 +56,8 @@ export function SegmentActionsMenu({
const joinTitle = isBilingual
? 'Not available for XLIFF projects (would break round-trip)'
: !canJoinNext
- ? 'No segment to join in the same block'
- : undefined
+ ? 'No segment to join in the same block'
+ : undefined
const items: MenuItem[] = [
{
diff --git a/src/features/editor/segments/SplitSegmentDialog.tsx b/src/features/editor/segments/SplitSegmentDialog.tsx
index b65109c..0f210d4 100644
--- a/src/features/editor/segments/SplitSegmentDialog.tsx
+++ b/src/features/editor/segments/SplitSegmentDialog.tsx
@@ -55,8 +55,8 @@ export function SplitSegmentDialog({ projectId }: SplitSegmentDialogProps) {
Split segment
- Click in the source where it should be divided, then split. The first part
- keeps its translation; the second becomes a new untranslated segment.
+ Click in the source where it should be divided, then split. The first part keeps its
+ translation; the second becomes a new untranslated segment.
diff --git a/src/features/editor/spell/SpellPanel.tsx b/src/features/editor/spell/SpellPanel.tsx
index 2824ebe..9b0971d 100644
--- a/src/features/editor/spell/SpellPanel.tsx
+++ b/src/features/editor/spell/SpellPanel.tsx
@@ -58,7 +58,10 @@ export function SpellPanel({ targetLang, focusedTarget, focusedSegmentId }: Spel
if (!active) return
const visible = tokens.filter((t) => !ignored.has(t.word.toLowerCase()))
const withSuggestions = await Promise.all(
- visible.map(async (token) => ({ token, suggestions: (await suggest(token.word)).slice(0, 5) })),
+ visible.map(async (token) => ({
+ token,
+ suggestions: (await suggest(token.word)).slice(0, 5),
+ })),
)
if (active) setMistakes(withSuggestions)
})()
diff --git a/src/features/editor/tm/TMPanel.tsx b/src/features/editor/tm/TMPanel.tsx
index bc7d4eb..0fdc52d 100644
--- a/src/features/editor/tm/TMPanel.tsx
+++ b/src/features/editor/tm/TMPanel.tsx
@@ -74,10 +74,7 @@ function MatchCard({
>
{match.entry.source}
-
diff --git a/src/features/editor/tools/ExportDocxButton.tsx b/src/features/editor/tools/ExportDocxButton.tsx
index 9bf9405..2d0f35f 100644
--- a/src/features/editor/tools/ExportDocxButton.tsx
+++ b/src/features/editor/tools/ExportDocxButton.tsx
@@ -64,14 +64,22 @@ export function ExportDocxButton({ projectId, name }: { projectId: string; name:
onClick={() => void onExport()}
disabled={busy}
data-testid="export-docx"
- title={pending ? 'Pending suggestions will be exported as accepted' : 'Export the translated document as .docx'}
+ title={
+ pending
+ ? 'Pending suggestions will be exported as accepted'
+ : 'Export the translated document as .docx'
+ }
className="inline-flex items-center gap-1.5 rounded-full border h-8 px-3 text-footnote transition-colors hover:bg-[var(--color-fill)] disabled:opacity-50"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-muted)' }}
>
{busy ? 'Exporting…' : 'Export .docx'}
{pending && (
-
+
)}
@@ -81,7 +89,9 @@ export function ExportDocxButton({ projectId, name }: { projectId: string; name:
testId="export-docx-gdrive"
filename={filename}
buildBlob={buildBlob}
- loadConnector={async () => (await import('@/extensions/connectors/gdrive')).gdriveConnector}
+ loadConnector={async () =>
+ (await import('@/extensions/connectors/gdrive')).gdriveConnector
+ }
/>
)}
{onedrive && (
@@ -90,7 +100,9 @@ export function ExportDocxButton({ projectId, name }: { projectId: string; name:
testId="export-docx-onedrive"
filename={filename}
buildBlob={buildBlob}
- loadConnector={async () => (await import('@/extensions/connectors/onedrive')).onedriveConnector}
+ loadConnector={async () =>
+ (await import('@/extensions/connectors/onedrive')).onedriveConnector
+ }
/>
)}
@@ -141,7 +153,10 @@ function SaveToCloudButton({
data-testid={testId}
title={error ?? `Save the translated .docx to ${label}`}
className="inline-flex items-center gap-1.5 rounded-full border h-8 px-3 text-footnote transition-colors hover:bg-[var(--color-fill)] disabled:opacity-50"
- style={{ borderColor: 'var(--color-border)', color: error ? 'var(--color-error)' : 'var(--color-muted)' }}
+ style={{
+ borderColor: 'var(--color-border)',
+ color: error ? 'var(--color-error)' : 'var(--color-muted)',
+ }}
>
{saved ? : }
{saving ? 'Saving…' : saved ? `Saved to ${label}` : `Save to ${label}`}
diff --git a/src/features/editor/useEditorModeStore.ts b/src/features/editor/useEditorModeStore.ts
index eb68194..6cb1ddd 100644
--- a/src/features/editor/useEditorModeStore.ts
+++ b/src/features/editor/useEditorModeStore.ts
@@ -61,5 +61,6 @@ export const useEditorModeStore = create((set, get) => ({
setReviewMode: (on) => get().setStage(on ? 'revise' : 'translate'),
setStatusFilter: (statusFilter) => set({ statusFilter }),
setEditMode: (editMode) => set({ editMode }),
- toggleEditMode: () => set({ editMode: get().editMode === 'suggesting' ? 'direct' : 'suggesting' }),
+ toggleEditMode: () =>
+ set({ editMode: get().editMode === 'suggesting' ? 'direct' : 'suggesting' }),
}))
diff --git a/src/features/editor/useLocalAuthor.ts b/src/features/editor/useLocalAuthor.ts
index 97b9ddf..873b0d3 100644
--- a/src/features/editor/useLocalAuthor.ts
+++ b/src/features/editor/useLocalAuthor.ts
@@ -7,6 +7,7 @@ import {
type LocalAuthor,
type ProfileSettings,
} from '@/storage/repositories/settingsRepo'
+import { useDisplayName } from '@/features/account/displayName'
// Mint/persist the stable author id once for the whole app, shared across every
// segment editor so we don't write it N times on load.
@@ -39,6 +40,8 @@ export function useLocalAuthor(): LocalAuthor {
// Prefer the reactive stored id once available; fall back to the minted one.
const id = profile?.authorId ?? authorId
- const name = (profile?.displayName ?? '').trim() || 'You'
+ // When signed in, attribute to the account name automatically; else the
+ // device-local name, else "You".
+ const name = useDisplayName(profile?.displayName)
return { authorId: id, authorName: name }
}
diff --git a/src/features/editor/useSidebarPanelStore.ts b/src/features/editor/useSidebarPanelStore.ts
index 4b99e6a..a55c518 100644
--- a/src/features/editor/useSidebarPanelStore.ts
+++ b/src/features/editor/useSidebarPanelStore.ts
@@ -108,7 +108,10 @@ export const useSidebarPanelStore = create()(
return { layout: { ...s.layout, [stage]: cur }, updatedAt: Date.now() }
}),
resetLayout: (stage) =>
- set((s) => ({ layout: { ...s.layout, [stage]: [...STAGE_TABS[stage]] }, updatedAt: Date.now() })),
+ set((s) => ({
+ layout: { ...s.layout, [stage]: [...STAGE_TABS[stage]] },
+ updatedAt: Date.now(),
+ })),
}),
{
name: 'verbalis.sidebar.layout',
diff --git a/src/features/editor/workflow/useWorkflowStore.ts b/src/features/editor/workflow/useWorkflowStore.ts
index 9d10184..5df61c9 100644
--- a/src/features/editor/workflow/useWorkflowStore.ts
+++ b/src/features/editor/workflow/useWorkflowStore.ts
@@ -29,7 +29,9 @@ export const useWorkflowStore = create((set) => ({
/** Whether the current role may accept/reject tracked changes (revisor / PM). */
export function useCanResolveChanges(): boolean {
- return useWorkflowStore((s) => workflowCapabilities(s.role, s.stage, 'translated').canResolveChanges)
+ return useWorkflowStore(
+ (s) => workflowCapabilities(s.role, s.stage, 'translated').canResolveChanges,
+ )
}
/** Whether the current role/stage forces suggesting mode (translator in review). */
diff --git a/src/features/guide/Markdown.tsx b/src/features/guide/Markdown.tsx
index 035d6d0..759cc4d 100644
--- a/src/features/guide/Markdown.tsx
+++ b/src/features/guide/Markdown.tsx
@@ -64,7 +64,10 @@ function renderNode(node: any): ReactNode {
case 'heading': {
const Tag = `h${node.depth}` as keyof JSX.IntrinsicElements
return (
-
+
{renderChildren(node.children)}
)
diff --git a/src/features/guide/index.tsx b/src/features/guide/index.tsx
index 1dce1d6..67e2261 100644
--- a/src/features/guide/index.tsx
+++ b/src/features/guide/index.tsx
@@ -51,9 +51,8 @@ export default function GuidePage() {
style={{ borderColor: 'var(--color-border)', background: 'var(--color-surface)' }}
>
- Reference material for Brazilian Portuguese ↔ British English work: the translate /
- revise workflow, British-English & ABNT/NBR conventions, and translation-theory
- definitions.
+ Reference material for Brazilian Portuguese ↔ British English work: the translate / revise
+ workflow, British-English & ABNT/NBR conventions, and translation-theory definitions.
Where the source workflow refers to a terminology lookup, use the installed{' '}
diff --git a/src/features/import/ImportDialog.tsx b/src/features/import/ImportDialog.tsx
index 6343c2d..bee4f87 100644
--- a/src/features/import/ImportDialog.tsx
+++ b/src/features/import/ImportDialog.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import {
Dialog,
DialogContent,
@@ -18,6 +18,11 @@ import type { ConnectorFile, StorageConnector } from '@/extensions/connectors/ty
import { isGdriveAvailable } from '@/extensions/connectors/gdrive/config'
import { isOnedriveAvailable } from '@/extensions/connectors/onedrive/config'
import { useImportProject } from './useImportProject'
+import {
+ getLookupSettings,
+ settingsRepo,
+ LOOKUP_SETTINGS_KEY,
+} from '@/storage/repositories/settingsRepo'
interface ImportDialogProps {
open: boolean
@@ -54,6 +59,25 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
const gdrive = isGdriveAvailable()
const onedrive = isOnedriveAvailable()
+ // Seed the language pair from the user's saved lookup defaults / last-used
+ // languages when the dialog opens, instead of a fixed guess. Falls back to a
+ // sensible distinct pair (this is a PT↔EN-oriented tool) so the monolingual
+ // "source ≠ target" guard is satisfied out of the box.
+ useEffect(() => {
+ if (!open) return
+ void getLookupSettings().then((l) => {
+ const target = l.defaultTargetLang
+ const source =
+ l.lastSourceLang && l.lastSourceLang !== target
+ ? l.lastSourceLang
+ : target === 'en'
+ ? 'pt'
+ : 'en'
+ setSourceLang(source)
+ setTargetLang(target)
+ })
+ }, [open])
+
function reset() {
setName('')
setSourceLang('en')
@@ -96,6 +120,12 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
if (!bilingual && sourceLang === targetLang) return
try {
await importProject({ file, name: name.trim(), sourceLang, targetLang })
+ // Remember the working languages so the next import and the quick lookup
+ // default to them.
+ void settingsRepo.set(LOOKUP_SETTINGS_KEY, {
+ defaultTargetLang: targetLang,
+ lastSourceLang: sourceLang,
+ })
reset()
onOpenChange(false)
} catch {
@@ -111,157 +141,161 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
return (
<>
- {
- if (!o) reset()
- onOpenChange(o)
- }}
- >
- {
+ if (!o) reset()
+ onOpenChange(o)
+ }}
>
-
- Import file
-
- Create a new project from a TXT, MD, DOCX, or XLIFF (XLF / mqxliff) file.
-
-
+
+
+ Import file
+
+ Create a new project from a TXT, MD, DOCX, or XLIFF (XLF / mqxliff) file.
+
+
-
-
-
Source file
-
- {(gdrive || onedrive) && (
-
- {gdrive && (
- void openDrivePicker()}
- data-testid="import-from-gdrive"
- >
-
- From Google Drive
-
- )}
- {onedrive && (
- void openOnedrivePicker()}
- data-testid="import-from-onedrive"
- >
-
- From OneDrive
-
- )}
-
- )}
- {file && (
-
- Selected: {file.name}
-
- )}
-
+
+
+
Source file
+
+ {(gdrive || onedrive) && (
+
+ {gdrive && (
+ void openDrivePicker()}
+ data-testid="import-from-gdrive"
+ >
+
+ From Google Drive
+
+ )}
+ {onedrive && (
+ void openOnedrivePicker()}
+ data-testid="import-from-onedrive"
+ >
+
+ From OneDrive
+
+ )}
+
+ )}
+ {file && (
+
+ Selected: {file.name}
+
+ )}
+
-
- Project name
- setName(e.target.value)}
- placeholder="My document"
- required
- />
-
+
+ Project name
+ setName(e.target.value)}
+ placeholder="My document"
+ required
+ />
+
- {bilingual ? (
-
- Source and target languages are read from the XLIFF file.
-
- ) : (
- <>
-
-
- Source
- setSourceLang(e.target.value)}
- >
- {LANGUAGE_OPTIONS.map((l) => (
-
- {l.label} ({l.code})
-
- ))}
-
-
-
-
Target
-
setTargetLang(e.target.value)}
- >
- {LANGUAGE_OPTIONS.map((l) => (
-
- {l.label} ({l.code})
-
- ))}
-
+ {bilingual ? (
+
+ Source and target languages are read from the XLIFF file.
+
+ ) : (
+ <>
+
+
+ Source
+ setSourceLang(e.target.value)}
+ >
+ {LANGUAGE_OPTIONS.map((l) => (
+
+ {l.label} ({l.code})
+
+ ))}
+
+
+
+ Target
+ setTargetLang(e.target.value)}
+ >
+ {LANGUAGE_OPTIONS.map((l) => (
+
+ {l.label} ({l.code})
+
+ ))}
+
+
-
- {sourceLang === targetLang && (
-
- Source and target languages must differ.
-
- )}
- >
- )}
+ {sourceLang === targetLang && (
+
+ Source and target languages must differ.
+
+ )}
+ >
+ )}
- {error && (
-
- {error}
-
- )}
+ {error && (
+
+ {error}
+
+ )}
-
- onOpenChange(false)}
- disabled={isImporting}
- >
- Cancel
-
-
- {isImporting ? 'Importing…' : 'Import'}
-
-
-
-
-
+
+ onOpenChange(false)}
+ disabled={isImporting}
+ >
+ Cancel
+
+
+ {isImporting ? 'Importing…' : 'Import'}
+
+
+
+
+
- {connector && (
-
- )}
+ {connector && (
+
+ )}
>
)
}
diff --git a/src/features/lookup/QuickLookupDialog.tsx b/src/features/lookup/QuickLookupDialog.tsx
index 99d9c30..e43d79a 100644
--- a/src/features/lookup/QuickLookupDialog.tsx
+++ b/src/features/lookup/QuickLookupDialog.tsx
@@ -6,10 +6,12 @@ import { useQuickLookupStore } from './useQuickLookupStore'
export function QuickLookupDialog() {
const open = useQuickLookupStore((s) => s.open)
const prefill = useQuickLookupStore((s) => s.prefill)
- const close = useQuickLookupStore((s) => s.close)
+ const sourceLang = useQuickLookupStore((s) => s.sourceLang)
+ const targetLang = useQuickLookupStore((s) => s.targetLang)
+ const setOpen = useQuickLookupStore((s) => s.setOpen)
return (
-
!v && close()}>
+
-
+
diff --git a/src/features/lookup/useQuickLookupStore.ts b/src/features/lookup/useQuickLookupStore.ts
index f4560d4..22d0bd4 100644
--- a/src/features/lookup/useQuickLookupStore.ts
+++ b/src/features/lookup/useQuickLookupStore.ts
@@ -1,15 +1,42 @@
import { create } from 'zustand'
+export interface LookupLangs {
+ source: string
+ target: string
+}
+
interface QuickLookupState {
open: boolean
prefill: string
- openWith: (prefill?: string) => void
- close: () => void
+ /** Effective languages to seed the lookup with (project langs when opened from
+ * inside a project, else undefined → fall back to the global lookup defaults). */
+ sourceLang?: string
+ targetLang?: string
+ /** The active project's languages, published by the editor while mounted so
+ * every lookup trigger defaults to them without prop-drilling. */
+ projectLangs: LookupLangs | null
+ openWith: (prefill?: string, langs?: LookupLangs) => void
+ setProjectLangs: (langs: LookupLangs | null) => void
+ /** Matches the `setOpen(boolean)` convention shared by the other disclosure
+ * stores (concordance, add-term, find-replace, …). */
+ setOpen: (open: boolean) => void
}
-export const useQuickLookupStore = create((set) => ({
+export const useQuickLookupStore = create((set, get) => ({
open: false,
prefill: '',
- openWith: (prefill = '') => set({ open: true, prefill }),
- close: () => set({ open: false, prefill: '' }),
+ sourceLang: undefined,
+ targetLang: undefined,
+ projectLangs: null,
+ openWith: (prefill = '', langs) => {
+ const effective = langs ?? get().projectLangs ?? undefined
+ set({
+ open: true,
+ prefill,
+ sourceLang: effective?.source,
+ targetLang: effective?.target,
+ })
+ },
+ setProjectLangs: (projectLangs) => set({ projectLangs }),
+ setOpen: (open) => set(open ? { open } : { open: false, prefill: '' }),
}))
diff --git a/src/features/projects/DeleteProjectDialog.tsx b/src/features/projects/DeleteProjectDialog.tsx
index df5f07c..a59f359 100644
--- a/src/features/projects/DeleteProjectDialog.tsx
+++ b/src/features/projects/DeleteProjectDialog.tsx
@@ -69,17 +69,13 @@ export function DeleteProjectDialog() {
data-testid="delete-project-dialog"
>
-
+
Delete project
- This permanently deletes {project?.name} and its{' '}
- {stats?.segments ?? 0} segment{stats?.segments === 1 ? '' : 's'}. This cannot be
- undone.
+ This permanently deletes {project?.name} and its {stats?.segments ?? 0}{' '}
+ segment{stats?.segments === 1 ? '' : 's'}. This cannot be undone.
diff --git a/src/features/projects/DuplicateProjectDialog.tsx b/src/features/projects/DuplicateProjectDialog.tsx
index deffafb..a704d99 100644
--- a/src/features/projects/DuplicateProjectDialog.tsx
+++ b/src/features/projects/DuplicateProjectDialog.tsx
@@ -82,7 +82,11 @@ export function DuplicateProjectDialog() {
Cancel
-
+
{working ? 'Duplicating…' : 'Duplicate'}
diff --git a/src/features/projects/EditProjectDialog.tsx b/src/features/projects/EditProjectDialog.tsx
index f6dc887..6c552aa 100644
--- a/src/features/projects/EditProjectDialog.tsx
+++ b/src/features/projects/EditProjectDialog.tsx
@@ -42,8 +42,7 @@ export function EditProjectDialog() {
const langChanged =
project != null && (sourceLang !== project.sourceLang || targetLang !== project.targetLang)
- const canSave =
- name.trim().length > 0 && sourceLang !== targetLang && !saving && project != null
+ const canSave = name.trim().length > 0 && sourceLang !== targetLang && !saving && project != null
const save = async () => {
if (!projectId || !canSave) return
@@ -130,7 +129,11 @@ export function EditProjectDialog() {
)}
{langChanged && sourceLang !== targetLang && (
-
+
Changing the language pair affects which translation-memory entries match this
project. Existing translations are kept.
diff --git a/src/features/projects/ProjectCard.tsx b/src/features/projects/ProjectCard.tsx
index 94e6768..f601eea 100644
--- a/src/features/projects/ProjectCard.tsx
+++ b/src/features/projects/ProjectCard.tsx
@@ -16,9 +16,11 @@ export function formatRelative(iso: string): string {
return new Date(iso).toLocaleDateString()
}
-export function projectProgress(
- counts: StatusCounts | undefined,
-): { total: number; done: number; pct: number } {
+export function projectProgress(counts: StatusCounts | undefined): {
+ total: number
+ done: number
+ pct: number
+} {
if (!counts) return { total: 0, done: 0, pct: 0 }
const total =
counts.untranslated + counts.draft + counts.translated + counts.reviewed + counts.locked
@@ -72,7 +74,10 @@ export function ProjectCard({ project, counts }: { project: Project; counts?: St
{project.name}
-
+
{project.sourceLang} → {project.targetLang}
diff --git a/src/features/projects/cloud/CloudControls.tsx b/src/features/projects/cloud/CloudControls.tsx
index 643ecd0..9fa105e 100644
--- a/src/features/projects/cloud/CloudControls.tsx
+++ b/src/features/projects/cloud/CloudControls.tsx
@@ -22,7 +22,12 @@ const ManageMembersDialog = lazy(() =>
export function CloudBadge({ project }: { project: Project }) {
if (!isCloudConfigured() || !project.cloud) return null
return (
-
+
Cloud
)
diff --git a/src/features/projects/cloud/ManageMembersDialog.tsx b/src/features/projects/cloud/ManageMembersDialog.tsx
index 41651c6..1e49416 100644
--- a/src/features/projects/cloud/ManageMembersDialog.tsx
+++ b/src/features/projects/cloud/ManageMembersDialog.tsx
@@ -114,7 +114,9 @@ export function ManageMembersDialog({
const email = inviteEmail.trim()
const res = await m.inviteMember(client, cloudId!, email, inviteRole)
if (res.status === 'not_found') {
- setNotice(`No Verbalis account uses ${email} yet. Ask them to sign in once, then invite again.`)
+ setNotice(
+ `No Verbalis account uses ${email} yet. Ask them to sign in once, then invite again.`,
+ )
return 'skip'
}
setInviteEmail('')
@@ -194,7 +196,11 @@ export function ManageMembersDialog({
))}
-
+
Invite
@@ -222,14 +228,21 @@ export function ManageMembersDialog({
))}
) : (
-
+
{STAGE_LABEL[stage]}
)}
{members === null ? (
-
+
Loading…
) : (
@@ -242,7 +255,10 @@ export function ManageMembersDialog({
data-testid={`member-${m.userId}`}
>
-
+
{m.displayName || m.email || 'Member'}
{m.isSelf && (you) }
@@ -290,12 +306,20 @@ export function ManageMembersDialog({
)}
{notice ? (
-
+
{notice}
) : null}
{error ? (
-
+
{error}
) : null}
diff --git a/src/features/projects/cloud/OpenCloudProjectDialog.tsx b/src/features/projects/cloud/OpenCloudProjectDialog.tsx
index d867a3f..1a85d2e 100644
--- a/src/features/projects/cloud/OpenCloudProjectDialog.tsx
+++ b/src/features/projects/cloud/OpenCloudProjectDialog.tsx
@@ -88,11 +88,18 @@ export function OpenCloudProjectDialog({
{items === null ? (
-
+
Loading…
) : items.length === 0 ? (
-
+
No cloud projects yet. Publish one from the projects list to get started.
) : (
@@ -104,7 +111,10 @@ export function OpenCloudProjectDialog({
style={{ borderColor: 'var(--color-border)' }}
>
-
+
{p.name}
@@ -128,7 +138,11 @@ export function OpenCloudProjectDialog({
)}
{error ? (
-
+
{error}
) : null}
diff --git a/src/features/projects/index.tsx b/src/features/projects/index.tsx
index 849b33d..b412775 100644
--- a/src/features/projects/index.tsx
+++ b/src/features/projects/index.tsx
@@ -97,7 +97,11 @@ export default function ProjectsPage() {
{visible.length === 0 ? (
-
+
No projects match “{query}”.
) : (
@@ -183,8 +187,8 @@ function FirstRunOnboarding({ onImport, onShortcuts }: FirstRunOnboardingProps)
Verbalis is a local-first {' '}
- computer-assisted translation workspace. Import your files, translate with memory
- and glossary support, and export clean — all without a single sentence leaving your
+ computer-assisted translation workspace. Import your files, translate with memory and
+ glossary support, and export clean — all without a single sentence leaving your
machine.
@@ -282,10 +286,7 @@ function Step({
-
+
{title}
diff --git a/src/features/settings/EditorSettingsSection.tsx b/src/features/settings/EditorSettingsSection.tsx
index aa2ac1c..3a28763 100644
--- a/src/features/settings/EditorSettingsSection.tsx
+++ b/src/features/settings/EditorSettingsSection.tsx
@@ -66,9 +66,9 @@ export function EditorSettingsSection() {
Rich text editing
- On by default: edit targets with bold/italic/underline,
- sub-/superscript, case transforms, tracked changes and comments.
- Turn off to use the plain text editor. Code segments stay plain.
+ On by default: edit targets with bold/italic/underline, sub-/superscript, case
+ transforms, tracked changes and comments. Turn off to use the plain text editor. Code
+ segments stay plain.
- Default target language for the global Quick Lookup dialog. Source
- language is auto-detected per lookup.
+ Default target language for the global Quick Lookup dialog. Source language is auto-detected
+ per lookup.
Default target language
diff --git a/src/features/settings/MTSettingsSection.tsx b/src/features/settings/MTSettingsSection.tsx
index 8667a98..4f28e25 100644
--- a/src/features/settings/MTSettingsSection.tsx
+++ b/src/features/settings/MTSettingsSection.tsx
@@ -45,11 +45,7 @@ function FieldRow({
}) {
return (
-
+
{label}
{children}
@@ -150,7 +146,10 @@ function ProviderCard({ id, settings, onPatch }: ProviderCardProps) {
data-testid={`mt-card-${id}`}
>
-
+
- Free, no signup. Anonymous quota is ~5,000 characters/day; adding an
- email raises it to ~50,000/day. Each request is limited to 500
- characters of source text.
+ Free, no signup. Anonymous quota is ~5,000 characters/day; adding an email raises it to
+ ~50,000/day. Each request is limited to 500 characters of source text.
>
)}
@@ -235,15 +233,13 @@ function ProviderCard({ id, settings, onPatch }: ProviderCardProps) {
- onPatch({ ollama: { ...settings.ollama, model: e.target.value } })
- }
+ onChange={(e) => onPatch({ ollama: { ...settings.ollama, model: e.target.value } })}
data-testid="mt-ollama-model"
/>
- Ollama must be running locally and configured to accept this origin
- (OLLAMA_ORIGINS=*).
+ Ollama must be running locally and configured to accept this origin (
+ OLLAMA_ORIGINS=*).
>
)}
@@ -263,9 +259,7 @@ function ProviderCard({ id, settings, onPatch }: ProviderCardProps) {
- onPatch({ claude: { ...settings.claude, model: e.target.value } })
- }
+ onChange={(e) => onPatch({ claude: { ...settings.claude, model: e.target.value } })}
data-testid="mt-claude-model"
/>
@@ -277,8 +271,8 @@ function ProviderCard({ id, settings, onPatch }: ProviderCardProps) {
background: 'color-mix(in srgb, var(--color-warning) 8%, transparent)',
}}
>
- Your API key is stored unencrypted in this browser's IndexedDB. Anyone with
- access to this browser profile can read it.
+ Your API key is stored unencrypted in this browser's IndexedDB. Anyone with access to
+ this browser profile can read it.
>
)}
@@ -308,10 +302,10 @@ function ProviderCard({ id, settings, onPatch }: ProviderCardProps) {
/>
- Open-source machine translation. Use a public instance (most require
- an API key — see libretranslate.com) or point this at
- your own self-hosted server. Base languages only — regional variants
- like pt-BR are folded to pt.
+ Open-source machine translation. Use a public instance (most require an API key — see{' '}
+ libretranslate.com) or point this at your own self-hosted server. Base
+ languages only — regional variants like pt-BR are folded to pt
+ .
>
)}
@@ -333,7 +327,11 @@ function ProviderCard({ id, settings, onPatch }: ProviderCardProps) {
)}
{test.status === 'error' && (
-
+
✗ {test.message.slice(0, 40)}
)}
@@ -343,10 +341,7 @@ function ProviderCard({ id, settings, onPatch }: ProviderCardProps) {
}
export function MTSettingsSection() {
- const stored = useLiveQuery(
- () => settingsRepo.get>(MT_SETTINGS_KEY),
- [],
- )
+ const stored = useLiveQuery(() => settingsRepo.get>(MT_SETTINGS_KEY), [])
const merged = useMemo(() => mergeMTSettings(stored), [stored])
const [draft, setDraft] = useState(merged)
const lastStoredRef = useRef(JSON.stringify(merged))
@@ -382,9 +377,8 @@ export function MTSettingsSection() {
- Verbalis ships with MyMemory enabled — a free
- translation service that needs no setup. Add another provider below
- for higher quality or quota.
+ Verbalis ships with MyMemory enabled — a free translation service that
+ needs no setup. Add another provider below for higher quality or quota.
diff --git a/src/features/settings/ProfileSettingsSection.tsx b/src/features/settings/ProfileSettingsSection.tsx
index f0129d6..af98f77 100644
--- a/src/features/settings/ProfileSettingsSection.tsx
+++ b/src/features/settings/ProfileSettingsSection.tsx
@@ -8,6 +8,7 @@ import {
PROFILE_SETTINGS_KEY,
type ProfileSettings,
} from '@/storage/repositories/settingsRepo'
+import { useAuthStore } from '@/features/account/useAuthStore'
export function ProfileSettingsSection() {
const stored = useLiveQuery(
@@ -15,6 +16,8 @@ export function ProfileSettingsSection() {
[],
)
const [draft, setDraft] = useState
(DEFAULT_PROFILE_SETTINGS)
+ const signedIn = useAuthStore((s) => s.status === 'authenticated')
+ const accountName = useAuthStore((s) => s.profileDisplayName ?? s.user?.displayName ?? null)
useEffect(() => {
if (stored !== undefined) setDraft(mergeProfileSettings(stored ?? undefined))
@@ -26,11 +29,26 @@ export function ProfileSettingsSection() {
await settingsRepo.set(PROFILE_SETTINGS_KEY, next)
}
+ // Signed in: attribution uses your account name automatically, so there's no
+ // separate local name to enter — point at Account settings instead of asking.
+ if (signedIn) {
+ return (
+
+ Your name for comments, tracked changes and collaboration comes from your account
+ {accountName ? ` — ${accountName}` : ''}. Change it in Account settings.
+
+ )
+ }
+
return (
- The name attached to comments you write. It will also identify you to
- peers once local-network collaboration lands. Stored on this device only.
+ The name attached to your comments, tracked changes and collaboration presence. Stored on
+ this device only.
Display name
diff --git a/src/features/settings/SemanticTMSection.tsx b/src/features/settings/SemanticTMSection.tsx
index 50c1e02..293969a 100644
--- a/src/features/settings/SemanticTMSection.tsx
+++ b/src/features/settings/SemanticTMSection.tsx
@@ -61,8 +61,8 @@ export function SemanticTMSection() {
Enable semantic matches in TM panel
- The first time you enable this and run a search, a ~50 MB model is
- downloaded and cached in your browser. Everything runs locally after that.
+ The first time you enable this and run a search, a ~50 MB model is downloaded and
+ cached in your browser. Everything runs locally after that.
@@ -127,7 +127,11 @@ export function SemanticTMSection() {
{state.error && (
-
+
{state.error}
)}
diff --git a/src/features/settings/SpellSettingsSection.tsx b/src/features/settings/SpellSettingsSection.tsx
index 9613621..db8767e 100644
--- a/src/features/settings/SpellSettingsSection.tsx
+++ b/src/features/settings/SpellSettingsSection.tsx
@@ -26,8 +26,8 @@ export function SpellSettingsSection() {
On-device spell-check
- Hunspell dictionaries run locally; the active one follows each project's
- target language (English and Portuguese ship today). Nothing is uploaded.
+ Hunspell dictionaries run locally; the active one follows each project's target language
+ (English and Portuguese ship today). Nothing is uploaded.
- Enabled providers appear in the command palette (⌘/Ctrl+K) and open the
- focused segment's source term in your browser. Verbalis never proxies the
- query — the request goes straight to the provider. Use {'{q}'},{' '}
- {'{src}'} and {'{tgt}'} in the URL template.
+ Enabled providers appear in the command palette (⌘/Ctrl+K) and open the focused segment's
+ source term in your browser. Verbalis never proxies the query — the request goes straight to
+ the provider. Use {'{q}'}, {'{src}'} and {'{tgt}'} in
+ the URL template.
@@ -102,10 +102,7 @@ export function WebSearchSettingsSection() {
variant="bordered"
size="sm"
onClick={() =>
- void save([
- ...providers,
- { id: uid(), name: '', urlTemplate: '', enabled: true },
- ])
+ void save([...providers, { id: uid(), name: '', urlTemplate: '', enabled: true }])
}
data-testid="settings-websearch-add"
>
diff --git a/src/features/settings/index.tsx b/src/features/settings/index.tsx
index 152c877..33df905 100644
--- a/src/features/settings/index.tsx
+++ b/src/features/settings/index.tsx
@@ -55,14 +55,7 @@ function Section({ title, children }: { title: string; children: React.ReactNode
}
type SectionId =
- | 'identity'
- | 'account'
- | 'mt'
- | 'editor'
- | 'spell'
- | 'search'
- | 'semantic'
- | 'about'
+ 'identity' | 'account' | 'mt' | 'editor' | 'spell' | 'search' | 'semantic' | 'about'
const NAV: Array<{ id: SectionId; label: string; icon: React.ReactNode }> = [
{ id: 'identity', label: 'Identity', icon:
},
@@ -131,9 +124,9 @@ function SectionContent({ id }: { id: SectionId }) {
- Translation Memory and Glossary work fully offline. Wiktionary lookups are
- cached after first fetch. Ollama runs locally on your machine; Claude and
- LibreTranslate require network access.
+ Translation Memory and Glossary work fully offline. Wiktionary lookups are cached
+ after first fetch. Ollama runs locally on your machine; Claude and LibreTranslate
+ require network access.
diff --git a/src/features/shortcuts/ShortcutsDialog.tsx b/src/features/shortcuts/ShortcutsDialog.tsx
index 2e8bf0e..d3b55c4 100644
--- a/src/features/shortcuts/ShortcutsDialog.tsx
+++ b/src/features/shortcuts/ShortcutsDialog.tsx
@@ -85,9 +85,7 @@ export function ShortcutsDialog() {
>
Keyboard shortcuts
-
- On macOS, Ctrl maps to ⌘ (Command).
-
+ On macOS, Ctrl maps to ⌘ (Command).
{GROUPS.map((g) => (
@@ -100,10 +98,7 @@ export function ShortcutsDialog() {
{g.items.map((s) => (
-
+
{s.description}
diff --git a/src/features/terminology/GlossaryEditDialog.tsx b/src/features/terminology/GlossaryEditDialog.tsx
index ee6213f..8ae761f 100644
--- a/src/features/terminology/GlossaryEditDialog.tsx
+++ b/src/features/terminology/GlossaryEditDialog.tsx
@@ -44,9 +44,7 @@ export function GlossaryEditDialog({
}: GlossaryEditDialogProps) {
const [term, setTerm] = useState('')
const [definition, setDefinition] = useState('')
- const [translations, setTranslations] = useState>(
- [],
- )
+ const [translations, setTranslations] = useState>([])
const [notes, setNotes] = useState('')
const [projectId, setProjectId] = useState(UNASSIGNED)
const [error, setError] = useState(null)
@@ -55,9 +53,7 @@ export function GlossaryEditDialog({
if (!draft) return
setTerm(draft.term)
setDefinition(draft.definition)
- setTranslations(
- draft.translations.length > 0 ? draft.translations : [{ lang: '', value: '' }],
- )
+ setTranslations(draft.translations.length > 0 ? draft.translations : [{ lang: '', value: '' }])
setNotes(draft.notes)
setProjectId(draft.projectId ?? UNASSIGNED)
setError(null)
@@ -87,14 +83,12 @@ export function GlossaryEditDialog({
onOpenChange(false)
}
- const addTranslationRow = () =>
- setTranslations((rows) => [...rows, { lang: '', value: '' }])
+ const addTranslationRow = () => setTranslations((rows) => [...rows, { lang: '', value: '' }])
const updateRow = (i: number, key: 'lang' | 'value', v: string) =>
setTranslations((rows) => rows.map((r, idx) => (idx === i ? { ...r, [key]: v } : r)))
- const removeRow = (i: number) =>
- setTranslations((rows) => rows.filter((_, idx) => idx !== i))
+ const removeRow = (i: number) => setTranslations((rows) => rows.filter((_, idx) => idx !== i))
return (
diff --git a/src/features/terminology/GlossaryTable.tsx b/src/features/terminology/GlossaryTable.tsx
index 7077b6a..71ee4e7 100644
--- a/src/features/terminology/GlossaryTable.tsx
+++ b/src/features/terminology/GlossaryTable.tsx
@@ -76,13 +76,8 @@ export function GlossaryTable({
{e.term}
-
-
- {e.definition}
-
+
+ {e.definition}
@@ -102,12 +97,10 @@ export function GlossaryTable({
- {e.projectId ? projectsById.get(e.projectId)?.name ?? '—' : '—'}
+ {e.projectId ? (projectsById.get(e.projectId)?.name ?? '—') : '—'}
-
- {e.notes ?? ''}
-
+ {e.notes ?? ''}
diff --git a/src/features/terminology/WiktionaryLookup.tsx b/src/features/terminology/WiktionaryLookup.tsx
index 39e7654..3f65c5b 100644
--- a/src/features/terminology/WiktionaryLookup.tsx
+++ b/src/features/terminology/WiktionaryLookup.tsx
@@ -12,8 +12,7 @@ import { useNetworkStatus } from '@/hooks/useNetworkStatus'
import { LANG_OPTIONS } from '@/core/lang/options'
import type { GlossaryEditDraft } from './GlossaryEditDialog'
-const OFFLINE_MESSAGE =
- "You're offline — only previously looked-up terms are available."
+const OFFLINE_MESSAGE = "You're offline — only previously looked-up terms are available."
interface WiktionaryLookupProps {
onAddToGlossary: (draft: GlossaryEditDraft) => void
@@ -136,7 +135,11 @@ export function WiktionaryLookup({ onAddToGlossary }: WiktionaryLookupProps) {
{error && (
-
+
{error}
)}
@@ -146,10 +149,7 @@ export function WiktionaryLookup({ onAddToGlossary }: WiktionaryLookupProps) {
{result.term}
-
+
({result.sourceLang})
@@ -171,10 +171,7 @@ export function WiktionaryLookup({ onAddToGlossary }: WiktionaryLookupProps) {
{result.definitions.slice(0, 6).map((d, i) => (
-
+
{d.partOfSpeech}
{d.definition}
diff --git a/src/features/terminology/index.tsx b/src/features/terminology/index.tsx
index 065f1aa..7840a01 100644
--- a/src/features/terminology/index.tsx
+++ b/src/features/terminology/index.tsx
@@ -43,11 +43,7 @@ const EMPTY_DRAFT: GlossaryEditDraft = {
notes: '',
}
-function CorporaBanner({
- packs,
-}: {
- packs: Array<{ id: string; count: number }> | undefined
-}) {
+function CorporaBanner({ packs }: { packs: Array<{ id: string; count: number }> | undefined }) {
const installedCount = packs?.length ?? 0
const termTotal = (packs ?? []).reduce((sum, p) => sum + p.count, 0)
return (
@@ -136,9 +132,7 @@ export default function TerminologyPage() {
const handleBulkRelang = async (lang: string) => {
if (!lang || !entries) return
- const updated = entries
- .filter((e) => selected.has(e.id))
- .map((e) => relangEntry(e, lang))
+ const updated = entries.filter((e) => selected.has(e.id)).map((e) => relangEntry(e, lang))
if (updated.length === 0) return
await glossaryRepo.bulkPut(updated)
setSelected(new Set())
diff --git a/src/features/tm/TMEditDialog.tsx b/src/features/tm/TMEditDialog.tsx
index 625ab7c..123764c 100644
--- a/src/features/tm/TMEditDialog.tsx
+++ b/src/features/tm/TMEditDialog.tsx
@@ -19,7 +19,10 @@ interface TMEditDialogProps {
entry: TMEntry | null
projects: Project[]
onOpenChange: (open: boolean) => void
- onSave: (id: string, changes: Pick) => Promise | void
+ onSave: (
+ id: string,
+ changes: Pick,
+ ) => Promise | void
}
export function TMEditDialog({ open, entry, projects, onOpenChange, onSave }: TMEditDialogProps) {
diff --git a/src/features/tm/TMImportButton.tsx b/src/features/tm/TMImportButton.tsx
index 2215185..a9303f7 100644
--- a/src/features/tm/TMImportButton.tsx
+++ b/src/features/tm/TMImportButton.tsx
@@ -59,12 +59,20 @@ export function TMImportButton() {
}}
/>
{status && (
-
+
{status}
)}
{error && (
-
+
{error}
)}
diff --git a/src/features/tm/TMTable.tsx b/src/features/tm/TMTable.tsx
index 2f33fb1..3ea2cd7 100644
--- a/src/features/tm/TMTable.tsx
+++ b/src/features/tm/TMTable.tsx
@@ -89,7 +89,7 @@ export function TMTable({
{e.sourceLang} → {e.targetLang}
- {e.projectId ? projectsById.get(e.projectId)?.name ?? '—' : '—'}
+ {e.projectId ? (projectsById.get(e.projectId)?.name ?? '—') : '—'}
{formatDate(e.date)}
diff --git a/src/features/translate/TranslateWorkspace.tsx b/src/features/translate/TranslateWorkspace.tsx
index 22d4989..496cfd6 100644
--- a/src/features/translate/TranslateWorkspace.tsx
+++ b/src/features/translate/TranslateWorkspace.tsx
@@ -34,6 +34,10 @@ interface TranslateWorkspaceProps {
prefill?: string
autoFocus?: boolean
testIdPrefix?: string
+ /** Seed languages (e.g. the open project's), taking precedence over the global
+ * lookup defaults. Auto-detection and manual selection still apply after. */
+ initialSourceLang?: string
+ initialTargetLang?: string
}
export function TranslateWorkspace({
@@ -41,6 +45,8 @@ export function TranslateWorkspace({
prefill = '',
autoFocus = true,
testIdPrefix = 'quick-lookup',
+ initialSourceLang,
+ initialTargetLang,
}: TranslateWorkspaceProps) {
const online = useNetworkStatus()
@@ -60,10 +66,7 @@ export function TranslateWorkspace({
const [saveGlossaryState, setSaveGlossaryState] = useState('idle')
const textareaRef = useRef(null)
- const wordCount = useMemo(
- () => input.trim().split(/\s+/).filter(Boolean).length,
- [input],
- )
+ const wordCount = useMemo(() => input.trim().split(/\s+/).filter(Boolean).length, [input])
const isShortInput = wordCount > 0 && wordCount <= SHORT_INPUT_MAX_WORDS
const provider = mtSettings ? resolveDefaultProvider(mtSettings) : undefined
@@ -74,13 +77,15 @@ export function TranslateWorkspace({
if (cancelled) return
setMtSettings(mt)
setLookupSettings(lookup)
- setTargetLang(lookup.defaultTargetLang)
- if (lookup.lastSourceLang) setSourceLang(lookup.lastSourceLang)
+ // Injected (project) languages win over the global lookup defaults.
+ setTargetLang(initialTargetLang ?? lookup.defaultTargetLang)
+ const seedSource = initialSourceLang ?? lookup.lastSourceLang
+ if (seedSource) setSourceLang(seedSource)
})
return () => {
cancelled = true
}
- }, [active])
+ }, [active, initialSourceLang, initialTargetLang])
useEffect(() => {
if (!active) return
@@ -121,14 +126,11 @@ export function TranslateWorkspace({
setSaveTmState('idle')
setSaveGlossaryState('idle')
- const persistLastLang = settingsRepo.set>(
- LOOKUP_SETTINGS_KEY,
- {
- ...(lookupSettings ?? {}),
- defaultTargetLang: lookupSettings?.defaultTargetLang ?? targetLang,
- lastSourceLang: sourceLang,
- },
- )
+ const persistLastLang = settingsRepo.set>(LOOKUP_SETTINGS_KEY, {
+ ...(lookupSettings ?? {}),
+ defaultTargetLang: lookupSettings?.defaultTargetLang ?? targetLang,
+ lastSourceLang: sourceLang,
+ })
void persistLastLang
const mtPromise = (async () => {
@@ -140,11 +142,7 @@ export function TranslateWorkspace({
}
setMtLoading(true)
try {
- const res = await translateWith(
- provider,
- { text, sourceLang, targetLang },
- mtSettings,
- )
+ const res = await translateWith(provider, { text, sourceLang, targetLang }, mtSettings)
setMtResult(res.text)
} catch (err) {
if (err instanceof MTError) setMtError(`${err.code}: ${err.message}`)
@@ -162,17 +160,12 @@ export function TranslateWorkspace({
}
setWikLoading(true)
try {
- const res = await fetchWiktionaryEntry(
- text,
- sourceLang === 'und' ? 'en' : sourceLang,
- )
+ const res = await fetchWiktionaryEntry(text, sourceLang === 'und' ? 'en' : sourceLang)
setWikResult(res)
} catch (err) {
if (err instanceof WiktionaryError) {
setWikError(
- err.code === 'not_found'
- ? `Not in ${sourceLang}.wiktionary.org`
- : err.message,
+ err.code === 'not_found' ? `Not in ${sourceLang}.wiktionary.org` : err.message,
)
} else {
setWikError(err instanceof Error ? err.message : 'Wiktionary failed')
@@ -247,10 +240,7 @@ export function TranslateWorkspace({
-
+
Term or sentence
-
+
{wordCount === 0
? 'Tip: Cmd/Ctrl+Enter to translate'
: isShortInput
- ? `Short input · ${wordCount} word${wordCount === 1 ? '' : 's'} · Wiktionary + MT`
- : `Sentence · ${wordCount} words · MT only`}
+ ? `Short input · ${wordCount} word${wordCount === 1 ? '' : 's'} · Wiktionary + MT`
+ : `Sentence · ${wordCount} words · MT only`}
@@ -311,10 +298,7 @@ export function TranslateWorkspace({
))}
{detected !== 'und' && detected !== sourceLang && (
-
+
Detected: {getLanguageLabel(detected)}
)}
@@ -344,11 +328,7 @@ export function TranslateWorkspace({
size="lg"
data-testid={`${testIdPrefix}-go`}
>
- {mtLoading || wikLoading ? (
-
- ) : (
-
- )}
+ {mtLoading || wikLoading ? : }
Translate
@@ -361,10 +341,7 @@ export function TranslateWorkspace({
}}
data-testid={`${testIdPrefix}-wiktionary`}
>
-
+
Wiktionary · {wikResult.term}
{wikResult.definitions.length > 0 && (
@@ -420,10 +397,7 @@ export function TranslateWorkspace({
data-testid={`${testIdPrefix}-mt`}
>
-
+
MT · {getLanguageLabel(sourceLang)} → {getLanguageLabel(targetLang)}
-
+
{mtResult}
@@ -451,11 +422,7 @@ export function TranslateWorkspace({
{!provider && (
<>
{' '}
-
+
Open Settings
>
diff --git a/src/features/translate/index.tsx b/src/features/translate/index.tsx
index c2cbfe5..409133d 100644
--- a/src/features/translate/index.tsx
+++ b/src/features/translate/index.tsx
@@ -8,11 +8,7 @@ export default function TranslatePage() {
title="Translate"
subtitle="Paste text, pick languages, translate — no project required."
/>
-
+
)
}
diff --git a/src/storage/cloud/members.ts b/src/storage/cloud/members.ts
index 9787f0a..4d58bb1 100644
--- a/src/storage/cloud/members.ts
+++ b/src/storage/cloud/members.ts
@@ -48,9 +48,10 @@ export async function listMembers(
const profRes = await client.from('profiles').select('id, display_name, email').in('id', ids)
if (profRes.error) throw new Error(profRes.error.message)
const profiles = new Map(
- ((profRes.data as { id: string; display_name: string | null; email: string | null }[] | null) ?? []).map(
- (p) => [p.id, p] as const,
- ),
+ (
+ (profRes.data as
+ { id: string; display_name: string | null; email: string | null }[] | null) ?? []
+ ).map((p) => [p.id, p] as const),
)
return rows
diff --git a/src/storage/cloud/projectCloud.ts b/src/storage/cloud/projectCloud.ts
index 3d08ef0..fe75729 100644
--- a/src/storage/cloud/projectCloud.ts
+++ b/src/storage/cloud/projectCloud.ts
@@ -133,7 +133,12 @@ export async function fetchCloudProject(
const row = projectRes.data as ProjectRowSelect | null
if (!row) return null
const role = (memberRes.data as { role: ProjectRole } | null)?.role ?? 'translator'
- return { summary: toSummary(row), role, stage: row.stage ?? 'translation', deadline: row.deadline ?? null }
+ return {
+ summary: toSummary(row),
+ role,
+ stage: row.stage ?? 'translation',
+ deadline: row.deadline ?? null,
+ }
}
/** Set a cloud project's workflow stage (project_manager-only, enforced by RLS). */
diff --git a/src/storage/cloud/rowSync.ts b/src/storage/cloud/rowSync.ts
index d64d1c4..8c4c8cc 100644
--- a/src/storage/cloud/rowSync.ts
+++ b/src/storage/cloud/rowSync.ts
@@ -3,10 +3,7 @@ import { db, type SyncResource } from '@/storage/db'
import type { GlossaryEntry, TMEntry } from '@/core/types'
import { settingsRepo } from '@/storage/repositories/settingsRepo'
import { isCloudConfigured, getSupabase } from './supabaseClient'
-import {
- setRowSyncEnabled,
- subscribeResourceChange,
-} from './rowSyncState'
+import { setRowSyncEnabled, subscribeResourceChange } from './rowSyncState'
import { useAuthStore } from '@/features/account/useAuthStore'
/**
diff --git a/src/storage/cloud/settingsSync.ts b/src/storage/cloud/settingsSync.ts
index 0d2d2ef..29a0f6e 100644
--- a/src/storage/cloud/settingsSync.ts
+++ b/src/storage/cloud/settingsSync.ts
@@ -122,7 +122,7 @@ async function fetchRemoteEntries(
.eq('user_id', userId)
.in('key', SYNCED_SETTINGS_KEYS as unknown as string[])
if (error) throw new Error(error.message)
- return (data as RemoteRow[] | null ?? []).map((r) => ({
+ return ((data as RemoteRow[] | null) ?? []).map((r) => ({
key: r.key,
value: r.value,
updatedAt: toEpoch(r.updated_at),
@@ -165,11 +165,7 @@ export async function pullAndReconcile(client: SupabaseClient, userId: string):
}
/** Push a single allowlisted key's current local value to the cloud. */
-export async function pushKey(
- client: SupabaseClient,
- userId: string,
- key: string,
-): Promise
{
+export async function pushKey(client: SupabaseClient, userId: string, key: string): Promise {
if (!SYNCED_KEY_SET.has(key)) return
const row = await settingsRepo.getRow(key)
if (row === undefined) return
diff --git a/src/storage/cloud/ydocPersistence.ts b/src/storage/cloud/ydocPersistence.ts
index 9245c9d..58de2d5 100644
--- a/src/storage/cloud/ydocPersistence.ts
+++ b/src/storage/cloud/ydocPersistence.ts
@@ -44,10 +44,7 @@ export interface SnapshotRow {
}
/** Fetch the current snapshot + seq for a project (nulls when unseeded). */
-export async function fetchSnapshot(
- client: SupabaseClient,
- cloudId: string,
-): Promise {
+export async function fetchSnapshot(client: SupabaseClient, cloudId: string): Promise {
const { data, error } = await client
.from('ydoc_state')
.select('state,seq')
@@ -66,10 +63,7 @@ export interface UpdateRow {
}
/** Fetch the whole append log for a project, ordered oldest-first. */
-export async function fetchUpdates(
- client: SupabaseClient,
- cloudId: string,
-): Promise {
+export async function fetchUpdates(client: SupabaseClient, cloudId: string): Promise {
const { data, error } = await client
.from('ydoc_updates')
.select('id,update')
diff --git a/src/storage/repositories/corpusRepo.ts b/src/storage/repositories/corpusRepo.ts
index 28aa7d7..9899c99 100644
--- a/src/storage/repositories/corpusRepo.ts
+++ b/src/storage/repositories/corpusRepo.ts
@@ -45,8 +45,7 @@ export const corpusRepo = {
termsByCorpus: (corpusId: string) => db.corpusTerms.where({ corpusId }).toArray(),
totalTermCount: (): Promise => db.corpusTerms.count(),
- isInstalled: async (id: string): Promise =>
- (await db.corpusPacks.get(id)) !== undefined,
+ isInstalled: async (id: string): Promise => (await db.corpusPacks.get(id)) !== undefined,
// Install a downloaded pack: persist its terms, record install state, and
// optionally seed the TM. Idempotent — re-installing replaces the pack.
diff --git a/src/storage/repositories/documentRepo.ts b/src/storage/repositories/documentRepo.ts
index 6a80ef6..86e9f87 100644
--- a/src/storage/repositories/documentRepo.ts
+++ b/src/storage/repositories/documentRepo.ts
@@ -3,8 +3,7 @@ import type { DocumentEntity, Block, Asset } from '@/core/documents/model'
/** The document belonging to a project (monolingual projects have exactly one). */
export const documentRepo = {
- getByProject: (projectId: string) =>
- db.documents.where('projectId').equals(projectId).first(),
+ getByProject: (projectId: string) => db.documents.where('projectId').equals(projectId).first(),
getById: (id: string) => db.documents.get(id),
@@ -26,8 +25,7 @@ export const blockRepo = {
bulkCreate: (blocks: Block[]) => db.blocks.bulkAdd(blocks),
- removeByProject: (projectId: string) =>
- db.blocks.where('projectId').equals(projectId).delete(),
+ removeByProject: (projectId: string) => db.blocks.where('projectId').equals(projectId).delete(),
}
/** Binary assets (embedded images, original file) referenced by a document. */
@@ -40,6 +38,5 @@ export const assetRepo = {
bulkCreate: (assets: Asset[]) => db.assets.bulkAdd(assets),
- removeByProject: (projectId: string) =>
- db.assets.where('projectId').equals(projectId).delete(),
+ removeByProject: (projectId: string) => db.assets.where('projectId').equals(projectId).delete(),
}
diff --git a/src/storage/repositories/glossaryRepo.ts b/src/storage/repositories/glossaryRepo.ts
index fd3de55..85844c1 100644
--- a/src/storage/repositories/glossaryRepo.ts
+++ b/src/storage/repositories/glossaryRepo.ts
@@ -63,10 +63,7 @@ export const glossaryRepo = {
const all = await db.glossary.toArray()
return all.filter((e) => !e.projectId || e.projectId === projectId)
},
- findByTerm: async (
- term: string,
- projectId?: string,
- ): Promise => {
+ findByTerm: async (term: string, projectId?: string): Promise => {
const needle = normalizeTerm(term)
if (!needle) return undefined
const rows = projectId
diff --git a/src/storage/repositories/projectRepo.ts b/src/storage/repositories/projectRepo.ts
index dadb66e..56503dc 100644
--- a/src/storage/repositories/projectRepo.ts
+++ b/src/storage/repositories/projectRepo.ts
@@ -96,7 +96,8 @@ export const projectRepo = {
await db.transaction('rw', [db.projects, db.projectTemplates, db.segments], async () => {
await db.projects.add(clone)
if (clonedSegments.length > 0) await db.segments.bulkAdd(clonedSegments)
- if (template) await db.projectTemplates.put({ projectId: newId, templateXml: template.templateXml })
+ if (template)
+ await db.projectTemplates.put({ projectId: newId, templateXml: template.templateXml })
})
return newId
},
diff --git a/src/storage/repositories/segmentRepo.ts b/src/storage/repositories/segmentRepo.ts
index 514ea40..e40885b 100644
--- a/src/storage/repositories/segmentRepo.ts
+++ b/src/storage/repositories/segmentRepo.ts
@@ -37,8 +37,7 @@ export const segmentRepo = {
update: (id: string, changes: Partial) =>
db.segments.update(id, { ...changes, updatedAt: new Date().toISOString() }),
- removeByProject: (projectId: string) =>
- db.segments.where('projectId').equals(projectId).delete(),
+ removeByProject: (projectId: string) => db.segments.where('projectId').equals(projectId).delete(),
// Counts come from the [projectId+status] index — IndexedDB tallies them
// without ever loading a segment row (source/target/rich text/comments), so a
@@ -92,9 +91,7 @@ export const segmentRepo = {
db.transaction('rw', db.segments, async () => {
const seg = await db.segments.get(id)
if (!seg?.comments) return
- const comments = seg.comments.map((c) =>
- c.id === commentId ? { ...c, ...changes } : c,
- )
+ const comments = seg.comments.map((c) => (c.id === commentId ? { ...c, ...changes } : c))
await db.segments.update(id, { comments, updatedAt: new Date().toISOString() })
}),
diff --git a/src/storage/repositories/settingsRepo.ts b/src/storage/repositories/settingsRepo.ts
index e71ceda..443dc29 100644
--- a/src/storage/repositories/settingsRepo.ts
+++ b/src/storage/repositories/settingsRepo.ts
@@ -1,14 +1,8 @@
import { db, type SettingsRow } from '@/storage/db'
import type { LookupSettings, MTSettings, SemanticTMSettings } from '@/core/types'
import { DEFAULT_QA_RULES, type QARuleToggles } from '@/core/qa/types'
-import {
- DEFAULT_AUTOCORRECT_SETTINGS,
- type AutocorrectSettings,
-} from '@/core/text/autocorrect'
-import {
- DEFAULT_WEB_SEARCH_SETTINGS,
- type WebSearchSettings,
-} from '@/core/websearch/providers'
+import { DEFAULT_AUTOCORRECT_SETTINGS, type AutocorrectSettings } from '@/core/text/autocorrect'
+import { DEFAULT_WEB_SEARCH_SETTINGS, type WebSearchSettings } from '@/core/websearch/providers'
export const MT_SETTINGS_KEY = 'mt.providers'
export const SEMANTIC_TM_KEY = 'tm.semantic'
@@ -185,9 +179,7 @@ export async function getSemanticTMSettings(): Promise {
return mergeSemanticTMSettings(stored)
}
-export function mergeLookupSettings(
- stored: Partial | undefined,
-): LookupSettings {
+export function mergeLookupSettings(stored: Partial | undefined): LookupSettings {
if (!stored) return { ...DEFAULT_LOOKUP_SETTINGS }
return { ...DEFAULT_LOOKUP_SETTINGS, ...stored }
}
diff --git a/src/storage/repositories/versionRepo.ts b/src/storage/repositories/versionRepo.ts
index d3f8d2c..942ea09 100644
--- a/src/storage/repositories/versionRepo.ts
+++ b/src/storage/repositories/versionRepo.ts
@@ -67,6 +67,10 @@ async function capture(
label?: string,
liveDoc?: Y.Doc,
signOff = false,
+ // Caller-resolved, account-aware display name (see features/account/displayName).
+ // The storage layer stays account-agnostic; when omitted, the device-local
+ // name is used. `authorId` is always the local identity (D8).
+ resolvedName?: string,
): Promise {
const doc = liveDoc ?? peekProjectDoc(projectId)
if (!doc) return null
@@ -74,6 +78,7 @@ async function capture(
const profile = await getProfileSettings()
// The same stable identity that attributes marks, comments, and presence (§5.3).
const { authorId, authorName } = await ensureLocalAuthor()
+ const name = resolvedName?.trim() || authorName
const createdAt = new Date().toISOString()
const version: ProjectVersion = {
id: crypto.randomUUID(),
@@ -82,9 +87,9 @@ async function capture(
label: label?.trim() ? label.trim() : undefined,
snapshot: encodeDoc(doc),
segmentCount: segments.length,
- author: profile.displayName || undefined,
+ author: resolvedName?.trim() || profile.displayName || undefined,
authorId,
- approval: signOff ? { authorId, authorName, at: createdAt } : undefined,
+ approval: signOff ? { authorId, authorName: name, at: createdAt } : undefined,
createdAt,
}
await db.versions.add(version)
@@ -101,8 +106,8 @@ function listAsc(projectId: string): Promise {
export const versionRepo = {
/** Manual "Save version" with a label. */
- saveNamed: (projectId: string, label: string, liveDoc?: Y.Doc) =>
- capture(projectId, 'named', label, liveDoc),
+ saveNamed: (projectId: string, label: string, liveDoc?: Y.Doc, resolvedName?: string) =>
+ capture(projectId, 'named', label, liveDoc, false, resolvedName),
/**
* Revisor / PM **sign-off** (ROADMAP §5.3): a labeled `named` version stamped
@@ -110,9 +115,13 @@ export const versionRepo = {
* timeline. Gating (who may sign off) is enforced at the call site via the
* workflow rules; this only records it.
*/
- signOff: async (projectId: string, liveDoc?: Y.Doc): Promise => {
- const { authorName } = await ensureLocalAuthor()
- return capture(projectId, 'named', `Approved by ${authorName}`, liveDoc, true)
+ signOff: async (
+ projectId: string,
+ liveDoc?: Y.Doc,
+ resolvedName?: string,
+ ): Promise => {
+ const name = resolvedName?.trim() || (await ensureLocalAuthor()).authorName
+ return capture(projectId, 'named', `Approved by ${name}`, liveDoc, true, name)
},
/**
@@ -205,10 +214,7 @@ export const versionRepo = {
* The history of one segment, newest first, with consecutive identical
* (status + target) states collapsed so each entry marks an actual change.
*/
- segmentTimeline: async (
- projectId: string,
- segmentId: string,
- ): Promise => {
+ segmentTimeline: async (projectId: string, segmentId: string): Promise => {
const versions = await listAsc(projectId)
const out: SegmentHistoryEntry[] = []
let prevKey: string | null = null
diff --git a/src/storage/schemas.ts b/src/storage/schemas.ts
deleted file mode 100644
index b29432d..0000000
--- a/src/storage/schemas.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type { Project, Segment, TMEntry, GlossaryEntry } from '@/core/types'
diff --git a/src/storage/sync/crypto.ts b/src/storage/sync/crypto.ts
index cc5ca17..a3e3a6e 100644
--- a/src/storage/sync/crypto.ts
+++ b/src/storage/sync/crypto.ts
@@ -105,10 +105,7 @@ export async function deterministicSalt(seed: string): Promise {
* Build an AES-GCM codec from a shared passphrase, keyed by a seed (the project
* id) so all peers of one shared project converge on the same key.
*/
-export async function codecForPassphrase(
- passphrase: string,
- seed: string,
-): Promise {
+export async function codecForPassphrase(passphrase: string, seed: string): Promise {
const salt = await deterministicSalt(seed)
const key = await deriveKey(passphrase, salt)
return createAesCodec(key, salt)
diff --git a/src/storage/sync/syncManager.ts b/src/storage/sync/syncManager.ts
index f03dca9..b0eb8c4 100644
--- a/src/storage/sync/syncManager.ts
+++ b/src/storage/sync/syncManager.ts
@@ -8,6 +8,7 @@ import { projectRepo } from '@/storage/repositories/projectRepo'
import { isCloudConfigured } from '@/storage/cloud/supabaseClient'
import { startYdocPersistence, type YdocPersistenceHandle } from '@/storage/cloud/ydocPersistence'
import { useAuthStore } from '@/features/account/useAuthStore'
+import { resolveDisplayName, PEER_NAME_FALLBACK } from '@/features/account/displayName'
/**
* Owns the live {@link SyncSessionHandle} per shared project (Foundation F3).
@@ -55,8 +56,7 @@ export async function startProjectSync(projectId: string): Promise void>()
- let displayName = opts.displayName
+ const displayName = opts.displayName
let activeSegmentId: string | undefined
let caret: CaretRange | undefined
let heartbeat: ReturnType | null = null
diff --git a/src/storage/sync/transport/chunking.ts b/src/storage/sync/transport/chunking.ts
index e2ebb2d..dd8e594 100644
--- a/src/storage/sync/transport/chunking.ts
+++ b/src/storage/sync/transport/chunking.ts
@@ -82,7 +82,10 @@ function assemble(kind: SyncMessage['kind'], from: PeerId, b64: string): SyncMes
* pass straight through. Partial messages are held until complete.
*/
export class ChunkReassembler {
- private pending = new Map }>()
+ private pending = new Map<
+ string,
+ { n: number; kind: SyncMessage['kind']; from: PeerId; parts: Map }
+ >()
/** Feed one chunk; returns the assembled message when this completes it. */
push(chunk: WireChunk): SyncMessage | null {
diff --git a/src/storage/sync/transport/index.ts b/src/storage/sync/transport/index.ts
index 84d465e..c87ff8e 100644
--- a/src/storage/sync/transport/index.ts
+++ b/src/storage/sync/transport/index.ts
@@ -16,7 +16,5 @@ export type { SyncTransport, SyncMessage, SyncMessageHandler, PeerId } from './t
*/
export function createTransport(projectId: string, opts?: { cloudId?: string }): SyncTransport {
if (opts?.cloudId) return new SupabaseRealtimeTransport(opts.cloudId)
- return isTauri()
- ? new TauriLanTransport(projectId)
- : new BroadcastChannelTransport(projectId)
+ return isTauri() ? new TauriLanTransport(projectId) : new BroadcastChannelTransport(projectId)
}
diff --git a/src/storage/sync/transport/supabaseRealtime.ts b/src/storage/sync/transport/supabaseRealtime.ts
index 6c9f2da..3198b46 100644
--- a/src/storage/sync/transport/supabaseRealtime.ts
+++ b/src/storage/sync/transport/supabaseRealtime.ts
@@ -111,9 +111,7 @@ export class SupabaseRealtimeTransport implements SyncTransport {
channel.unsubscribe()
return
}
- channel.on('broadcast', { event: EVENT }, ({ payload }) =>
- this.onInbound(payload as WireChunk),
- )
+ channel.on('broadcast', { event: EVENT }, ({ payload }) => this.onInbound(payload as WireChunk))
// Assign before subscribing: the SUBSCRIBED callback may fire synchronously
// and flush the outbox, which sends through `this.channel`.
this.channel = channel
diff --git a/src/storage/sync/transport/tauri.ts b/src/storage/sync/transport/tauri.ts
index f517412..6657063 100644
--- a/src/storage/sync/transport/tauri.ts
+++ b/src/storage/sync/transport/tauri.ts
@@ -51,9 +51,8 @@ export class TauriLanTransport implements SyncTransport {
const { core, event } = await loadTauri()
this.core = core
// Rust emits `verbalis://sync/` for every inbound peer message.
- this.unlisten = await event.listen(
- `verbalis://sync/${this.projectId}`,
- (e) => this.handler?.(e.payload),
+ this.unlisten = await event.listen(`verbalis://sync/${this.projectId}`, (e) =>
+ this.handler?.(e.payload),
)
await core.invoke('start_sharing', { projectId: this.projectId })
}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 4e2625b..e7eb545 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -50,7 +50,8 @@
@font-face {
font-family: 'Geist Mono';
- src: url('../../node_modules/geist/dist/fonts/geist-mono/GeistMono-SemiBold.woff2') format('woff2');
+ src: url('../../node_modules/geist/dist/fonts/geist-mono/GeistMono-SemiBold.woff2')
+ format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
diff --git a/tailwind.config.ts b/tailwind.config.ts
index dbe5371..3ab49e9 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -64,10 +64,22 @@ const config: Config = {
xl: 'var(--radius-xl)',
},
fontSize: {
- 'large-title': ['var(--text-large-title)', { lineHeight: 'var(--leading-large-title)', letterSpacing: '-0.022em' }],
- 'title-1': ['var(--text-title-1)', { lineHeight: 'var(--leading-title-1)', letterSpacing: '-0.02em' }],
- 'title-2': ['var(--text-title-2)', { lineHeight: 'var(--leading-title-2)', letterSpacing: '-0.015em' }],
- 'title-3': ['var(--text-title-3)', { lineHeight: 'var(--leading-title-3)', letterSpacing: '-0.01em' }],
+ 'large-title': [
+ 'var(--text-large-title)',
+ { lineHeight: 'var(--leading-large-title)', letterSpacing: '-0.022em' },
+ ],
+ 'title-1': [
+ 'var(--text-title-1)',
+ { lineHeight: 'var(--leading-title-1)', letterSpacing: '-0.02em' },
+ ],
+ 'title-2': [
+ 'var(--text-title-2)',
+ { lineHeight: 'var(--leading-title-2)', letterSpacing: '-0.015em' },
+ ],
+ 'title-3': [
+ 'var(--text-title-3)',
+ { lineHeight: 'var(--leading-title-3)', letterSpacing: '-0.01em' },
+ ],
headline: ['var(--text-headline)', { lineHeight: 'var(--leading-headline)' }],
body: ['var(--text-body)', { lineHeight: 'var(--leading-body)' }],
callout: ['var(--text-callout)', { lineHeight: 'var(--leading-callout)' }],
diff --git a/tests/e2e/comments-anchored.spec.ts b/tests/e2e/comments-anchored.spec.ts
index 54d35e2..a554ed2 100644
--- a/tests/e2e/comments-anchored.spec.ts
+++ b/tests/e2e/comments-anchored.spec.ts
@@ -47,7 +47,9 @@ test('comment on a selection: highlight, thread, reply, resolve', async ({ page
const replyInput = thread.locator('[data-testid^="comment-reply-input-"]')
await replyInput.fill('Agreed')
await replyInput.press('ControlOrMeta+Enter')
- await expect(thread.locator('[data-testid^="comment-reply-"]').filter({ hasText: 'Agreed' })).toBeVisible()
+ await expect(
+ thread.locator('[data-testid^="comment-reply-"]').filter({ hasText: 'Agreed' }),
+ ).toBeVisible()
// Resolving the thread drops the highlight but keeps the comment.
await thread.locator('[data-testid^="comment-resolve-"]').click()
diff --git a/tests/e2e/fixtures/sample.md b/tests/e2e/fixtures/sample.md
index 05e08a6..de00abb 100644
--- a/tests/e2e/fixtures/sample.md
+++ b/tests/e2e/fixtures/sample.md
@@ -8,8 +8,8 @@ This is a second paragraph. It also has two sentences.
- Second item.
```js
-const x = 1;
-const y = 2;
+const x = 1
+const y = 2
```
> A quote sentence. Another one.
diff --git a/tests/e2e/import-and-edit.spec.ts b/tests/e2e/import-and-edit.spec.ts
index 0d72b0b..c2972b1 100644
--- a/tests/e2e/import-and-edit.spec.ts
+++ b/tests/e2e/import-and-edit.spec.ts
@@ -14,7 +14,8 @@ test('import a markdown file, edit a segment, confirm with Ctrl+Enter', async ({
await page.setInputFiles('#import-file', FIXTURE)
await expect(page.locator('#import-name')).toHaveValue('sample')
- // Defaults are en → es; just submit.
+ // Default pair (pt → en with no saved lookup history) is source ≠ target, so
+ // the monolingual guard is satisfied — just submit.
await page.getByRole('button', { name: 'Import', exact: true }).click()
await expect(page).toHaveURL(/#\/project\//)
diff --git a/tests/e2e/mt-flow.spec.ts b/tests/e2e/mt-flow.spec.ts
index 873833b..770ab63 100644
--- a/tests/e2e/mt-flow.spec.ts
+++ b/tests/e2e/mt-flow.spec.ts
@@ -30,13 +30,11 @@ async function waitForMTPersisted(
open.onsuccess = () => res(open.result)
open.onerror = () => rej(open.error)
})
- const row = await new Promise<{ value?: Record } | undefined>(
- (res, rej) => {
- const r = db.transaction('settings').objectStore('settings').get('mt.providers')
- r.onsuccess = () => res(r.result)
- r.onerror = () => rej(r.error)
- },
- )
+ const row = await new Promise<{ value?: Record } | undefined>((res, rej) => {
+ const r = db.transaction('settings').objectStore('settings').get('mt.providers')
+ r.onsuccess = () => res(r.result)
+ r.onerror = () => rej(r.error)
+ })
db.close()
const v = row?.value as
| {
diff --git a/tests/e2e/tm-flow.spec.ts b/tests/e2e/tm-flow.spec.ts
index 7e3c244..e2e758f 100644
--- a/tests/e2e/tm-flow.spec.ts
+++ b/tests/e2e/tm-flow.spec.ts
@@ -24,9 +24,7 @@ test.beforeEach(async ({ page }) => {
})
})
-test('TM auto-populates on confirm and surfaces matches in a second project', async ({
- page,
-}) => {
+test('TM auto-populates on confirm and surfaces matches in a second project', async ({ page }) => {
// Project 1: translate the first paragraph sentence.
await importSample(page, 'first')
const target1 = targetEditor(page, 1)
diff --git a/tests/e2e/tracked-changes.spec.ts b/tests/e2e/tracked-changes.spec.ts
index 05804f7..a4f2de4 100644
--- a/tests/e2e/tracked-changes.spec.ts
+++ b/tests/e2e/tracked-changes.spec.ts
@@ -205,7 +205,5 @@ test('jump to next tracked change moves focus via the command palette', async ({
await page.keyboard.press('Control+k')
await page.getByTestId('cmd-next-change').click()
- await expect(
- page.locator('[data-segment-index="3"][data-focused="true"]'),
- ).toBeVisible()
+ await expect(page.locator('[data-segment-index="3"][data-focused="true"]')).toBeVisible()
})
diff --git a/tests/unit/AddonsPage.test.tsx b/tests/unit/AddonsPage.test.tsx
index b3a5bc2..4f7d585 100644
--- a/tests/unit/AddonsPage.test.tsx
+++ b/tests/unit/AddonsPage.test.tsx
@@ -46,7 +46,9 @@ describe('AddonsPage (§6.2)', () => {
fireEvent.click(toggle)
expect(extensionRegistry.isEnabled('qa.double_space')).toBe(false)
- expect((screen.getByTestId('addon-toggle-qa.double_space') as HTMLInputElement).checked).toBe(false)
+ expect((screen.getByTestId('addon-toggle-qa.double_space') as HTMLInputElement).checked).toBe(
+ false,
+ )
fireEvent.click(screen.getByTestId('addon-toggle-qa.double_space'))
expect(extensionRegistry.isEnabled('qa.double_space')).toBe(true)
diff --git a/tests/unit/ConnectorFilePicker.test.tsx b/tests/unit/ConnectorFilePicker.test.tsx
index 64708b2..2b36119 100644
--- a/tests/unit/ConnectorFilePicker.test.tsx
+++ b/tests/unit/ConnectorFilePicker.test.tsx
@@ -9,7 +9,9 @@ function fakeConnector(files: ConnectorFile[]): StorageConnector {
name: 'Google Drive',
isConfigured: () => true,
listFiles: vi.fn().mockResolvedValue(files),
- downloadFile: vi.fn().mockImplementation(async (f: ConnectorFile) => new File(['data'], f.name)),
+ downloadFile: vi
+ .fn()
+ .mockImplementation(async (f: ConnectorFile) => new File(['data'], f.name)),
uploadFile: vi.fn(),
}
}
@@ -60,8 +62,14 @@ describe('ConnectorFilePicker (§6.3)', () => {
it('surfaces a listing error', async () => {
const connector = fakeConnector(FILES)
- ;(connector.listFiles as ReturnType).mockRejectedValueOnce(new Error('Authorisation cancelled'))
- render( {}} onPick={() => {}} />)
- expect(await screen.findByTestId('connector-error')).toHaveTextContent('Authorisation cancelled')
+ ;(connector.listFiles as ReturnType).mockRejectedValueOnce(
+ new Error('Authorisation cancelled'),
+ )
+ render(
+ {}} onPick={() => {}} />,
+ )
+ expect(await screen.findByTestId('connector-error')).toHaveTextContent(
+ 'Authorisation cancelled',
+ )
})
})
diff --git a/tests/unit/MTPanel.test.tsx b/tests/unit/MTPanel.test.tsx
index 7ba4815..0eefc93 100644
--- a/tests/unit/MTPanel.test.tsx
+++ b/tests/unit/MTPanel.test.tsx
@@ -18,12 +18,7 @@ beforeEach(async () => {
function renderPanel(onApply = vi.fn()) {
return render(
-
+
,
)
}
diff --git a/tests/unit/OfflineBadge.test.tsx b/tests/unit/OfflineBadge.test.tsx
index 65e969e..987ed59 100644
--- a/tests/unit/OfflineBadge.test.tsx
+++ b/tests/unit/OfflineBadge.test.tsx
@@ -13,10 +13,7 @@ describe('OfflineBadge', () => {
let originalDescriptor: PropertyDescriptor | undefined
beforeEach(() => {
- originalDescriptor = Object.getOwnPropertyDescriptor(
- Object.getPrototypeOf(navigator),
- 'onLine',
- )
+ originalDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(navigator), 'onLine')
setOnline(true)
})
diff --git a/tests/unit/TMEditDialog.test.tsx b/tests/unit/TMEditDialog.test.tsx
index 99f1c7f..9e3c1bb 100644
--- a/tests/unit/TMEditDialog.test.tsx
+++ b/tests/unit/TMEditDialog.test.tsx
@@ -15,15 +15,7 @@ const entry: TMEntry = {
describe('TMEditDialog', () => {
it('saves edited source/target and resolves project to undefined for global', async () => {
const onSave = vi.fn()
- render(
- ,
- )
+ render( )
const target = screen.getByTestId('tm-edit-target') as HTMLTextAreaElement
expect(target.value).toBe('olá')
diff --git a/tests/unit/about.test.tsx b/tests/unit/about.test.tsx
index f6d92d6..dd5f34d 100644
--- a/tests/unit/about.test.tsx
+++ b/tests/unit/about.test.tsx
@@ -20,7 +20,7 @@ describe('AboutPage', () => {
const hrefs = Array.from(document.querySelectorAll('a')).map((a) => a.getAttribute('href'))
expect(hrefs).toContain('https://github.com/pedrobritx/verbalis')
expect(hrefs).toContain('https://www.linkedin.com/in/pedrobritx/')
- expect(hrefs).toContain('https://pedrobritx.github.io/EwP/')
+ expect(hrefs).toContain('https://britx.me')
expect(hrefs).toContain('https://buymeacoffee.com/pedrobritx')
})
})
diff --git a/tests/unit/analysis.report.test.ts b/tests/unit/analysis.report.test.ts
index e6dfe79..834327c 100644
--- a/tests/unit/analysis.report.test.ts
+++ b/tests/unit/analysis.report.test.ts
@@ -75,12 +75,20 @@ describe('analyzeProject', () => {
})
it('buckets unmatched content as no-match', () => {
- const report = analyzeProject([seg(0, 'unrelated text here')], [tm('Hello world', 'Hola')], opts)
+ const report = analyzeProject(
+ [seg(0, 'unrelated text here')],
+ [tm('Hello world', 'Hola')],
+ opts,
+ )
expect(report.leverage.none.segments).toBe(1)
})
it('tracks status counts', () => {
- const report = analyzeProject([seg(0, 'a', 'translated'), seg(1, 'b', 'untranslated')], [], opts)
+ const report = analyzeProject(
+ [seg(0, 'a', 'translated'), seg(1, 'b', 'untranslated')],
+ [],
+ opts,
+ )
expect(report.statusCounts.translated).toBe(1)
expect(report.statusCounts.untranslated).toBe(1)
})
diff --git a/tests/unit/bilingual.xliff12.test.ts b/tests/unit/bilingual.xliff12.test.ts
index e219b66..8054d0d 100644
--- a/tests/unit/bilingual.xliff12.test.ts
+++ b/tests/unit/bilingual.xliff12.test.ts
@@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest'
import { exportXliff12, parseXliff12 } from '@/core/bilingual/xliff12'
-import {
- statusToXliffState,
- xliffStateToStatus,
-} from '@/core/bilingual/xliff12Status'
+import { statusToXliffState, xliffStateToStatus } from '@/core/bilingual/xliff12Status'
import type { ExportXliffUnit } from '@/core/bilingual/xliff12'
const MIN_XLIFF = `
diff --git a/tests/unit/changes.extract.test.ts b/tests/unit/changes.extract.test.ts
index d65d792..7452461 100644
--- a/tests/unit/changes.extract.test.ts
+++ b/tests/unit/changes.extract.test.ts
@@ -1,10 +1,5 @@
import { describe, expect, it } from 'vitest'
-import {
- createEditor,
- $getRoot,
- $createParagraphNode,
- $createTextNode,
-} from 'lexical'
+import { createEditor, $getRoot, $createParagraphNode, $createTextNode } from 'lexical'
import { RICH_NAMESPACE, getRichNodes } from '@/core/editor/richText'
// Importing the node registers it (side effect) so the headless editor can build
// and serialize states containing change marks.
diff --git a/tests/unit/changes.resolve.test.ts b/tests/unit/changes.resolve.test.ts
index 2ca6d42..07338b7 100644
--- a/tests/unit/changes.resolve.test.ts
+++ b/tests/unit/changes.resolve.test.ts
@@ -15,7 +15,13 @@ import { extractChanges } from '@/core/changes/extract'
const AUTHOR = { authorId: 'a1', authorName: 'Ada' }
function makeEditor(): LexicalEditor {
- return createEditor({ namespace: RICH_NAMESPACE, nodes: getRichNodes(), onError: (e) => { throw e } })
+ return createEditor({
+ namespace: RICH_NAMESPACE,
+ nodes: getRichNodes(),
+ onError: (e) => {
+ throw e
+ },
+ })
}
/**
diff --git a/tests/unit/cloud.members.test.ts b/tests/unit/cloud.members.test.ts
index 88f7920..fd77631 100644
--- a/tests/unit/cloud.members.test.ts
+++ b/tests/unit/cloud.members.test.ts
@@ -1,18 +1,15 @@
import { describe, expect, it } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
-import {
- listMembers,
- inviteMember,
- changeMemberRole,
- removeMember,
-} from '@/storage/cloud/members'
+import { listMembers, inviteMember, changeMemberRole, removeMember } from '@/storage/cloud/members'
type Row = Record
type Filter = ['eq', string, unknown] | ['in', string, unknown[]]
function applyFilters(rows: Row[], filters: Filter[]): Row[] {
return rows.filter((r) =>
- filters.every((f) => (f[0] === 'eq' ? r[f[1]] === f[2] : (f[2] as unknown[]).includes(r[f[1]]))),
+ filters.every((f) =>
+ f[0] === 'eq' ? r[f[1]] === f[2] : (f[2] as unknown[]).includes(r[f[1]]),
+ ),
)
}
@@ -103,7 +100,12 @@ function makeClient(
(m) => m.project_id === args.p_project_id && m.user_id === uid,
)
if (existing) existing.role = args.p_role
- else tables.project_members.push({ project_id: args.p_project_id, user_id: uid, role: args.p_role })
+ else
+ tables.project_members.push({
+ project_id: args.p_project_id,
+ user_id: uid,
+ role: args.p_role,
+ })
return Promise.resolve({ data: uid, error: null })
},
}
@@ -145,20 +147,33 @@ describe('inviteMember', () => {
})
const res = await inviteMember(client, PID, 'Cid@x.com ', 'revisor')
expect(res).toEqual({ status: 'invited', userId: 'u9' })
- expect(tables.project_members).toContainEqual({ project_id: PID, user_id: 'u9', role: 'revisor' })
+ expect(tables.project_members).toContainEqual({
+ project_id: PID,
+ user_id: 'u9',
+ role: 'revisor',
+ })
})
it('reports not_found when no account uses that email', async () => {
const { client } = makeClient({ profiles: [] })
- expect(await inviteMember(client, PID, 'ghost@x.com', 'translator')).toEqual({ status: 'not_found' })
+ expect(await inviteMember(client, PID, 'ghost@x.com', 'translator')).toEqual({
+ status: 'not_found',
+ })
})
it('propagates a forbidden error from the RPC', async () => {
const { client } = makeClient(
{},
- { invite: () => ({ data: null, error: { message: 'only a project_manager may invite members' } }) },
+ {
+ invite: () => ({
+ data: null,
+ error: { message: 'only a project_manager may invite members' },
+ }),
+ },
+ )
+ await expect(inviteMember(client, PID, 'x@x.com', 'translator')).rejects.toThrow(
+ /project_manager/,
)
- await expect(inviteMember(client, PID, 'x@x.com', 'translator')).rejects.toThrow(/project_manager/)
})
})
diff --git a/tests/unit/cloud.projectCloud.test.ts b/tests/unit/cloud.projectCloud.test.ts
index dff3942..6b7723e 100644
--- a/tests/unit/cloud.projectCloud.test.ts
+++ b/tests/unit/cloud.projectCloud.test.ts
@@ -130,7 +130,11 @@ describe('insertCloudProject', () => {
it('propagates an insert error', async () => {
const client = {
from: () => ({
- insert: () => ({ select: () => ({ single: () => Promise.resolve({ data: null, error: { message: 'RLS denied' } }) }) }),
+ insert: () => ({
+ select: () => ({
+ single: () => Promise.resolve({ data: null, error: { message: 'RLS denied' } }),
+ }),
+ }),
}),
} as unknown as SupabaseClient
await expect(
diff --git a/tests/unit/cloud.rowSync.repo.test.ts b/tests/unit/cloud.rowSync.repo.test.ts
index 48fe56f..1dc9921 100644
--- a/tests/unit/cloud.rowSync.repo.test.ts
+++ b/tests/unit/cloud.rowSync.repo.test.ts
@@ -17,7 +17,12 @@ describe('repos stamp updatedAt', () => {
const g = await db.glossary.get(gid)
expect(typeof g?.updatedAt).toBe('number')
- const tid = await tmRepo.upsert({ source: 'a', target: 'b', sourceLang: 'en', targetLang: 'pt' })
+ const tid = await tmRepo.upsert({
+ source: 'a',
+ target: 'b',
+ sourceLang: 'en',
+ targetLang: 'pt',
+ })
const t = await db.tm.get(tid)
expect(typeof t?.updatedAt).toBe('number')
})
diff --git a/tests/unit/cloud.rowSync.test.ts b/tests/unit/cloud.rowSync.test.ts
index 85b00cf..42d6b02 100644
--- a/tests/unit/cloud.rowSync.test.ts
+++ b/tests/unit/cloud.rowSync.test.ts
@@ -4,12 +4,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { db } from '@/storage/db'
import { glossaryRepo } from '@/storage/repositories/glossaryRepo'
import { setRowSyncEnabled } from '@/storage/cloud/rowSyncState'
-import {
- pushResource,
- pullResource,
- syncResource,
- syncAllResources,
-} from '@/storage/cloud/rowSync'
+import { pushResource, pullResource, syncResource, syncAllResources } from '@/storage/cloud/rowSync'
import type { GlossaryEntry } from '@/core/types'
interface ServerRow {
@@ -39,9 +34,7 @@ function makeServer() {
order() {
const cutoff = Date.parse(iso)
const data = [...rows.values()]
- .filter(
- (r) => r.user_id === userId && Date.parse(r.updated_at) > cutoff,
- )
+ .filter((r) => r.user_id === userId && Date.parse(r.updated_at) > cutoff)
.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at))
return Promise.resolve({ data, error: null })
},
@@ -106,8 +99,25 @@ describe('pushResource', () => {
it('excludes bundled-corpus TM rows', async () => {
const { client, tables } = makeServer()
await db.tm.bulkPut([
- { id: 't1', source: 'a', target: 'b', sourceLang: 'en', targetLang: 'pt', date: 'x', updatedAt: 10 },
- { id: 'c1', source: 'c', target: 'd', sourceLang: 'en', targetLang: 'pt', date: 'x', updatedAt: 10, corpusId: 'pack1' },
+ {
+ id: 't1',
+ source: 'a',
+ target: 'b',
+ sourceLang: 'en',
+ targetLang: 'pt',
+ date: 'x',
+ updatedAt: 10,
+ },
+ {
+ id: 'c1',
+ source: 'c',
+ target: 'd',
+ sourceLang: 'en',
+ targetLang: 'pt',
+ date: 'x',
+ updatedAt: 10,
+ corpusId: 'pack1',
+ },
])
const tmAdapter = {
resource: 'tm' as const,
diff --git a/tests/unit/cloud.settingsSync.test.ts b/tests/unit/cloud.settingsSync.test.ts
index 03d917b..885d51a 100644
--- a/tests/unit/cloud.settingsSync.test.ts
+++ b/tests/unit/cloud.settingsSync.test.ts
@@ -74,10 +74,7 @@ describe('reconcileSettings (per-key LWW)', () => {
})
it('ignores keys outside the allowlist entirely', () => {
- const { pull, push } = reconcileSettings(
- [L(MT_SETTINGS_KEY, 100)],
- [R(MT_SETTINGS_KEY, 1)],
- )
+ const { pull, push } = reconcileSettings([L(MT_SETTINGS_KEY, 100)], [R(MT_SETTINGS_KEY, 1)])
expect(pull).toHaveLength(0)
expect(push).toHaveLength(0)
})
@@ -104,9 +101,7 @@ function makeClient(rows: FakeRow[]) {
eq(_col: string, userId: string) {
return {
in(_keyCol: string, keys: string[]) {
- const data = rows.filter(
- (r) => r.user_id === userId && keys.includes(r.key),
- )
+ const data = rows.filter((r) => r.user_id === userId && keys.includes(r.key))
return Promise.resolve({ data, error: null })
},
}
diff --git a/tests/unit/cloud.supabaseClient.test.ts b/tests/unit/cloud.supabaseClient.test.ts
index 3284d0c..10efb63 100644
--- a/tests/unit/cloud.supabaseClient.test.ts
+++ b/tests/unit/cloud.supabaseClient.test.ts
@@ -1,9 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import {
- isCloudConfigured,
- getSupabase,
- configuredProviders,
-} from '@/storage/cloud/supabaseClient'
+import { isCloudConfigured, getSupabase, configuredProviders } from '@/storage/cloud/supabaseClient'
// Default test env leaves VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY unset, so
// this mirrors a local-only deployment: the cloud is off and the module must
diff --git a/tests/unit/connectors.driveApi.test.ts b/tests/unit/connectors.driveApi.test.ts
index 49a944b..d3920ac 100644
--- a/tests/unit/connectors.driveApi.test.ts
+++ b/tests/unit/connectors.driveApi.test.ts
@@ -15,7 +15,13 @@ describe('driveApi.listDriveFiles', () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(
jsonOk({
files: [
- { id: 'a', name: 'doc.docx', mimeType: 'application/vnd...', modifiedTime: '2026-07-20T00:00:00Z', size: '1234' },
+ {
+ id: 'a',
+ name: 'doc.docx',
+ mimeType: 'application/vnd...',
+ modifiedTime: '2026-07-20T00:00:00Z',
+ size: '1234',
+ },
{ id: 'b', name: 'notes.txt' },
],
}),
@@ -29,7 +35,13 @@ describe('driveApi.listDriveFiles', () => {
expect((init.headers as Record).Authorization).toBe('Bearer tok-123')
expect(files).toEqual([
- { id: 'a', name: 'doc.docx', mimeType: 'application/vnd...', modifiedTime: '2026-07-20T00:00:00Z', sizeBytes: 1234 },
+ {
+ id: 'a',
+ name: 'doc.docx',
+ mimeType: 'application/vnd...',
+ modifiedTime: '2026-07-20T00:00:00Z',
+ sizeBytes: 1234,
+ },
{ id: 'b', name: 'notes.txt' },
])
})
@@ -46,7 +58,9 @@ describe('driveApi.listDriveFiles', () => {
status: 401,
json: async () => ({ error: { message: 'Invalid Credentials' } }),
})
- await expect(listDriveFiles('bad', {}, fetchImpl as unknown as typeof fetch)).rejects.toMatchObject({
+ await expect(
+ listDriveFiles('bad', {}, fetchImpl as unknown as typeof fetch),
+ ).rejects.toMatchObject({
name: 'ConnectorError',
code: 'auth',
message: 'Invalid Credentials',
@@ -54,8 +68,12 @@ describe('driveApi.listDriveFiles', () => {
})
it('maps a 429 to rate_limit', async () => {
- const fetchImpl = vi.fn().mockResolvedValueOnce({ ok: false, status: 429, json: async () => ({}) })
- await expect(listDriveFiles('t', {}, fetchImpl as unknown as typeof fetch)).rejects.toMatchObject({
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce({ ok: false, status: 429, json: async () => ({}) })
+ await expect(
+ listDriveFiles('t', {}, fetchImpl as unknown as typeof fetch),
+ ).rejects.toMatchObject({
code: 'rate_limit',
})
})
@@ -80,9 +98,11 @@ describe('driveApi.downloadDriveFile', () => {
describe('driveApi.uploadDriveFile', () => {
it('POSTs a multipart/related body with metadata + media and returns the file', async () => {
- const fetchImpl = vi.fn().mockResolvedValueOnce(
- jsonOk({ id: 'new1', name: 'out.docx', mimeType: 'application/x-docx' }),
- )
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(
+ jsonOk({ id: 'new1', name: 'out.docx', mimeType: 'application/x-docx' }),
+ )
const bytes = new TextEncoder().encode('DOCXBYTES')
const created = await uploadDriveFile(
'tok',
diff --git a/tests/unit/connectors.gdrive.test.ts b/tests/unit/connectors.gdrive.test.ts
index 67cd78e..9f3cb99 100644
--- a/tests/unit/connectors.gdrive.test.ts
+++ b/tests/unit/connectors.gdrive.test.ts
@@ -62,16 +62,24 @@ describe('createGdriveConnector', () => {
const files = await c.listFiles()
expect(getToken).toHaveBeenCalled()
expect(files).toEqual([{ id: '1', name: 'a.txt' }])
- expect((fetchImpl.mock.calls[0][1].headers as Record).Authorization).toBe('Bearer tok-x')
+ expect((fetchImpl.mock.calls[0][1].headers as Record).Authorization).toBe(
+ 'Bearer tok-x',
+ )
})
it('downloads a file into a named File with its mime type', async () => {
const getToken = vi.fn().mockResolvedValue('tok')
const bytes = new TextEncoder().encode('body').buffer
- const fetchImpl = vi.fn().mockResolvedValueOnce({ ok: true, status: 200, arrayBuffer: async () => bytes })
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce({ ok: true, status: 200, arrayBuffer: async () => bytes })
const c = createGdriveConnector({ getToken, fetchImpl: fetchImpl as unknown as typeof fetch })
- const file = await c.downloadFile({ id: '9', name: 'report.docx', mimeType: 'application/x-docx' })
+ const file = await c.downloadFile({
+ id: '9',
+ name: 'report.docx',
+ mimeType: 'application/x-docx',
+ })
expect(file).toBeInstanceOf(File)
expect(file.name).toBe('report.docx')
expect(file.type).toBe('application/x-docx')
@@ -83,7 +91,11 @@ describe('createGdriveConnector', () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(jsonOk({ id: 'up1', name: 'x.docx' }))
const c = createGdriveConnector({ getToken, fetchImpl: fetchImpl as unknown as typeof fetch })
- const created = await c.uploadFile({ name: 'x.docx', mimeType: 'application/x-docx', bytes: new Uint8Array([1, 2]) })
+ const created = await c.uploadFile({
+ name: 'x.docx',
+ mimeType: 'application/x-docx',
+ bytes: new Uint8Array([1, 2]),
+ })
expect(created).toEqual({ id: 'up1', name: 'x.docx' })
expect(fetchImpl.mock.calls[0][1].method).toBe('POST')
})
diff --git a/tests/unit/connectors.graphApi.test.ts b/tests/unit/connectors.graphApi.test.ts
index 9fb8c01..d30f182 100644
--- a/tests/unit/connectors.graphApi.test.ts
+++ b/tests/unit/connectors.graphApi.test.ts
@@ -15,7 +15,13 @@ describe('graphApi.listOnedriveFiles', () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(
jsonOk({
value: [
- { id: 'a', name: 'doc.docx', size: 1234, lastModifiedDateTime: '2026-07-20T00:00:00Z', file: { mimeType: 'application/x-docx' } },
+ {
+ id: 'a',
+ name: 'doc.docx',
+ size: 1234,
+ lastModifiedDateTime: '2026-07-20T00:00:00Z',
+ file: { mimeType: 'application/x-docx' },
+ },
{ id: 'f', name: 'Folder', folder: { childCount: 2 } },
{ id: 'b', name: 'notes.txt', file: {} },
],
@@ -25,13 +31,19 @@ describe('graphApi.listOnedriveFiles', () => {
const [url, init] = fetchImpl.mock.calls[0]
expect(url).toBe(
- "https://graph.microsoft.com/v1.0/me/drive/root/children?$top=50&$orderby=lastModifiedDateTime%20desc&$select=id,name,size,file,folder,lastModifiedDateTime",
+ 'https://graph.microsoft.com/v1.0/me/drive/root/children?$top=50&$orderby=lastModifiedDateTime%20desc&$select=id,name,size,file,folder,lastModifiedDateTime',
)
expect((init.headers as Record).Authorization).toBe('Bearer tok-1')
// Folder skipped; files mapped.
expect(files).toEqual([
- { id: 'a', name: 'doc.docx', mimeType: 'application/x-docx', modifiedTime: '2026-07-20T00:00:00Z', sizeBytes: 1234 },
+ {
+ id: 'a',
+ name: 'doc.docx',
+ mimeType: 'application/x-docx',
+ modifiedTime: '2026-07-20T00:00:00Z',
+ sizeBytes: 1234,
+ },
{ id: 'b', name: 'notes.txt' },
])
})
@@ -48,7 +60,9 @@ describe('graphApi.listOnedriveFiles', () => {
status: 401,
json: async () => ({ error: { message: 'InvalidAuthenticationToken' } }),
})
- await expect(listOnedriveFiles('bad', {}, fetchImpl as unknown as typeof fetch)).rejects.toMatchObject({
+ await expect(
+ listOnedriveFiles('bad', {}, fetchImpl as unknown as typeof fetch),
+ ).rejects.toMatchObject({
name: 'ConnectorError',
code: 'auth',
message: 'InvalidAuthenticationToken',
@@ -59,7 +73,9 @@ describe('graphApi.listOnedriveFiles', () => {
describe('graphApi.downloadOnedriveFile', () => {
it('GETs the item content with the token and returns the bytes', async () => {
const bytes = new TextEncoder().encode('hello').buffer
- const fetchImpl = vi.fn().mockResolvedValueOnce({ ok: true, status: 200, arrayBuffer: async () => bytes })
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce({ ok: true, status: 200, arrayBuffer: async () => bytes })
const out = await downloadOnedriveFile('tok', 'item 1', fetchImpl as unknown as typeof fetch)
const [url, init] = fetchImpl.mock.calls[0]
@@ -71,9 +87,11 @@ describe('graphApi.downloadOnedriveFile', () => {
describe('graphApi.uploadOnedriveFile', () => {
it('PUTs the bytes to the root path and returns the created item', async () => {
- const fetchImpl = vi.fn().mockResolvedValueOnce(
- jsonOk({ id: 'new1', name: 'out.docx', file: { mimeType: 'application/x-docx' } }),
- )
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(
+ jsonOk({ id: 'new1', name: 'out.docx', file: { mimeType: 'application/x-docx' } }),
+ )
const bytes = new TextEncoder().encode('DOCXBYTES')
const created = await uploadOnedriveFile(
'tok',
diff --git a/tests/unit/connectors.onedrive.test.ts b/tests/unit/connectors.onedrive.test.ts
index 964f1d3..126d3b1 100644
--- a/tests/unit/connectors.onedrive.test.ts
+++ b/tests/unit/connectors.onedrive.test.ts
@@ -57,22 +57,32 @@ describe('createOnedriveConnector', () => {
it('lists files through an injected token + fetch', async () => {
const getToken = vi.fn().mockResolvedValue('tok-x')
- const fetchImpl = vi.fn().mockResolvedValueOnce(jsonOk({ value: [{ id: '1', name: 'a.txt', file: {} }] }))
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(jsonOk({ value: [{ id: '1', name: 'a.txt', file: {} }] }))
const c = createOnedriveConnector({ getToken, fetchImpl: fetchImpl as unknown as typeof fetch })
const files = await c.listFiles()
expect(getToken).toHaveBeenCalled()
expect(files).toEqual([{ id: '1', name: 'a.txt' }])
- expect((fetchImpl.mock.calls[0][1].headers as Record).Authorization).toBe('Bearer tok-x')
+ expect((fetchImpl.mock.calls[0][1].headers as Record).Authorization).toBe(
+ 'Bearer tok-x',
+ )
})
it('downloads a file into a named File with its mime type', async () => {
const getToken = vi.fn().mockResolvedValue('tok')
const bytes = new TextEncoder().encode('body').buffer
- const fetchImpl = vi.fn().mockResolvedValueOnce({ ok: true, status: 200, arrayBuffer: async () => bytes })
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce({ ok: true, status: 200, arrayBuffer: async () => bytes })
const c = createOnedriveConnector({ getToken, fetchImpl: fetchImpl as unknown as typeof fetch })
- const file = await c.downloadFile({ id: '9', name: 'report.docx', mimeType: 'application/x-docx' })
+ const file = await c.downloadFile({
+ id: '9',
+ name: 'report.docx',
+ mimeType: 'application/x-docx',
+ })
expect(file).toBeInstanceOf(File)
expect(file.name).toBe('report.docx')
expect(file.type).toBe('application/x-docx')
@@ -84,7 +94,11 @@ describe('createOnedriveConnector', () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(jsonOk({ id: 'up1', name: 'x.docx', file: {} }))
const c = createOnedriveConnector({ getToken, fetchImpl: fetchImpl as unknown as typeof fetch })
- const created = await c.uploadFile({ name: 'x.docx', mimeType: 'application/x-docx', bytes: new Uint8Array([1, 2]) })
+ const created = await c.uploadFile({
+ name: 'x.docx',
+ mimeType: 'application/x-docx',
+ bytes: new Uint8Array([1, 2]),
+ })
expect(created).toEqual({ id: 'up1', name: 'x.docx' })
expect(fetchImpl.mock.calls[0][1].method).toBe('PUT')
})
diff --git a/tests/unit/corpus.customPack.test.ts b/tests/unit/corpus.customPack.test.ts
index d65957e..0f21c88 100644
--- a/tests/unit/corpus.customPack.test.ts
+++ b/tests/unit/corpus.customPack.test.ts
@@ -36,11 +36,14 @@ describe('rowsToCorpusFile', () => {
})
it('matches the base language when the target is a regional variant', () => {
- const file = rowsToCorpusFile([{ term: 'colour', translations: { 'en-GB': 'colour' }, notes: '' }], {
- id: 'custom:x',
- langSource: 'pt',
- langTarget: 'en-US',
- })
+ const file = rowsToCorpusFile(
+ [{ term: 'colour', translations: { 'en-GB': 'colour' }, notes: '' }],
+ {
+ id: 'custom:x',
+ langSource: 'pt',
+ langTarget: 'en-US',
+ },
+ )
expect(file.terms).toEqual([['colour', 'colour', 'custom', '']])
})
diff --git a/tests/unit/corpus.match.test.ts b/tests/unit/corpus.match.test.ts
index 98aa073..def7f0a 100644
--- a/tests/unit/corpus.match.test.ts
+++ b/tests/unit/corpus.match.test.ts
@@ -1,9 +1,5 @@
import { describe, expect, it } from 'vitest'
-import {
- buildCorpusIndex,
- findCorpusHits,
- keySideForSourceLang,
-} from '@/core/corpus/match'
+import { buildCorpusIndex, findCorpusHits, keySideForSourceLang } from '@/core/corpus/match'
import type { CorpusTerm } from '@/core/corpus/types'
function term(over: Partial = {}): CorpusTerm {
diff --git a/tests/unit/corpusRepo.test.ts b/tests/unit/corpusRepo.test.ts
index 4c9f1e2..9899a5c 100644
--- a/tests/unit/corpusRepo.test.ts
+++ b/tests/unit/corpusRepo.test.ts
@@ -63,10 +63,9 @@ describe('corpusRepo', () => {
it('re-installing replaces the previous pack (idempotent)', async () => {
await corpusRepo.install(pack(), { label: 'Competition' })
- await corpusRepo.install(
- pack({ terms: [['multa', 'fine', 'CADE', '']] }),
- { label: 'Competition' },
- )
+ await corpusRepo.install(pack({ terms: [['multa', 'fine', 'CADE', '']] }), {
+ label: 'Competition',
+ })
const terms = await corpusRepo.termsByCorpus('competition')
expect(terms).toHaveLength(1)
expect(terms[0].source).toBe('multa')
diff --git a/tests/unit/crdtBridge.test.ts b/tests/unit/crdtBridge.test.ts
index be063e0..e9900d3 100644
--- a/tests/unit/crdtBridge.test.ts
+++ b/tests/unit/crdtBridge.test.ts
@@ -53,7 +53,9 @@ describe('Dexie→Yjs bridge', () => {
expect(readSegment(doc, 'p1', 's1')?.locked).toBe(true)
await segmentRepo.addComment('s1', { id: 'c1', body: 'hi', createdAt: 'x' })
- expect(readSegment(doc, 'p1', 's1')?.comments).toEqual([{ id: 'c1', body: 'hi', createdAt: 'x' }])
+ expect(readSegment(doc, 'p1', 's1')?.comments).toEqual([
+ { id: 'c1', body: 'hi', createdAt: 'x' },
+ ])
await segmentRepo.deleteComment('s1', 'c1')
expect(readSegment(doc, 'p1', 's1')?.comments).toBeUndefined()
diff --git a/tests/unit/db.migration.test.ts b/tests/unit/db.migration.test.ts
index 0953197..485a43a 100644
--- a/tests/unit/db.migration.test.ts
+++ b/tests/unit/db.migration.test.ts
@@ -187,7 +187,14 @@ describe('v6 document/block backfill', () => {
it('builds a document + blocks for monolingual projects and stamps blockId', async () => {
const v5 = openV5()
await v5.table('projects').bulkAdd([
- { id: 'mono', name: 'Doc', sourceLang: 'en', targetLang: 'fr', createdAt: 'n', updatedAt: 'n' },
+ {
+ id: 'mono',
+ name: 'Doc',
+ sourceLang: 'en',
+ targetLang: 'fr',
+ createdAt: 'n',
+ updatedAt: 'n',
+ },
{
id: 'xliff',
name: 'Bi',
@@ -199,11 +206,50 @@ describe('v6 document/block backfill', () => {
},
])
await v5.table('segments').bulkAdd([
- { id: 's0', projectId: 'mono', index: 0, source: 'Title', target: '', status: 'untranslated', sourceMeta: { kind: 'heading', blockIndex: 0, sentenceIndex: 0 }, createdAt: 'n', updatedAt: 'n' },
- { id: 's1', projectId: 'mono', index: 1, source: 'A.', target: '', status: 'untranslated', sourceMeta: { kind: 'paragraph', blockIndex: 1, sentenceIndex: 0 }, createdAt: 'n', updatedAt: 'n' },
- { id: 's2', projectId: 'mono', index: 2, source: 'B.', target: '', status: 'untranslated', sourceMeta: { kind: 'paragraph', blockIndex: 1, sentenceIndex: 1 }, createdAt: 'n', updatedAt: 'n' },
+ {
+ id: 's0',
+ projectId: 'mono',
+ index: 0,
+ source: 'Title',
+ target: '',
+ status: 'untranslated',
+ sourceMeta: { kind: 'heading', blockIndex: 0, sentenceIndex: 0 },
+ createdAt: 'n',
+ updatedAt: 'n',
+ },
+ {
+ id: 's1',
+ projectId: 'mono',
+ index: 1,
+ source: 'A.',
+ target: '',
+ status: 'untranslated',
+ sourceMeta: { kind: 'paragraph', blockIndex: 1, sentenceIndex: 0 },
+ createdAt: 'n',
+ updatedAt: 'n',
+ },
+ {
+ id: 's2',
+ projectId: 'mono',
+ index: 2,
+ source: 'B.',
+ target: '',
+ status: 'untranslated',
+ sourceMeta: { kind: 'paragraph', blockIndex: 1, sentenceIndex: 1 },
+ createdAt: 'n',
+ updatedAt: 'n',
+ },
// XLIFF segment: no sourceMeta.
- { id: 'x0', projectId: 'xliff', index: 0, source: 'x', target: '', status: 'untranslated', createdAt: 'n', updatedAt: 'n' },
+ {
+ id: 'x0',
+ projectId: 'xliff',
+ index: 0,
+ source: 'x',
+ target: '',
+ status: 'untranslated',
+ createdAt: 'n',
+ updatedAt: 'n',
+ },
])
v5.close()
diff --git a/tests/unit/displayName.test.ts b/tests/unit/displayName.test.ts
new file mode 100644
index 0000000..58a8ee4
--- /dev/null
+++ b/tests/unit/displayName.test.ts
@@ -0,0 +1,68 @@
+import { describe, it, expect } from 'vitest'
+import {
+ resolveDisplayName,
+ SELF_NAME_FALLBACK,
+ PEER_NAME_FALLBACK,
+ type AuthNameSlice,
+} from '@/features/account/displayName'
+
+const user = (displayName: string | null): AuthNameSlice['user'] => ({
+ id: 'u1',
+ email: 'a@b.c',
+ displayName,
+ avatarUrl: null,
+})
+
+const signedOut: AuthNameSlice = { status: 'unauthenticated', user: null, profileDisplayName: null }
+
+describe('resolveDisplayName', () => {
+ it('prefers the account profile name when signed in', () => {
+ const auth: AuthNameSlice = {
+ status: 'authenticated',
+ user: user('Meta Name'),
+ profileDisplayName: 'Account Name',
+ }
+ expect(resolveDisplayName('Local', auth)).toBe('Account Name')
+ })
+
+ it('falls back to the user metadata name when the profile name is empty', () => {
+ const auth: AuthNameSlice = {
+ status: 'authenticated',
+ user: user('Meta Name'),
+ profileDisplayName: null,
+ }
+ expect(resolveDisplayName('Local', auth)).toBe('Meta Name')
+ })
+
+ it('uses the local name when signed in but the account has no name', () => {
+ const auth: AuthNameSlice = {
+ status: 'authenticated',
+ user: user(null),
+ profileDisplayName: ' ',
+ }
+ expect(resolveDisplayName('Local', auth)).toBe('Local')
+ })
+
+ it('uses the local name when signed out', () => {
+ expect(resolveDisplayName('Local', signedOut)).toBe('Local')
+ })
+
+ it('falls back to the friendly default when nothing is set', () => {
+ expect(resolveDisplayName('', signedOut)).toBe(SELF_NAME_FALLBACK)
+ expect(resolveDisplayName(undefined, signedOut)).toBe(SELF_NAME_FALLBACK)
+ })
+
+ it('honours a custom fallback (e.g. peer presence)', () => {
+ expect(resolveDisplayName(null, signedOut, PEER_NAME_FALLBACK)).toBe(PEER_NAME_FALLBACK)
+ expect(PEER_NAME_FALLBACK).not.toBe(SELF_NAME_FALLBACK)
+ })
+
+ it('does not use the account name for a non-authenticated status', () => {
+ const loading: AuthNameSlice = {
+ status: 'loading',
+ user: user('Meta'),
+ profileDisplayName: 'Account',
+ }
+ expect(resolveDisplayName('Local', loading)).toBe('Local')
+ })
+})
diff --git a/tests/unit/documentRepo.test.ts b/tests/unit/documentRepo.test.ts
index d3994a7..e629a68 100644
--- a/tests/unit/documentRepo.test.ts
+++ b/tests/unit/documentRepo.test.ts
@@ -7,7 +7,14 @@ import type { Block, DocumentEntity } from '@/core/documents/model'
const now = 'now'
function doc(projectId: string): DocumentEntity {
- return { id: `doc-${projectId}`, projectId, name: 'Doc', sourceFormat: 'md', createdAt: now, updatedAt: now }
+ return {
+ id: `doc-${projectId}`,
+ projectId,
+ name: 'Doc',
+ sourceFormat: 'md',
+ createdAt: now,
+ updatedAt: now,
+ }
}
function block(documentId: string, projectId: string, index: number): Block {
@@ -22,13 +29,20 @@ beforeEach(async () => {
describe('documentRepo / blockRepo', () => {
it('stores and reads a project document', async () => {
await documentRepo.create(doc('p1'))
- expect(await documentRepo.getByProject('p1')).toMatchObject({ projectId: 'p1', sourceFormat: 'md' })
+ expect(await documentRepo.getByProject('p1')).toMatchObject({
+ projectId: 'p1',
+ sourceFormat: 'md',
+ })
expect(await documentRepo.getByProject('nope')).toBeUndefined()
})
it('reads blocks for a document in index order', async () => {
// Insert out of order; expect ordered read.
- await blockRepo.bulkCreate([block('doc-p1', 'p1', 2), block('doc-p1', 'p1', 0), block('doc-p1', 'p1', 1)])
+ await blockRepo.bulkCreate([
+ block('doc-p1', 'p1', 2),
+ block('doc-p1', 'p1', 0),
+ block('doc-p1', 'p1', 1),
+ ])
const ordered = await blockRepo.byDocument('doc-p1')
expect(ordered.map((b) => b.index)).toEqual([0, 1, 2])
})
diff --git a/tests/unit/documents.docxImport.test.ts b/tests/unit/documents.docxImport.test.ts
index 53929a8..2a801c4 100644
--- a/tests/unit/documents.docxImport.test.ts
+++ b/tests/unit/documents.docxImport.test.ts
@@ -13,7 +13,9 @@ describe('extractRuns', () => {
}
it('captures bold / italic / underline / sub / sup / links, merging like runs', () => {
- const out = runs('plain bold and it x y link ')
+ const out = runs(
+ 'plain bold and it x y link ',
+ )
expect(out).toEqual([
{ text: 'plain ' },
{ text: 'bold', bold: true },
@@ -27,7 +29,9 @@ describe('extractRuns', () => {
})
it('composes nested formats', () => {
- expect(runs('both ')).toEqual([{ text: 'both', bold: true, italic: true }])
+ expect(runs('both ')).toEqual([
+ { text: 'both', bold: true, italic: true },
+ ])
})
})
@@ -35,7 +39,10 @@ describe('htmlToParsedDocx', () => {
it('keeps whole-block source runs on a heading while segmenting text', () => {
const { segments, blocks } = htmlToParsedDocx('The Big Title ')
expect(segments).toEqual([
- { source: 'The Big Title', sourceMeta: { kind: 'heading', depth: 2, blockIndex: 0, sentenceIndex: 0 } },
+ {
+ source: 'The Big Title',
+ sourceMeta: { kind: 'heading', depth: 2, blockIndex: 0, sentenceIndex: 0 },
+ },
])
expect(blocks[0]).toMatchObject({
kind: 'heading',
@@ -50,7 +57,11 @@ describe('htmlToParsedDocx', () => {
expect(segments.map((s) => s.source)).toEqual(['One two.', 'Three.'])
expect(blocks).toHaveLength(1)
expect(blocks[0].kind).toBe('paragraph')
- expect(blocks[0].sourceRuns).toEqual([{ text: 'One ' }, { text: 'two', italic: true }, { text: '. Three.' }])
+ expect(blocks[0].sourceRuns).toEqual([
+ { text: 'One ' },
+ { text: 'two', italic: true },
+ { text: '. Three.' },
+ ])
})
it('emits a table block with cell runs and one segment per non-empty cell', () => {
diff --git a/tests/unit/documents.fromSegments.test.ts b/tests/unit/documents.fromSegments.test.ts
index d9e9800..df6edd6 100644
--- a/tests/unit/documents.fromSegments.test.ts
+++ b/tests/unit/documents.fromSegments.test.ts
@@ -43,10 +43,10 @@ describe('buildBlocksFromSegments', () => {
})
it('yields no blocks for segments without sourceMeta (e.g. XLIFF)', () => {
- const { blocks, segmentBlockIds } = buildBlocksFromSegments(
- [seg('x'), seg('y')],
- { documentId: 'd', projectId: 'p' },
- )
+ const { blocks, segmentBlockIds } = buildBlocksFromSegments([seg('x'), seg('y')], {
+ documentId: 'd',
+ projectId: 'p',
+ })
expect(blocks).toEqual([])
expect(segmentBlockIds.size).toBe(0)
})
diff --git a/tests/unit/documents.render.test.ts b/tests/unit/documents.render.test.ts
index 9761059..6148bbf 100644
--- a/tests/unit/documents.render.test.ts
+++ b/tests/unit/documents.render.test.ts
@@ -26,10 +26,31 @@ describe('renderDocument', () => {
block({ id: 'b0', index: 0, kind: 'heading', depth: 1 }),
]
const segments = [
- seg({ id: 's-h', index: 0, blockId: 'b0', source: 'Title', target: 'Titre', sourceMeta: { kind: 'heading', blockIndex: 0, sentenceIndex: 0 } }),
+ seg({
+ id: 's-h',
+ index: 0,
+ blockId: 'b0',
+ source: 'Title',
+ target: 'Titre',
+ sourceMeta: { kind: 'heading', blockIndex: 0, sentenceIndex: 0 },
+ }),
// Two sentences in b1: first translated, second not.
- seg({ id: 's-a', index: 1, blockId: 'b1', source: 'One.', target: 'Un.', sourceMeta: { kind: 'paragraph', blockIndex: 1, sentenceIndex: 0 } }),
- seg({ id: 's-b', index: 2, blockId: 'b1', source: 'Two.', target: '', sourceMeta: { kind: 'paragraph', blockIndex: 1, sentenceIndex: 1 } }),
+ seg({
+ id: 's-a',
+ index: 1,
+ blockId: 'b1',
+ source: 'One.',
+ target: 'Un.',
+ sourceMeta: { kind: 'paragraph', blockIndex: 1, sentenceIndex: 0 },
+ }),
+ seg({
+ id: 's-b',
+ index: 2,
+ blockId: 'b1',
+ source: 'Two.',
+ target: '',
+ sourceMeta: { kind: 'paragraph', blockIndex: 1, sentenceIndex: 1 },
+ }),
]
const model = renderDocument(blocks, segments)
@@ -50,20 +71,53 @@ describe('renderDocument', () => {
it('respects sentenceIndex order regardless of segment array order', () => {
const blocks = [block({ id: 'b', index: 0 })]
const segments = [
- seg({ id: 's2', index: 1, blockId: 'b', source: 'B.', target: 'Bt.', sourceMeta: { kind: 'paragraph', blockIndex: 0, sentenceIndex: 1 } }),
- seg({ id: 's1', index: 0, blockId: 'b', source: 'A.', target: 'At.', sourceMeta: { kind: 'paragraph', blockIndex: 0, sentenceIndex: 0 } }),
+ seg({
+ id: 's2',
+ index: 1,
+ blockId: 'b',
+ source: 'B.',
+ target: 'Bt.',
+ sourceMeta: { kind: 'paragraph', blockIndex: 0, sentenceIndex: 1 },
+ }),
+ seg({
+ id: 's1',
+ index: 0,
+ blockId: 'b',
+ source: 'A.',
+ target: 'At.',
+ sourceMeta: { kind: 'paragraph', blockIndex: 0, sentenceIndex: 0 },
+ }),
]
expect(renderDocument(blocks, segments)[0].targetText).toBe('At. Bt.')
})
it('maps table cells to segments in row-major order and carries image asset ids', () => {
const blocks = [
- block({ id: 't', index: 0, kind: 'table', attrs: { rows: [[[{ text: 'H' }], [{ text: '' }]], [[{ text: 'C' }]]] } }),
+ block({
+ id: 't',
+ index: 0,
+ kind: 'table',
+ attrs: { rows: [[[{ text: 'H' }], [{ text: '' }]], [[{ text: 'C' }]]] },
+ }),
block({ id: 'img', index: 1, kind: 'image', attrs: { assetId: 'a9' } }),
]
const segments = [
- seg({ id: 'c0', index: 0, blockId: 't', source: 'H', target: 'Ht', sourceMeta: { kind: 'paragraph', blockIndex: 0, sentenceIndex: 0 } }),
- seg({ id: 'c1', index: 1, blockId: 't', source: 'C', target: '', sourceMeta: { kind: 'paragraph', blockIndex: 0, sentenceIndex: 1 } }),
+ seg({
+ id: 'c0',
+ index: 0,
+ blockId: 't',
+ source: 'H',
+ target: 'Ht',
+ sourceMeta: { kind: 'paragraph', blockIndex: 0, sentenceIndex: 0 },
+ }),
+ seg({
+ id: 'c1',
+ index: 1,
+ blockId: 't',
+ source: 'C',
+ target: '',
+ sourceMeta: { kind: 'paragraph', blockIndex: 0, sentenceIndex: 1 },
+ }),
]
const model = renderDocument(blocks, segments)
const table = model[0]
diff --git a/tests/unit/documents.targetRuns.test.ts b/tests/unit/documents.targetRuns.test.ts
index e2e29ea..a498f28 100644
--- a/tests/unit/documents.targetRuns.test.ts
+++ b/tests/unit/documents.targetRuns.test.ts
@@ -28,8 +28,16 @@ describe('targetRichToRuns', () => {
it('applies accepted-preview to tracked changes (insertions kept, deletions dropped)', () => {
const json = state([
{ type: 'text', text: 'keep ', format: 0 },
- { type: 'change-mark', changeType: 'delete', children: [{ type: 'text', text: 'gone', format: 0 }] },
- { type: 'change-mark', changeType: 'insert', children: [{ type: 'text', text: 'added', format: 0 }] },
+ {
+ type: 'change-mark',
+ changeType: 'delete',
+ children: [{ type: 'text', text: 'gone', format: 0 }],
+ },
+ {
+ type: 'change-mark',
+ changeType: 'insert',
+ children: [{ type: 'text', text: 'added', format: 0 }],
+ },
])
expect(targetRichToRuns(json)).toEqual([{ text: 'keep added' }])
})
diff --git a/tests/unit/documents.toDocx.test.ts b/tests/unit/documents.toDocx.test.ts
index f7f4aa3..247b4fb 100644
--- a/tests/unit/documents.toDocx.test.ts
+++ b/tests/unit/documents.toDocx.test.ts
@@ -33,11 +33,29 @@ describe('exportProjectDocx', () => {
block({ id: 'p', index: 1, kind: 'paragraph' }),
]
const boldRich = JSON.stringify({
- root: { type: 'root', children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Salut.', format: 1 }] }] },
+ root: {
+ type: 'root',
+ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Salut.', format: 1 }] }],
+ },
})
const segments = [
- seg({ id: 's-h', index: 0, blockId: 'h', source: 'Title', target: 'Titre', sourceMeta: { kind: 'heading', blockIndex: 0, sentenceIndex: 0 } }),
- seg({ id: 's-p', index: 1, blockId: 'p', source: 'Hi.', target: 'Salut.', targetRich: boldRich, sourceMeta: { kind: 'paragraph', blockIndex: 1, sentenceIndex: 0 } }),
+ seg({
+ id: 's-h',
+ index: 0,
+ blockId: 'h',
+ source: 'Title',
+ target: 'Titre',
+ sourceMeta: { kind: 'heading', blockIndex: 0, sentenceIndex: 0 },
+ }),
+ seg({
+ id: 's-p',
+ index: 1,
+ blockId: 'p',
+ source: 'Hi.',
+ target: 'Salut.',
+ targetRich: boldRich,
+ sourceMeta: { kind: 'paragraph', blockIndex: 1, sentenceIndex: 0 },
+ }),
]
const blob = await exportProjectDocx(blocks, segments, [])
@@ -51,11 +69,30 @@ describe('exportProjectDocx', () => {
it('exports a table with translated cells', async () => {
const blocks = [
- block({ id: 't', index: 0, kind: 'table', attrs: { rows: [[[{ text: 'H' }], [{ text: 'X' }]]] } }),
+ block({
+ id: 't',
+ index: 0,
+ kind: 'table',
+ attrs: { rows: [[[{ text: 'H' }], [{ text: 'X' }]]] },
+ }),
]
const segments = [
- seg({ id: 'c0', index: 0, blockId: 't', source: 'H', target: 'Ht', sourceMeta: { kind: 'paragraph', blockIndex: 0, sentenceIndex: 0 } }),
- seg({ id: 'c1', index: 1, blockId: 't', source: 'X', target: 'Xt', sourceMeta: { kind: 'paragraph', blockIndex: 0, sentenceIndex: 1 } }),
+ seg({
+ id: 'c0',
+ index: 0,
+ blockId: 't',
+ source: 'H',
+ target: 'Ht',
+ sourceMeta: { kind: 'paragraph', blockIndex: 0, sentenceIndex: 0 },
+ }),
+ seg({
+ id: 'c1',
+ index: 1,
+ blockId: 't',
+ source: 'X',
+ target: 'Xt',
+ sourceMeta: { kind: 'paragraph', blockIndex: 0, sentenceIndex: 1 },
+ }),
]
const xml = await documentXml(await exportProjectDocx(blocks, segments, []))
expect(xml).toContain(' {
})
it('deleteByModel removes only matching records', async () => {
- await embeddingsRepo.bulkUpsert([
- makeRecord({ model: 'a' }),
- makeRecord({ model: 'b' }),
- ])
+ await embeddingsRepo.bulkUpsert([makeRecord({ model: 'a' }), makeRecord({ model: 'b' })])
await embeddingsRepo.deleteByModel('a')
expect(await embeddingsRepo.count('a')).toBe(0)
expect(await embeddingsRepo.count('b')).toBe(1)
diff --git a/tests/unit/glossary.csv.multiterm.test.ts b/tests/unit/glossary.csv.multiterm.test.ts
index bfe75b8..d0bc631 100644
--- a/tests/unit/glossary.csv.multiterm.test.ts
+++ b/tests/unit/glossary.csv.multiterm.test.ts
@@ -6,7 +6,9 @@ describe('parseCSV — MultiTerm-style locale headers', () => {
const text = `EN,DE,FR\nhello,hallo,bonjour\nworld,welt,monde`
const rows = parseCSV(text)
expect(rows).toHaveLength(4)
- expect(rows.map((r) => ({ term: r.term, targetLang: r.targetLang, translation: r.translation }))).toEqual([
+ expect(
+ rows.map((r) => ({ term: r.term, targetLang: r.targetLang, translation: r.translation })),
+ ).toEqual([
{ term: 'hello', targetLang: 'DE', translation: 'hallo' },
{ term: 'hello', targetLang: 'FR', translation: 'bonjour' },
{ term: 'world', targetLang: 'DE', translation: 'welt' },
diff --git a/tests/unit/glossary.csv.test.ts b/tests/unit/glossary.csv.test.ts
index 566f72e..6de1303 100644
--- a/tests/unit/glossary.csv.test.ts
+++ b/tests/unit/glossary.csv.test.ts
@@ -117,10 +117,7 @@ describe('exportCSV', () => {
}
it('writes one row per (entry, translation lang)', () => {
- const out = exportCSV(
- [entry({ translations: { es: 'hola', fr: 'bonjour' } })],
- 'en',
- )
+ const out = exportCSV([entry({ translations: { es: 'hola', fr: 'bonjour' } })], 'en')
const lines = out.trim().split('\n')
expect(lines[0]).toBe('term,definition,sourceLang,targetLang,translation,notes')
expect(lines).toHaveLength(3)
@@ -134,10 +131,7 @@ describe('exportCSV', () => {
})
it('quotes values containing commas, newlines, and quotes', () => {
- const out = exportCSV(
- [entry({ term: 'with, comma', definition: 'has "quotes"' })],
- 'en',
- )
+ const out = exportCSV([entry({ term: 'with, comma', definition: 'has "quotes"' })], 'en')
expect(out).toContain('"with, comma"')
expect(out).toContain('"has ""quotes"""')
})
diff --git a/tests/unit/glossary.match.test.ts b/tests/unit/glossary.match.test.ts
index 6477787..f696a6c 100644
--- a/tests/unit/glossary.match.test.ts
+++ b/tests/unit/glossary.match.test.ts
@@ -47,18 +47,14 @@ describe('findGlossaryHits', () => {
})
it('does not double-report the same entry when it appears twice', () => {
- const out = findGlossaryHits(
- 'hello hello hello',
- [entry({ id: '1', term: 'hello' })],
- { limit: 10 },
- )
+ const out = findGlossaryHits('hello hello hello', [entry({ id: '1', term: 'hello' })], {
+ limit: 10,
+ })
expect(out).toHaveLength(1)
})
it('respects the limit option', () => {
- const candidates = Array.from({ length: 5 }, (_, i) =>
- entry({ id: `e${i}`, term: `term${i}` }),
- )
+ const candidates = Array.from({ length: 5 }, (_, i) => entry({ id: `e${i}`, term: `term${i}` }))
const source = 'term0 term1 term2 term3 term4'
const out = findGlossaryHits(source, candidates, { limit: 3 })
expect(out).toHaveLength(3)
@@ -84,11 +80,7 @@ describe('findGlossaryHits', () => {
})
it('handles term with regex metacharacters safely', () => {
- const out = findGlossaryHits(
- 'use C++ here',
- [entry({ term: 'C++' })],
- { limit: 10 },
- )
+ const out = findGlossaryHits('use C++ here', [entry({ term: 'C++' })], { limit: 10 })
expect(out).toHaveLength(1)
})
})
diff --git a/tests/unit/glossary.tsv.test.ts b/tests/unit/glossary.tsv.test.ts
index 0b7be49..8e2ea82 100644
--- a/tests/unit/glossary.tsv.test.ts
+++ b/tests/unit/glossary.tsv.test.ts
@@ -71,9 +71,7 @@ describe('exportTSV', () => {
it('skips entries that have no translation in the chosen language', () => {
const out = exportTSV(
- [
- { id: 'a', term: 'hello', definition: '', translations: { fr: 'bonjour' } },
- ],
+ [{ id: 'a', term: 'hello', definition: '', translations: { fr: 'bonjour' } }],
'es',
)
expect(out).toBe('')
diff --git a/tests/unit/glossaryRepo.test.ts b/tests/unit/glossaryRepo.test.ts
index 9cf8c3d..fa36d41 100644
--- a/tests/unit/glossaryRepo.test.ts
+++ b/tests/unit/glossaryRepo.test.ts
@@ -37,11 +37,7 @@ describe('glossaryRepo', () => {
})
it('removeMany deletes by id list', async () => {
- const entries = [
- makeEntry({ term: 'a' }),
- makeEntry({ term: 'b' }),
- makeEntry({ term: 'c' }),
- ]
+ const entries = [makeEntry({ term: 'a' }), makeEntry({ term: 'b' }), makeEntry({ term: 'c' })]
await glossaryRepo.bulkAdd(entries)
await glossaryRepo.removeMany([entries[0].id, entries[2].id])
const left = await glossaryRepo.getAll()
diff --git a/tests/unit/history.diff.test.ts b/tests/unit/history.diff.test.ts
index 6a5161b..40bc9b2 100644
--- a/tests/unit/history.diff.test.ts
+++ b/tests/unit/history.diff.test.ts
@@ -10,13 +10,23 @@ describe('diffWords', () => {
const parts = diffWords('hello world', 'hello big world')
expect(parts.filter((p) => p.op === 'add').map((p) => p.text.trim())).toEqual(['big'])
// Reassembling the "after" side reproduces the new text.
- expect(parts.filter((p) => p.op !== 'remove').map((p) => p.text).join('')).toBe('hello big world')
+ expect(
+ parts
+ .filter((p) => p.op !== 'remove')
+ .map((p) => p.text)
+ .join(''),
+ ).toBe('hello big world')
})
it('detects a removed word', () => {
const parts = diffWords('hello big world', 'hello world')
expect(parts.filter((p) => p.op === 'remove').map((p) => p.text.trim())).toEqual(['big'])
- expect(parts.filter((p) => p.op !== 'add').map((p) => p.text).join('')).toBe('hello big world')
+ expect(
+ parts
+ .filter((p) => p.op !== 'add')
+ .map((p) => p.text)
+ .join(''),
+ ).toBe('hello big world')
})
it('handles a full replacement and empty sides', () => {
diff --git a/tests/unit/i18n.languages.test.ts b/tests/unit/i18n.languages.test.ts
index 1a4b81a..8ca9c7c 100644
--- a/tests/unit/i18n.languages.test.ts
+++ b/tests/unit/i18n.languages.test.ts
@@ -1,9 +1,5 @@
import { describe, expect, it } from 'vitest'
-import {
- baseLanguage,
- displayLanguage,
- LANGUAGE_OPTIONS,
-} from '@/core/i18n/languages'
+import { baseLanguage, displayLanguage, LANGUAGE_OPTIONS } from '@/core/i18n/languages'
describe('LANGUAGE_OPTIONS', () => {
it('includes regional variants for English and Portuguese', () => {
diff --git a/tests/unit/lookup.store.test.ts b/tests/unit/lookup.store.test.ts
new file mode 100644
index 0000000..26e70ac
--- /dev/null
+++ b/tests/unit/lookup.store.test.ts
@@ -0,0 +1,52 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { useQuickLookupStore } from '@/features/lookup/useQuickLookupStore'
+
+/**
+ * The quick-lookup store seeds its languages from (in precedence order) an
+ * explicit `openWith` argument, then the active project's languages published by
+ * the editor, then nothing (so the workspace falls back to the global lookup
+ * defaults). This proves that precedence.
+ */
+describe('useQuickLookupStore language seeding', () => {
+ beforeEach(() => {
+ useQuickLookupStore.setState({
+ open: false,
+ prefill: '',
+ sourceLang: undefined,
+ targetLang: undefined,
+ projectLangs: null,
+ })
+ })
+
+ it('leaves languages unset when no project langs and no explicit langs', () => {
+ useQuickLookupStore.getState().openWith('hello')
+ const s = useQuickLookupStore.getState()
+ expect(s.open).toBe(true)
+ expect(s.prefill).toBe('hello')
+ expect(s.sourceLang).toBeUndefined()
+ expect(s.targetLang).toBeUndefined()
+ })
+
+ it('uses the published project languages when present', () => {
+ useQuickLookupStore.getState().setProjectLangs({ source: 'pt', target: 'en' })
+ useQuickLookupStore.getState().openWith('termo')
+ const s = useQuickLookupStore.getState()
+ expect(s.sourceLang).toBe('pt')
+ expect(s.targetLang).toBe('en')
+ })
+
+ it('lets an explicit openWith argument win over the project languages', () => {
+ useQuickLookupStore.getState().setProjectLangs({ source: 'pt', target: 'en' })
+ useQuickLookupStore.getState().openWith('mot', { source: 'fr', target: 'de' })
+ const s = useQuickLookupStore.getState()
+ expect(s.sourceLang).toBe('fr')
+ expect(s.targetLang).toBe('de')
+ })
+
+ it('clears project languages when the editor unmounts', () => {
+ useQuickLookupStore.getState().setProjectLangs({ source: 'pt', target: 'en' })
+ useQuickLookupStore.getState().setProjectLangs(null)
+ useQuickLookupStore.getState().openWith('x')
+ expect(useQuickLookupStore.getState().sourceLang).toBeUndefined()
+ })
+})
diff --git a/tests/unit/mt.ollama.test.ts b/tests/unit/mt.ollama.test.ts
index e715b54..a32a68f 100644
--- a/tests/unit/mt.ollama.test.ts
+++ b/tests/unit/mt.ollama.test.ts
@@ -48,10 +48,7 @@ describe('ollamaProvider.translate', () => {
it('throws invalid on empty source', async () => {
await expect(
- ollamaProvider.translate(
- { text: ' ', sourceLang: 'en', targetLang: 'es' },
- settings,
- ),
+ ollamaProvider.translate({ text: ' ', sourceLang: 'en', targetLang: 'es' }, settings),
).rejects.toMatchObject({ code: 'invalid' })
})
@@ -67,7 +64,9 @@ describe('ollamaProvider.translate', () => {
})
it('returns aborted on AbortError', async () => {
- const fetchImpl = vi.fn().mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' }))
+ const fetchImpl = vi
+ .fn()
+ .mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' }))
await expect(
ollamaProvider.translate(
{ text: 'hi', sourceLang: 'en', targetLang: 'es' },
@@ -93,7 +92,9 @@ describe('ollamaProvider.translate', () => {
})
it('throws network on non-OK status', async () => {
- const fetchImpl = vi.fn().mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) })
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) })
await expect(
ollamaProvider.translate(
{ text: 'hi', sourceLang: 'en', targetLang: 'es' },
diff --git a/tests/unit/numbers.test.ts b/tests/unit/numbers.test.ts
index 0ebfd59..b09d563 100644
--- a/tests/unit/numbers.test.ts
+++ b/tests/unit/numbers.test.ts
@@ -57,7 +57,9 @@ describe('sameNumberMultiset', () => {
expect(sameNumberMultiset(['50', '3'], ['3', '50'])).toBe(true)
})
it('detects a swapped value (memoQ 7676-instead-of-50 case)', () => {
- expect(sameNumberMultiset(extractNumbers('total 50'), extractNumbers('المجموع ٧٦٧٦'))).toBe(false)
+ expect(sameNumberMultiset(extractNumbers('total 50'), extractNumbers('المجموع ٧٦٧٦'))).toBe(
+ false,
+ )
})
it('treats Latin and Arabic-Indic of the same value as equal', () => {
expect(sameNumberMultiset(extractNumbers('50'), extractNumbers('٥٠'))).toBe(true)
diff --git a/tests/unit/projectRepo.test.ts b/tests/unit/projectRepo.test.ts
index a605437..1b07c72 100644
--- a/tests/unit/projectRepo.test.ts
+++ b/tests/unit/projectRepo.test.ts
@@ -86,7 +86,13 @@ describe('projectRepo.removeCascade', () => {
await projectRepo.create(p)
await db.segments.bulkAdd([makeSeg(p.id), makeSeg(p.id)])
await db.tm.add({
- id: 't1', source: 'a', target: 'b', sourceLang: 'en', targetLang: 'es', projectId: p.id, date: '2024-01-01',
+ id: 't1',
+ source: 'a',
+ target: 'b',
+ sourceLang: 'en',
+ targetLang: 'es',
+ projectId: p.id,
+ date: '2024-01-01',
} as TMEntry)
await projectRepo.removeCascade(p.id)
@@ -100,11 +106,28 @@ describe('projectRepo.removeCascade', () => {
const p = makeProject()
await projectRepo.create(p)
await db.tm.add({
- id: 't1', source: 'a', target: 'b', sourceLang: 'en', targetLang: 'es', projectId: p.id, date: '2024-01-01',
+ id: 't1',
+ source: 'a',
+ target: 'b',
+ sourceLang: 'en',
+ targetLang: 'es',
+ projectId: p.id,
+ date: '2024-01-01',
} as TMEntry)
- await db.glossary.add({ id: 'g1', term: 'x', definition: '', translations: {}, projectId: p.id } as GlossaryEntry)
+ await db.glossary.add({
+ id: 'g1',
+ term: 'x',
+ definition: '',
+ translations: {},
+ projectId: p.id,
+ } as GlossaryEntry)
await db.embeddings.add({
- id: 'e1', tmId: 't1', model: 'm', dim: 1, vector: new Float32Array([1]), createdAt: '2024-01-01',
+ id: 'e1',
+ tmId: 't1',
+ model: 'm',
+ dim: 1,
+ vector: new Float32Array([1]),
+ createdAt: '2024-01-01',
} as EmbeddingRecord)
await projectRepo.removeCascade(p.id, { deleteResources: true })
@@ -118,7 +141,12 @@ describe('projectRepo.removeCascade', () => {
const p = makeProject()
await projectRepo.create(p)
await db.tm.add({
- id: 'global', source: 'a', target: 'b', sourceLang: 'en', targetLang: 'es', date: '2024-01-01',
+ id: 'global',
+ source: 'a',
+ target: 'b',
+ sourceLang: 'en',
+ targetLang: 'es',
+ date: '2024-01-01',
} as TMEntry)
await projectRepo.removeCascade(p.id, { deleteResources: true })
expect(await db.tm.count()).toBe(1)
@@ -141,7 +169,10 @@ describe('projectRepo.duplicate', () => {
const clone = await projectRepo.getById(newId as string)
expect(clone?.name).toBe('Original (copy)')
- const cloneSegs = await db.segments.where('projectId').equals(newId as string).sortBy('index')
+ const cloneSegs = await db.segments
+ .where('projectId')
+ .equals(newId as string)
+ .sortBy('index')
expect(cloneSegs.map((s) => [s.source, s.target])).toEqual([
['a', 'A'],
['b', ''],
diff --git a/tests/unit/qa.checks.test.ts b/tests/unit/qa.checks.test.ts
index 517bcbf..d19f536 100644
--- a/tests/unit/qa.checks.test.ts
+++ b/tests/unit/qa.checks.test.ts
@@ -99,8 +99,12 @@ describe('runQA', () => {
it('allows a space before ; : ? ! for French targets but still flags the comma', () => {
const fr = { targetLang: 'fr-FR' }
- expect(runQA([seg('a', 'Bonjour ; toi')], [], fr).some((i) => i.code === 'space_before_punct')).toBe(false)
- expect(runQA([seg('a', 'Bonjour ,toi')], [], fr).some((i) => i.code === 'space_before_punct')).toBe(true)
+ expect(
+ runQA([seg('a', 'Bonjour ; toi')], [], fr).some((i) => i.code === 'space_before_punct'),
+ ).toBe(false)
+ expect(
+ runQA([seg('a', 'Bonjour ,toi')], [], fr).some((i) => i.code === 'space_before_punct'),
+ ).toBe(true)
})
it('flags a back-to-back repeated word', () => {
@@ -115,7 +119,9 @@ describe('runQA', () => {
it('flags straight quotes only when the rule is enabled', () => {
const target = 'she said "hi"'
- expect(runQA([seg('a', target)], [], OPTS).some((i) => i.code === 'straight_quotes')).toBe(false)
+ expect(runQA([seg('a', target)], [], OPTS).some((i) => i.code === 'straight_quotes')).toBe(
+ false,
+ )
expect(
runQA([seg('a', target)], [], { ...OPTS, rules: { straight_quotes: true } }).some(
(i) => i.code === 'straight_quotes',
diff --git a/tests/unit/qa.registryRules.test.ts b/tests/unit/qa.registryRules.test.ts
index 6f4f5c8..f36e57f 100644
--- a/tests/unit/qa.registryRules.test.ts
+++ b/tests/unit/qa.registryRules.test.ts
@@ -47,7 +47,10 @@ describe('effectiveQaRules (registry-gated QA, §6.2)', () => {
const segments = [seg('hello world')] // double space
// Enabled: the double_space rule fires.
- let issues = runQA(segments, [], { targetLang: 'en', rules: effectiveQaRules(DEFAULT_QA_RULES) })
+ let issues = runQA(segments, [], {
+ targetLang: 'en',
+ rules: effectiveQaRules(DEFAULT_QA_RULES),
+ })
expect(issues.some((i) => i.code === 'double_space')).toBe(true)
// Disable the addon → the finding is gone, others unaffected.
diff --git a/tests/unit/richText.test.ts b/tests/unit/richText.test.ts
index ce6283e..7616c40 100644
--- a/tests/unit/richText.test.ts
+++ b/tests/unit/richText.test.ts
@@ -1,10 +1,5 @@
import { describe, expect, it } from 'vitest'
-import {
- createEditor,
- $getRoot,
- $createParagraphNode,
- $createTextNode,
-} from 'lexical'
+import { createEditor, $getRoot, $createParagraphNode, $createTextNode } from 'lexical'
import {
richStateToPlain,
richStateToOriginal,
@@ -173,7 +168,12 @@ describe('inline tags: XLIFF import → rich edit → export', () => {
expect(derivedPlain).toBe('Clique {1} para continuar{2}')
const out = exportXliff12(parsed.templateXml, [
- { transUnitId: unit.transUnitId, target: derivedPlain, status: 'translated', inlineTags: unit.inlineTags },
+ {
+ transUnitId: unit.transUnitId,
+ target: derivedPlain,
+ status: 'translated',
+ inlineTags: unit.inlineTags,
+ },
])
expect(out).toContain('here ')
expect(out).toContain(' ')
diff --git a/tests/unit/segmentCrdt.test.ts b/tests/unit/segmentCrdt.test.ts
index b79ba13..5a793ff 100644
--- a/tests/unit/segmentCrdt.test.ts
+++ b/tests/unit/segmentCrdt.test.ts
@@ -80,7 +80,13 @@ describe('segmentCrdt model', () => {
applyUpdate(
doc,
- makeSeg({ id: 's1', target: 'novo', status: 'translated', targetRich: undefined, locked: undefined }),
+ makeSeg({
+ id: 's1',
+ target: 'novo',
+ status: 'translated',
+ targetRich: undefined,
+ locked: undefined,
+ }),
)
const out = readSegment(doc, 'p1', 's1')!
@@ -128,7 +134,11 @@ describe('segmentCrdt model', () => {
it('seedFromDexie populates an empty doc once and marks it seeded', () => {
const doc = new Y.Doc()
expect(isSeeded(doc)).toBe(false)
- const segs = [makeSeg({ id: 'a', index: 2 }), makeSeg({ id: 'b', index: 0 }), makeSeg({ id: 'c', index: 1 })]
+ const segs = [
+ makeSeg({ id: 'a', index: 2 }),
+ makeSeg({ id: 'b', index: 0 }),
+ makeSeg({ id: 'c', index: 1 }),
+ ]
seedFromDexie(doc, 'p1', segs)
expect(isSeeded(doc)).toBe(true)
expect(readAllSegments(doc, 'p1').map((s) => s.id)).toEqual(['b', 'c', 'a'])
diff --git a/tests/unit/segmentRepo.test.ts b/tests/unit/segmentRepo.test.ts
index 40a7ecb..59e425f 100644
--- a/tests/unit/segmentRepo.test.ts
+++ b/tests/unit/segmentRepo.test.ts
@@ -231,8 +231,20 @@ describe('segmentRepo', () => {
it('joinWithNext merges sources, targets and re-indexes', async () => {
const meta = { kind: 'paragraph' as const, blockIndex: 0, sentenceIndex: 0 }
await segmentRepo.bulkCreate([
- makeSeg({ index: 0, source: 'One.', target: 'Um.', status: 'translated', sourceMeta: { ...meta, sentenceIndex: 0 } }),
- makeSeg({ index: 1, source: 'Two.', target: '', status: 'untranslated', sourceMeta: { ...meta, sentenceIndex: 1 } }),
+ makeSeg({
+ index: 0,
+ source: 'One.',
+ target: 'Um.',
+ status: 'translated',
+ sourceMeta: { ...meta, sentenceIndex: 0 },
+ }),
+ makeSeg({
+ index: 1,
+ source: 'Two.',
+ target: '',
+ status: 'untranslated',
+ sourceMeta: { ...meta, sentenceIndex: 1 },
+ }),
makeSeg({ index: 2, source: 'Three.', sourceMeta: { ...meta, sentenceIndex: 2 } }),
])
const rows = await segmentRepo.byProject('p1')
@@ -250,8 +262,16 @@ describe('segmentRepo', () => {
it('joinWithNext refuses to join across source blocks', async () => {
await segmentRepo.bulkCreate([
- makeSeg({ index: 0, source: 'Heading', sourceMeta: { kind: 'heading', blockIndex: 0, sentenceIndex: 0 } }),
- makeSeg({ index: 1, source: 'Body', sourceMeta: { kind: 'paragraph', blockIndex: 1, sentenceIndex: 0 } }),
+ makeSeg({
+ index: 0,
+ source: 'Heading',
+ sourceMeta: { kind: 'heading', blockIndex: 0, sentenceIndex: 0 },
+ }),
+ makeSeg({
+ index: 1,
+ source: 'Body',
+ sourceMeta: { kind: 'paragraph', blockIndex: 1, sentenceIndex: 0 },
+ }),
])
const rows = await segmentRepo.byProject('p1')
expect(await segmentRepo.joinWithNext('p1', rows[0].id)).toBe(false)
diff --git a/tests/unit/segmentation.docx.test.ts b/tests/unit/segmentation.docx.test.ts
index 3eafd17..e8296c9 100644
--- a/tests/unit/segmentation.docx.test.ts
+++ b/tests/unit/segmentation.docx.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
-const convertToHtml = vi.fn<(args: { arrayBuffer: ArrayBuffer }) => Promise<{ value: string; messages: unknown[] }>>()
+const convertToHtml =
+ vi.fn<(args: { arrayBuffer: ArrayBuffer }) => Promise<{ value: string; messages: unknown[] }>>()
vi.mock('mammoth', () => ({
default: {
@@ -44,11 +45,7 @@ describe('segmentDocx', () => {
it('splits paragraphs into sentences and tracks blockIndex', async () => {
mockHtml('First sentence. Second sentence.
Para two.
')
const out = await segmentDocx(EMPTY_BUFFER)
- expect(out.map((s) => s.source)).toEqual([
- 'First sentence.',
- 'Second sentence.',
- 'Para two.',
- ])
+ expect(out.map((s) => s.source)).toEqual(['First sentence.', 'Second sentence.', 'Para two.'])
expect(out.map((s) => s.sourceMeta.blockIndex)).toEqual([0, 0, 1])
expect(out.map((s) => s.sourceMeta.sentenceIndex)).toEqual([0, 1, 0])
expect(out.every((s) => s.sourceMeta.kind === 'paragraph')).toBe(true)
diff --git a/tests/unit/segmentation.test.ts b/tests/unit/segmentation.test.ts
index c51af96..1f7766e 100644
--- a/tests/unit/segmentation.test.ts
+++ b/tests/unit/segmentation.test.ts
@@ -34,11 +34,7 @@ describe('segment TXT', () => {
it('splits paragraph into multiple sentences', () => {
const out = segment('First sentence. Second sentence. Third one.', 'txt')
expect(out).toHaveLength(3)
- expect(out.map((s) => s.source)).toEqual([
- 'First sentence.',
- 'Second sentence.',
- 'Third one.',
- ])
+ expect(out.map((s) => s.source)).toEqual(['First sentence.', 'Second sentence.', 'Third one.'])
expect(out.map((s) => s.sourceMeta.sentenceIndex)).toEqual([0, 1, 2])
expect(out.every((s) => s.sourceMeta.blockIndex === 0)).toBe(true)
})
diff --git a/tests/unit/segments.test.ts b/tests/unit/segments.test.ts
index a819a5a..89dbc63 100644
--- a/tests/unit/segments.test.ts
+++ b/tests/unit/segments.test.ts
@@ -3,7 +3,12 @@ import { groupByNormalizedSource, repeatedIndices } from '@/core/segments/repeti
import { buildPattern, previewReplace } from '@/core/segments/findReplace'
import type { Segment, SegmentStatus } from '@/core/types'
-function seg(index: number, source: string, target = '', status: SegmentStatus = 'translated'): Segment {
+function seg(
+ index: number,
+ source: string,
+ target = '',
+ status: SegmentStatus = 'translated',
+): Segment {
return {
id: `s${index}`,
projectId: 'p1',
@@ -38,7 +43,11 @@ describe('find & replace', () => {
})
it('honors whole-word matching', () => {
- const rows = previewReplace([seg(0, 'x', 'color and colorful')], { find: 'color', wholeWord: true }, 'colour')
+ const rows = previewReplace(
+ [seg(0, 'x', 'color and colorful')],
+ { find: 'color', wholeWord: true },
+ 'colour',
+ )
expect(rows[0].after).toBe('colour and colorful')
expect(rows[0].count).toBe(1)
})
@@ -46,7 +55,8 @@ describe('find & replace', () => {
it('is case-insensitive by default and case-sensitive on request', () => {
expect(previewReplace([seg(0, 'x', 'Color color')], { find: 'color' }, 'X')[0].count).toBe(2)
expect(
- previewReplace([seg(0, 'x', 'Color color')], { find: 'color', caseSensitive: true }, 'X')[0].count,
+ previewReplace([seg(0, 'x', 'Color color')], { find: 'color', caseSensitive: true }, 'X')[0]
+ .count,
).toBe(1)
})
diff --git a/tests/unit/settings.about.test.tsx b/tests/unit/settings.about.test.tsx
index fd37df8..56ca836 100644
--- a/tests/unit/settings.about.test.tsx
+++ b/tests/unit/settings.about.test.tsx
@@ -12,9 +12,7 @@ describe('SettingsPage About section', () => {
openAbout()
expect(screen.getByTestId('settings-version')).toHaveTextContent('0.0.0-test')
expect(screen.getByTestId('settings-build-sha')).toHaveTextContent('testsha')
- expect(screen.getByTestId('settings-build-time')).toHaveTextContent(
- '2026-01-01T00:00:00.000Z',
- )
+ expect(screen.getByTestId('settings-build-time')).toHaveTextContent('2026-01-01T00:00:00.000Z')
})
it('includes the offline copy', () => {
diff --git a/tests/unit/sync.chunking.test.ts b/tests/unit/sync.chunking.test.ts
index f653622..d2fbd04 100644
--- a/tests/unit/sync.chunking.test.ts
+++ b/tests/unit/sync.chunking.test.ts
@@ -70,8 +70,14 @@ describe('encodeMessage + ChunkReassembler', () => {
// Interleave a[0], b[0], a[1], b[1], …
const max = Math.max(a.length, b.length)
for (let i = 0; i < max; i++) {
- if (a[i]) { const m = r.push(a[i]); if (m) done.push(m) }
- if (b[i]) { const m = r.push(b[i]); if (m) done.push(m) }
+ if (a[i]) {
+ const m = r.push(a[i])
+ if (m) done.push(m)
+ }
+ if (b[i]) {
+ const m = r.push(b[i])
+ if (m) done.push(m)
+ }
}
expect(done).toHaveLength(2)
const byPeer = Object.fromEntries(
diff --git a/tests/unit/sync.presence.test.ts b/tests/unit/sync.presence.test.ts
index 230bb24..53f43a0 100644
--- a/tests/unit/sync.presence.test.ts
+++ b/tests/unit/sync.presence.test.ts
@@ -43,7 +43,13 @@ describe('presence tracker (F3)', () => {
it('carries the stable userId and caret through upsert (§4.4)', () => {
const t = new PresenceTracker('self')
t.upsert(
- { peerId: 'a', displayName: 'Ana', userId: 'user-9', activeSegmentId: 's1', caret: { anchor: 2, focus: 5 } },
+ {
+ peerId: 'a',
+ displayName: 'Ana',
+ userId: 'user-9',
+ activeSegmentId: 's1',
+ caret: { anchor: 2, focus: 5 },
+ },
1000,
)
const [peer] = t.list(1000)
diff --git a/tests/unit/sync.reverseBridge.test.ts b/tests/unit/sync.reverseBridge.test.ts
index d21f4b8..dbdf2ae 100644
--- a/tests/unit/sync.reverseBridge.test.ts
+++ b/tests/unit/sync.reverseBridge.test.ts
@@ -5,18 +5,18 @@ import { createEditor, $getRoot, $createParagraphNode, $createTextNode } from 'l
import { db } from '@/storage/db'
import { segmentRepo } from '@/storage/repositories/segmentRepo'
import { RICH_NAMESPACE, getRichNodes, richStateToPlain } from '@/core/editor/richText'
-import {
- __resetBridgeForTest,
- installSegmentBridge,
- setDocResolver,
-} from '@/storage/sync/bridge'
+import { __resetBridgeForTest, installSegmentBridge, setDocResolver } from '@/storage/sync/bridge'
import { installReverseBridge } from '@/storage/sync/reverseBridge'
import { ORIGIN_REMOTE, getSegmentsMap, seedFromDexie } from '@/storage/sync/segmentCrdt'
import type { Segment } from '@/core/types'
/** A valid serialized Lexical target state whose plain derivation is `text`. */
function richFor(text: string): string {
- const editor = createEditor({ namespace: RICH_NAMESPACE, nodes: getRichNodes(), onError: () => {} })
+ const editor = createEditor({
+ namespace: RICH_NAMESPACE,
+ nodes: getRichNodes(),
+ onError: () => {},
+ })
editor.update(
() => {
const p = $createParagraphNode()
@@ -66,7 +66,9 @@ describe('Yjs→Dexie reverse bridge (F3)', () => {
const detach = installReverseBridge(local, 'p1')
const remote = new Y.Doc()
- seedFromDexie(remote, 'p1', [makeSeg({ id: 's1', source: 'hello', target: 'olá', status: 'translated' })])
+ seedFromDexie(remote, 'p1', [
+ makeSeg({ id: 's1', source: 'hello', target: 'olá', status: 'translated' }),
+ ])
Y.applyUpdate(local, Y.encodeStateAsUpdate(remote), ORIGIN_REMOTE)
await tick()
@@ -141,7 +143,9 @@ describe('Yjs→Dexie reverse bridge (F3)', () => {
it('does not loop: the reverse write is mirror-suppressed', async () => {
const detach = installReverseBridge(local, 'p1')
const remote = new Y.Doc()
- seedFromDexie(remote, 'p1', [makeSeg({ id: 's1', target: 'x', updatedAt: '2020-01-01T00:00:00.000Z' })])
+ seedFromDexie(remote, 'p1', [
+ makeSeg({ id: 's1', target: 'x', updatedAt: '2020-01-01T00:00:00.000Z' }),
+ ])
Y.applyUpdate(local, Y.encodeStateAsUpdate(remote), ORIGIN_REMOTE)
await tick()
diff --git a/tests/unit/tm.concordance.test.ts b/tests/unit/tm.concordance.test.ts
index b10a7b7..7d751da 100644
--- a/tests/unit/tm.concordance.test.ts
+++ b/tests/unit/tm.concordance.test.ts
@@ -3,7 +3,14 @@ import { concordance } from '@/core/tm/concordance'
import type { TMEntry } from '@/core/types'
function tm(id: string, source: string, target: string): TMEntry {
- return { id, source, target, sourceLang: 'en', targetLang: 'es', date: '2024-01-01T00:00:00.000Z' }
+ return {
+ id,
+ source,
+ target,
+ sourceLang: 'en',
+ targetLang: 'es',
+ date: '2024-01-01T00:00:00.000Z',
+ }
}
const entries = [
diff --git a/tests/unit/tm.match.test.ts b/tests/unit/tm.match.test.ts
index ea32cb7..125a3b0 100644
--- a/tests/unit/tm.match.test.ts
+++ b/tests/unit/tm.match.test.ts
@@ -48,9 +48,7 @@ describe('findMatches', () => {
})
it('respects the limit', () => {
- const candidates = Array.from({ length: 10 }, (_, i) =>
- entry(`c${i}`, `hello world ${i}`),
- )
+ const candidates = Array.from({ length: 10 }, (_, i) => entry(`c${i}`, `hello world ${i}`))
const out = findMatches('hello world', candidates, { threshold: 0.5, limit: 3 })
expect(out).toHaveLength(3)
})
diff --git a/tests/unit/tm.semantic.test.ts b/tests/unit/tm.semantic.test.ts
index cb62f8f..aef7b8b 100644
--- a/tests/unit/tm.semantic.test.ts
+++ b/tests/unit/tm.semantic.test.ts
@@ -25,19 +25,31 @@ const MODEL = 'test-model'
describe('findSemanticMatches', () => {
it('returns empty when query is blank', async () => {
- const out = await findSemanticMatches(' ', MODEL, [entry({})], { topK: 5, threshold: 0.5 }, {
- embedAndRank: async () => [],
- })
+ const out = await findSemanticMatches(
+ ' ',
+ MODEL,
+ [entry({})],
+ { topK: 5, threshold: 0.5 },
+ {
+ embedAndRank: async () => [],
+ },
+ )
expect(out).toEqual([])
})
it('returns empty when no candidates have embeddings', async () => {
const candidates = [entry({ source: 'foo' }), entry({ source: 'bar' })]
- const out = await findSemanticMatches('q', MODEL, candidates, { topK: 5, threshold: 0.5 }, {
- embedAndRank: async () => {
- throw new Error('should not be called')
+ const out = await findSemanticMatches(
+ 'q',
+ MODEL,
+ candidates,
+ { topK: 5, threshold: 0.5 },
+ {
+ embedAndRank: async () => {
+ throw new Error('should not be called')
+ },
},
- })
+ )
expect(out).toEqual([])
})
diff --git a/tests/unit/tmRepo.test.ts b/tests/unit/tmRepo.test.ts
index 50be915..20f9e44 100644
--- a/tests/unit/tmRepo.test.ts
+++ b/tests/unit/tmRepo.test.ts
@@ -81,7 +81,11 @@ describe('tmRepo', () => {
})
it('removeMany deletes by id list', async () => {
- const entries = [makeEntry({ source: 'a' }), makeEntry({ source: 'b' }), makeEntry({ source: 'c' })]
+ const entries = [
+ makeEntry({ source: 'a' }),
+ makeEntry({ source: 'b' }),
+ makeEntry({ source: 'c' }),
+ ]
await tmRepo.bulkAdd(entries)
await tmRepo.removeMany([entries[0].id, entries[2].id])
const left = await db.tm.toArray()
diff --git a/tests/unit/useNetworkStatus.test.ts b/tests/unit/useNetworkStatus.test.ts
index 8186a06..86eae81 100644
--- a/tests/unit/useNetworkStatus.test.ts
+++ b/tests/unit/useNetworkStatus.test.ts
@@ -13,10 +13,7 @@ describe('useNetworkStatus', () => {
let originalDescriptor: PropertyDescriptor | undefined
beforeEach(() => {
- originalDescriptor = Object.getOwnPropertyDescriptor(
- Object.getPrototypeOf(navigator),
- 'onLine',
- )
+ originalDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(navigator), 'onLine')
setOnline(true)
})
diff --git a/tests/unit/versionRepo.changes.test.ts b/tests/unit/versionRepo.changes.test.ts
index c5e0308..ce3ff80 100644
--- a/tests/unit/versionRepo.changes.test.ts
+++ b/tests/unit/versionRepo.changes.test.ts
@@ -5,7 +5,11 @@ import { db } from '@/storage/db'
import { segmentRepo } from '@/storage/repositories/segmentRepo'
import { versionRepo } from '@/storage/repositories/versionRepo'
import { installSegmentBridge, setDocResolver } from '@/storage/sync/bridge'
-import { getProfileSettings, settingsRepo, PROFILE_SETTINGS_KEY } from '@/storage/repositories/settingsRepo'
+import {
+ getProfileSettings,
+ settingsRepo,
+ PROFILE_SETTINGS_KEY,
+} from '@/storage/repositories/settingsRepo'
import type { Segment } from '@/core/types'
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
diff --git a/tests/unit/versionRepo.test.ts b/tests/unit/versionRepo.test.ts
index 384967f..a57da50 100644
--- a/tests/unit/versionRepo.test.ts
+++ b/tests/unit/versionRepo.test.ts
@@ -76,7 +76,11 @@ describe('versionRepo', () => {
await segmentRepo.bulkCreate([makeSeg({ id: 's1', target: 'v1text', status: 'draft' })])
const v1 = await versionRepo.saveNamed('p1', 'before rich', doc)
- await segmentRepo.update('s1', { target: 'richtext', targetRich: '{"root":{}}', status: 'translated' })
+ await segmentRepo.update('s1', {
+ target: 'richtext',
+ targetRich: '{"root":{}}',
+ status: 'translated',
+ })
expect((await segmentRepo.getById('s1'))?.targetRich).toBeDefined()
const ok = await versionRepo.restoreSegment('p1', 's1', v1!.id)
@@ -146,7 +150,17 @@ describe('versionRepo', () => {
root: {
children: [
{
- children: [{ detail: 0, format: 0, mode: 'normal', style: '', text: 'rico', type: 'text', version: 1 }],
+ children: [
+ {
+ detail: 0,
+ format: 0,
+ mode: 'normal',
+ style: '',
+ text: 'rico',
+ type: 'text',
+ version: 1,
+ },
+ ],
direction: 'ltr',
format: '',
indent: 0,
diff --git a/tests/unit/workflow.rules.test.ts b/tests/unit/workflow.rules.test.ts
index 382058e..4d9fee4 100644
--- a/tests/unit/workflow.rules.test.ts
+++ b/tests/unit/workflow.rules.test.ts
@@ -46,7 +46,12 @@ describe('workflowCapabilities — role × stage matrix (§5.2)', () => {
expect(t).toMatchObject({ canEditDirect: false, mustSuggest: false, canResolveChanges: true })
const r = caps('revisor', 'review')
- expect(r).toMatchObject({ canEditDirect: true, mustSuggest: false, canResolveChanges: true, canReview: true })
+ expect(r).toMatchObject({
+ canEditDirect: true,
+ mustSuggest: false,
+ canResolveChanges: true,
+ canReview: true,
+ })
expect(r.canManage).toBe(false)
})
@@ -70,12 +75,23 @@ describe('effectiveRoleStage — local-only is PM-of-self', () => {
it('grants a manager in translation with no cloud link (zero regression)', () => {
expect(effectiveRoleStage(undefined)).toEqual({ role: 'project_manager', stage: 'translation' })
const c = workflowCapabilities('project_manager', 'translation', 'draft')
- expect(c).toMatchObject({ canEditDirect: true, canResolveChanges: true, canManage: true, mustSuggest: false })
+ expect(c).toMatchObject({
+ canEditDirect: true,
+ canResolveChanges: true,
+ canManage: true,
+ mustSuggest: false,
+ })
})
it('reads a cloud project’s role and defaults the stage to translation', () => {
- expect(effectiveRoleStage({ role: 'translator' })).toEqual({ role: 'translator', stage: 'translation' })
- expect(effectiveRoleStage({ role: 'revisor', stage: 'review' })).toEqual({ role: 'revisor', stage: 'review' })
+ expect(effectiveRoleStage({ role: 'translator' })).toEqual({
+ role: 'translator',
+ stage: 'translation',
+ })
+ expect(effectiveRoleStage({ role: 'revisor', stage: 'review' })).toEqual({
+ role: 'revisor',
+ stage: 'review',
+ })
})
})
diff --git a/tsconfig.json b/tsconfig.json
index 1ffef60..d32ff68 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,4 @@
{
"files": [],
- "references": [
- { "path": "./tsconfig.app.json" },
- { "path": "./tsconfig.node.json" }
- ]
+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
diff --git a/vite.config.ts b/vite.config.ts
index d236184..9c7841b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -47,8 +47,18 @@ export default defineConfig({
icons: [
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
- { src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
- { src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
+ {
+ src: 'icons/icon-maskable-192.png',
+ sizes: '192x192',
+ type: 'image/png',
+ purpose: 'maskable',
+ },
+ {
+ src: 'icons/icon-maskable-512.png',
+ sizes: '512x512',
+ type: 'image/png',
+ purpose: 'maskable',
+ },
],
},
workbox: {