VERBALIS is a local-first, browser-native CAT (Computer-Assisted Translation) tool. It runs entirely as a static site with no server, no backend, and no account requirement. User data stays in IndexedDB on the device.
| 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 |
src/
├── app/ — Router, providers, root component
├── components/
│ ├── ui/ — shadcn primitives (owned, not a dependency)
│ └── layout/ — AppShell, Sidebar, TopBar
├── features/ — One directory per domain feature
├── core/
│ ├── types/ — Shared TypeScript interfaces
│ └── ... — Segmentation, parsing, shortcuts logic
├── storage/
│ ├── db.ts — Dexie instance (single source of truth)
│ └── repositories/ — Data access layer
└── workers/ — Web Workers via Comlink
| 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.
The v2 "Translation IDE" revamp (tracked changes, comments, accounts, real-time collaboration, roles, extensions and connectors) is delivered on top of this foundation and recorded milestone-by-milestone in
docs/revamp/STATUS.mdanddocs/revamp/ROADMAP.md; the optional cloud layer is documented indocs/cloud.md.
Static build → GitHub Actions → GitHub Pages at https://verbalis.britx.me/.
Critical GH Pages constraints:
base: '/'in vite.config.ts, overridable via theBASE_PATHenv var (e.g.BASE_PATH=/verbalis/ pnpm build) for anyone forking this project into a path-based GitHub Pages deployment insteadHashRouter(not BrowserRouter) — no server-side routing on GH Pages- PWA
start_url: "."andscope: "."— relative paths required, so they resolve correctly under either a root or nested base
A global CommandPalette (cmdk-based) is mounted in src/app/App.tsx and opened with Ctrl+K / ⌘K. It exposes navigation, theme toggle, the global Import dialog, and — when on /project/:id — editor actions: toggle review mode, mark current segment reviewed, jump-to-status, status filter. Two small Zustand stores back it: useCommandPaletteStore (open state) and useEditorActionsStore (the editor exposes its current actions here so the palette can call into it without prop drilling). Editor-mode state (reviewMode, statusFilter) lives in useEditorModeStore. Reviewer keystroke: Ctrl+Shift+Enter on a segment toggles between translated and reviewed. Ctrl+Shift+R toggles review mode globally.
DOCX import uses mammoth.convertToHtml, then a small DOM walker (src/core/segmentation/docx.ts) maps the HTML tree back to the same ParsedSegment shape used for TXT/MD. The walker stays on the main thread because mammoth depends on JSZip + DOMParser; the existing parsing worker remains TXT/MD-only.
| 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 |
Verbalis is installable and works fully offline once the service worker has cached the shell. Phase 5 turns three latent stubs into real behaviour and hardens the one network-dependent feature (Wiktionary).
- Update notification (prompt mode).
vite-plugin-pwais configured withregisterType: 'prompt'so a new build is not auto-applied —src/pwa/register.tswiresonNeedRefreshinto a Zustand store (src/pwa/usePwaStore.ts) andsrc/pwa/UpdateBanner.tsxrenders a fixed banner with "Reload" and "Later". Reload calls theupdateSW(true)function returned byregisterSW, which triggersskipWaiting+ page reload. This keeps in-flight textarea edits safe. - First-run offline-ready toast.
onOfflineReadyflips the same store;src/pwa/OfflineReadyToast.tsxshows a one-shot "ready to work offline" toast gated bylocalStorage(verbalis.pwa.offlineReadyAck). The ack key is cleared wheneveronNeedRefreshfires so a post-update install re-confirms. - Online/offline awareness.
src/hooks/useNetworkStatus.tssubscribes to windowonline/offlineand seeds fromnavigator.onLine.src/components/layout/OfflineBadge.tsxrenders a small "Offline" pill in the TopBar when offline.WiktionaryLookupuses it to gate the Look-up button on either being online or having an in-memory cache hit, and translatesWiktionaryError('network')into a clear offline message. - Wiktionary runtime cache.
vite.config.tsadds two WorkboxruntimeCachingrules (StaleWhileRevalidate, max 100 entries / 30 days) — one for the REST/api/rest_v1/page/definition/*endpoint, one for the action API/w/api.php. Previously-looked-up terms therefore resolve from cache when the network is unavailable. - Navigation fallback.
workbox.navigateFallback:${basePath}index.html`` (derived from the sameBASE_PATH-driven `base` as the Vite config) keeps offline deep-refreshes inside the SPA shell rather than hitting Workbox's default 404. - Build identity.
vite.config.tsinjects__APP_VERSION__(frompackage.json),__BUILD_SHA__(fromgit rev-parse --short HEAD, falling back to'dev'), and__BUILD_TIME__viadefine. The Settings page shows all three in an "About" section so users can report bugs against a specific build.
Phase 6 introduces three machine translation providers and an opt-in semantic TM. Everything still runs in the browser; nothing leaves the device except the user's own MT calls.
- Provider abstraction (
src/core/mt/) mirrorssrc/core/glossary/wiktionary.ts— pure functions, an injectablefetchImpl, a typedMTErrorwith a discriminatedcode. Three providers ship:ollama.ts(POST/api/chatto a local endpoint, defaulthttp://localhost:11434, no key — error messages mention theOLLAMA_ORIGINSrequirement explicitly);claude.ts(POSThttps://api.anthropic.com/v1/messageswithanthropic-version: 2023-06-01andanthropic-dangerous-direct-browser-access: true, default modelclaude-haiku-4-5-20251001, maps 401/403→auth, 429→rate_limit);libretranslate.ts(configurable endpoint, optional API key, maps 400→unsupported_lang). DeepL from the original Phase 6 wording was dropped becauseapi.deepl.comhas no public CORS — substituted with LibreTranslate, which is also free/open and works directly from the browser. - Settings persistence (Dexie v2).
src/storage/db.tsadds asettingskey/value table (&key) and anembeddingstable (id, tmId, model, [tmId+model]) viathis.version(2).stores(...). v1 tables are unchanged so the upgrade is purely additive.src/storage/repositories/settingsRepo.tsexposes typedget<T>/set<T>plusMT_SETTINGS_KEY,SEMANTIC_TM_KEY, defaults, and merge helpers (mergeMTSettings,mergeSemanticTMSettings). API keys are stored plaintext in IndexedDB — the Settings UI shows an explicit warning. Browser-side encryption would be theatre since the key has to be plaintext at use time. - MT panel (
src/features/editor/mt/) is a new third sidebar tab alongside TM and Glossary.MTPanel.tsxmirrorsTMPanel.tsx: provider dropdown (only enabled providers), explicit "Translate" button (no auto-fetch — prevents accidental Claude spend), abort-on-source-change viaAbortController, error keyed offMTError.code, "Apply" calls the samehandleApplyTMcallback the EditorPage already uses for TM. Offline gating mirrorsWiktionaryLookup: Claude and LibreTranslate disable whenuseNetworkStatus()reports offline; Ollama (local) is always available.useEditorActionsStoregainstranslateCurrentWithMT(providerId?)so the command palette can trigger an MT translation on the current segment without prop drilling. - Semantic TM (opt-in).
src/core/embeddings/index.tslazily dynamic-imports@xenova/transformersand caches afeature-extractionpipeline keyed by model. The default model isXenova/paraphrase-multilingual-MiniLM-L12-v2— 384-dim, ~50 MB quantized, multilingual.src/workers/embeddings.worker.tsexposesembed,embedMany, andembedAndRankvia Comlink so the model runs off the main thread;src/workers/client.tslazily wraps it (getEmbeddingsWorker()).src/core/tm/semantic.tsaddsfindSemanticMatches(looks up cached vectors inembeddingsRepo, sends only candidate vectors + the query to the worker for ranking) andmergeMatches(dedupes lexical + semantic results by entry id, lexical wins on tie).useTMMatchesopts into semantic results when the user has enabled it; the TM panel'sMatchCardadds a smallsemanticbadge whensimilarityMethod === 'semantic'. An index is built from Settings → "Build / rebuild index", which chunks the entire TM throughworker.embedMany(16 entries at a time) and writesEmbeddingRecord { id, tmId, model, dim, vector: Float32Array, createdAt }rows. Float32Array is stored natively via Dexie's structured clone. - Worker code-splitting.
vite.config.tssetsworker.format: 'es'because IIFE workers can't dynamically import@xenova/transformers.optimizeDeps.exclude: ['@xenova/transformers']keeps the library out of the prebundle. The final build splitstransformers-*.js(~830 KB) into a separate chunk that only loads when the user enables semantic TM. - Model caching. A new Workbox
runtimeCachingrule (CacheFirst,^https://huggingface.co/.*/resolve/.*, 1-year max,rangeRequests: true) caches the embedding-model files so subsequent cold starts work offline after the one-time download. - Out of scope: streaming MT (Ollama supports it; v1 is single-shot for simplicity), batch "translate all empty segments" (possible follow-up), encrypted key storage (not meaningful client-side), auto-translating on segment focus.
F3 turns the single-user CRDT layer (F2) into peer collaboration. F2 mirrored
each project's segments into a per-project Yjs doc one way (Dexie→Yjs,
src/storage/sync/bridge.ts). F3 closes the loop and adds a peer transport.
Decided architecture: Tauri desktop peers + mDNS auto-discovery + encrypted
Yjs sync, with the desktop shell staying thin (discovery + transport only) and
all product logic remaining in the shared React app.
This phase ships the platform-agnostic TypeScript sync core (fully tested in the PWA) plus a documented Tauri/Rust scaffold; the cross-machine mDNS/LAN networking under Tauri is the follow-up.
- Reverse observer (Yjs→Dexie) —
src/storage/sync/reverseBridge.tsmakes sync bidirectional. Remote updates are applied to the doc taggedORIGIN_REMOTE; anobserveDeepon the segments map reconciles the changed rows back into Dexie (the source of truth) viareadSegment→db.segments.put, souseLiveQuery, TM, QA and version history see merged peer edits with no new read paths. Loop safety: our own mirror writes (ORIGIN_DEXIE) are ignored here, and the reverse Dexie write runs insidewithMirrorSuppressed(a ref-counted guard inbridge.ts) so it never bounces back into the doc — which also stops anupdatedAt-only ping-pong between peers (the reverse write keeps the doc's own LWWupdatedAt). - Transport seam —
src/storage/sync/transport/definesSyncTransport(start/send/onMessage/destroy) and aSyncMessageunion (hello,bye,state-request,state,update,presence).BroadcastChannelTransportis a zero-dependency same-machine, cross-tab implementation that works in the PWA today;TauriLanTransportis the desktop bridge (lazily imports@tauri-apps/apithrough a computed specifier so the PWA build neither bundles nor requires it).createTransport()picks one viaisTauri()(platform.ts). - Sync session —
src/storage/sync/syncSession.tsbinds a Yjs doc to a transport: local doc updates (origin ≠ORIGIN_REMOTE) are broadcast; inboundupdate/stateare applied withORIGIN_REMOTE(driving the reverse bridge); a joining peer is answered withY.encodeStateAsUpdatefor initial convergence.syncManager.tsref-counts one session per shared project and resolves identity (profile.identity), transport and encryption codec. - Presence —
src/storage/sync/presence.tsis a lightweight, dependency-free roster (peer id → name / colour / active segment) broadcast on a heartbeat with TTL expiry — ephemeral UI data, deliberately not a CRDT. - Encryption —
src/storage/sync/crypto.tsis a WebCrypto AES-GCM payload codec with a PBKDF2-derived key from a project share passphrase (deterministic salt from the project id so peers converge without a handshake). Identity codec for same-machine BroadcastChannel; real encryption for the LAN transport. - Opt-in sharing + UI — sharing is per-project and off by default
(
shareRepo, stored in thesettingstable; no Dexie migration).EditorPagemountsuseProjectSync(src/features/editor/peers/), which starts/stops the session on the share flag and publishes peers intousePresenceStore. A new Peers sidebar tab (PeersPanel.tsx) carries the share toggle and the live peer list; presence follows the focused segment. - Desktop scaffold —
src-tauri/(Cargo manifest,tauri.conf.json,mdns.rsadvertising/browsing_verbalis._tcp.local,transport.rs,commands.rsexposingstart_sharing/stop_sharing/broadcast_sync_message). Built by a separate Rust pipeline, intentionally outside the PWA CI — seesrc-tauri/README.md.
A catalogue of pre-curated Brazilian-Portuguese→British-English terminology that ships with the app and is installed by field/area into the user's working set. Source data is the consolidated CADE / Noronha / TIPS termbase (~35k pairs) from the pt-en-legal-translation skill.
- Build-time data prep (
scripts/build-corpora.mjs) readsscripts/data/master_glossary.csv(pt,en,domain,source,note), classifies each pair into a curated field via priority-ordered keyword rules (competition, tax, IP, labour, corporate, accounting-finance, criminal, civil-procedure, academic, contracts, withgeneral-legalas the catch-all), de-duplicates on the pt+en pair, and writes one compact JSON pack per field plusmanifest.jsonintopublic/corpora/. Re-run withpnpm build-corporaafter editing the CSV or theFIELDSrules. Packs are disjoint (single primary field assignment) so install counts stay clean. The classification is keyword-heuristic by design — the bulk of the general legal termbase falls through togeneral-legal. - Static assets, not precache. Pack JSON (one is ~1.7 MB) is fetched on demand, never bundled into JS and excluded from the SW precache (
workbox.globPatternsonly globs js/css/html/ico/png/svg/woff2). AruntimeCachingrule (StaleWhileRevalidate,/(corpora|guide)/.*\.(json|md)$) keeps installed packs and the guide available offline after first use. - Core (
src/core/corpus/):manifest.tsfetches the catalogue/packs (resolving URLs againstimport.meta.env.BASE_URL);match.tsis an efficient whole-word matcher that scales to tens of thousands of terms — it builds a first-token index once (buildCorpusIndex) and, per segment, only tests candidate terms whose first word actually appears (findCorpusHits), preferring the longest match per position.keySideForSourceLangpicks which side of the PT→EN corpus to match against based on the project's source language (PT source → PT side; EN source → EN side, suggesting PT). This avoids the per-entry regex cost of the hand-curated glossary matcher, which is fine for small user glossaries but would not survive 35k rows. - Storage (
corpusRepo) keeps corpus terms in their owncorpusTermstable (separate from the user's editableglossary, for performance) and tracks install state incorpusPacks.install()persists terms, records the pack, and optionally seeds the TM (db.tm.bulkAdd, tagged withcorpusId);uninstall()removes the terms, the record, and any TM entries it seeded. Re-install is idempotent (uninstall-then-install). - UI:
/corpora(src/features/corpora/) is the catalogue — one card per field with term count, provenance, an "Also add to TM" toggle, and install/remove. The editor Glossary panel surfaces corpus hits under a "From corpora" divider alongside hand-curated glossary hits (useCorpusMatchesis only mounted when the Glossary tab is active, so there is no cost otherwise). The Glossary page shows an install summary banner linking to/corpora. - Translation guide (
/guide,src/features/guide/) renders the skill's workflow, standards/conventions and translation-theory reference docs (shipped aspublic/guide/*.md) via a small mdast→React renderer (Markdown.tsx) built on the existingunified+remark-gfmstack — no new dependency, nodangerouslySetInnerHTML.