Single source of truth for build progress. Every revamp PR must update this file.
States: pending · in-progress · in-review (PR #n) · done (PR #n) · skipped (reason).
Rules for autonomous sessions:
- Work on exactly one phase per session, chosen as: the first
pendingphase (top-to-bottom) whose Depends on entries are alldone. - If a phase is
in-review, check its PR for CI failures or review comments and fix them instead of starting a new phase. - Never mark a phase
doneyourself — only the owner merging the PR does that (the next session records the merge here). - Scope overflow: ship the core slice green, then append the remainder as a new row (e.g.
1.3.1) here and in ROADMAP §4.
| Phase | Name | Size | Depends on | Status |
|---|---|---|---|---|
| 0 | Bootstrap: roadmap, status, prompts, vision docs in repo | S | — | done (PR #37) |
| 0.1 | CI hardening (typecheck + vitest + Playwright on PRs) | S | — | done (PR #37) |
| 1.1 | Change model core + ChangeMarkNode + derivation semantics | M | 0.1 | done (PR #38) |
| 1.2 | Rich editing default-on + Playwright migration | M | 1.1 | done (PR #39) |
| 1.3 | Suggesting mode (edits become tracked suggestions) | M | 1.2 | done (PR #40) |
| 1.4 | Accept/reject + Changes panel rework | M | 1.3 | done (PR #41) |
| 1.5 | Range-anchored threaded comments | M | 1.2 | done (PR #42) |
| 1.6 | Review polish: confirm gating, navigation, attribution | S | 1.4, 1.5 | done (PR #43) |
| 2.1 | Document/block schema (Dexie v6) + backfill | M | 0.1 | done (PR #44) |
| 2.2 | DOCX import fidelity upgrade | M | 2.1 | done (PR #45) |
| 2.3 | Document preview pane | M | 2.1 | done (PR #46) |
| 2.4 | Clean DOCX export | M | 2.2, 2.3 | done (PR #47) |
| 3.1 | Supabase bootstrap + Google/magic-link auth + PKCE | M | 0.1 | done (PR #48) |
| 3.2 | Microsoft + Apple providers + account settings | S | 3.1 | done (PR #50) |
| 3.3 | Synced preferences/settings/layout | M | 3.1 | done (PR #51) |
| 3.3.1 | Sidebar layout sync (deferred from 3.3) | S | 3.3 | done (PR #53) |
| 3.4 | Personal term bank + TM sync | M | 3.3 | done (PR #52) |
| 4.1 | Cloud project schema + RLS + publish/join | M | 3.1 | done (PR #55) |
| 4.2 | SupabaseRealtimeTransport + chunking | M | 4.1 | done (PR #56) |
| 4.3 | Postgres persistence loop (catch-up, append, compaction) | M | 4.2 | done (PR #57) |
| 4.4 | Live collab UX: cursors, leases, attribution | M | 4.3, 1.6 | done (PR #58) |
| 4.4.1 | Remote caret overlay (live cursors, deferred from 4.4) | S | 4.4 | done (PR #59) |
| 5.1 | Members & roles management | M | 4.1 | done (PR #60) |
| 5.2 | Role-gated editing workflow | M | 5.1, 1.6 | done (PR #61) |
| 5.3 | Approval workflow + attribution in versioning | S | 5.2 | done (PR #62) |
| 6.1 | Extension registry + MT providers as built-in addons | M | 0.1 | done (PR #63) |
| 6.2 | QA rules + formats as addons + Add-ons page | M | 6.1 | done (PR #64) |
| 6.3 | Google Drive connector | M | 6.1, 2.4 | done (PR #65) |
| 6.4 | OneDrive connector | S | 6.3 | in-review (PR #66) |
- 2026-07-17 — Phase 0: plan drafted from the five vision documents + codebase exploration; committed to
claude/verbalis-ide-revamp-plan-bvvrzz. - 2026-07-18 — Phase 0.1: CI workflow stacked on PR #37 (same branch as Phase 0, owner-approved) so the new CI validates the bootstrap PR itself. Deviation from ROADMAP wording: triggers on
pull_requestonly (not non-main pushes) to avoid double-running CI on every PR push; every phase ships as a PR, so coverage is identical. - 2026-07-18 — Phase 0.1 also repaired 5 e2e specs that had silently rotted on
main(nothing ran them in CI — exactly why this phase exists): the sidebar's tab strip became stacked sections (sidebar-tab-*testids gone), Settings gained sectioned navigation (settings-nav-*click required), glossary insert buttons were renamed (glossary-insert-primary/secondary), and a duplicatepeers-paneltestid inSidebarPanel.tsxbroke strict mode (app fix: wrapper testid removed). Full suite green: 498 unit / 15 e2e. - 2026-07-18 — Phases 0 and 0.1 merged in PR #37. Phase 1.1 built:
src/core/changes/{model,extract}.ts(TrackedChange model, pureextractChanges/hasPendingChanges),src/features/editor/rich/ChangeMarkNode.ts(inline ElementNode modeled on LinkNode;@lexical/markis NOT installed so no new dep;getTextContent()→''for deletes gives D2 semantics),richStateToOriginal()added tosrc/core/editor/richText.ts(inverse projection sharing the same headless engine),.rsg-change-ins/.rsg-change-delCSS, and node registered into the live editor. No behavior change yet (model/rendering/serialization only), so no new e2e. Green: 507 unit (+9) / 15 e2e / build. - 2026-07-18 — Phase 1.1 merged in PR #38. Phase 1.2: flipped
DEFAULT_EDITOR_SETTINGS.richEditingto true (plain is now the opt-out; code segments stay plain), updated the settings copy, and migrated all 6 target-touching e2e specs offtextarea.fill()/toHaveValue()onto contenteditable helpers in newtests/e2e/helpers/richEditor.ts(targetEditor/setTarget/expectTargetText). Discovered and fixed a real bug the flip exposed: the focus-time FormatToolbar/TagStrip rendered in-flow, so mounting/unmounting it on focus/blur reflowed the segment list and could swallow the very next click on unrelated chrome (stage switcher, status filter) ~1/3 of the time — now floated as an absolute overlay inRichSegmentEditor.tsx(proven 6/6 reliable, and a UX win: no layout jump on focus). Opt-out (plain) path still covered bySegmentRow.confirm/segmentCounterunit tests. Green twice consecutively: 15 e2e / 507 unit / build. - 2026-07-18 — Phase 1.2 CI (PR #39) surfaced a pre-existing
mt-flowflake unrelated to the toolbar fix: the MT settings checkboxes update optimistically and persist to Dexie fire-and-forget, sopage.gotoright after a toggle could abort the write (CI showed MyMemory still running despite being unchecked). Fixed by awaitForMTPersistedhelper that polls IndexedDB until the settings actually land before navigating. mt-flow green 3× locally; full suite 15/15. - 2026-07-18 — Phase 1.2 merged in PR #39. Phase 1.3 built: suggesting mode.
editMode: 'direct'|'suggesting'onuseEditorModeStore; a globalEditModeTogglebeside the stage switcher + a command-palette toggle (cmd-toggle-suggesting); stable per-author id minted/persisted inprofile.identityviaensureLocalAuthor()+useLocalAuthor().TrackedChangesPluginintercepts CONTROLLED_TEXT_INSERTION/BACKSPACE/DELETE/PASTE/CUT at CRITICAL priority when suggesting (and not mid-IME): typing wraps text in an insertChangeMarkNode(extending an adjacent own insertion), deletion wraps the range in a delete mark (or really removes the author's own pending insertion). Pure$-helpers insrc/features/editor/rich/suggest.ts.- Deviations from ROADMAP §4 (noted): (a) suggest helpers live in
features/editor/rich/notcore/changes/— they must construct the feature-layerChangeMarkNode, whichcore/cannot import; still pure + headlessly unit-tested. (b) Built on the designated session branch, notclaude/revamp-phase-1-3. - Two bugs found + fixed while building: (1) a nested
editor.updateinside the command handlers let the native beforeinput insertion slip through on the first keystroke (duplicated leading char) — command listeners already run in an update, so the$-helpers are now called directly. (2) The D2 plain-text derivation leaked deleted text:RootNode.getTextContent()returns a DOM-derived cache in read mode, and a pending deletion's text is in the DOM (struck through), so the live autosave read "Casa" instead of "". Fixed by deriving the persisted plain target viarichStateToPlain(serializedState)(a fresh parse, no DOM cache) inRichSegmentEditor's autosave — this also hardens the invariant for every future mark type. - Known limitation (candidate 1.3.x): suggesting mode only applies in the rich editor (the plain textarea opt-out / code segments edit directly); IME-composed insertions are not yet wrapped (guarded off, never corrupts text). Accept/reject UI is Phase 1.4.
- Tests:
changes.suggestunit (6 cases: insert/extend/replace/range-delete/own-insert-removal/edge),tracked-changes.spec.tse2e (insertion + deletion, proposed target verified from IndexedDB). Green: 513 unit (+6) / 17 e2e (+2) / build; new tests 5/5 stable.
- Deviations from ROADMAP §4 (noted): (a) suggest helpers live in
- 2026-07-18 — Phase 1.3 merged in PR #40. Phase 1.4 built: accept/reject for tracked changes.
resolve.ts(beside ChangeMarkNode):$resolveChangeMark/$resolveChangeById/$resolveAllChangeslive helpers + headlessresolveChangeInRich/resolveAllInRichfor unmounted segments (accept-insert & reject-delete keep text by unwrapping; accept-delete & reject-insert drop it).changes/resolveOps.tsorchestrates read→resolve→segmentRepo.update(kept OUT of segmentRepo so the storage layer never imports the Lexical node — a noted deviation from ROADMAP'ssegmentRepo.resolveChange) +listPendingChanges/resolveProjectAll. ChangesPanel reworked into two tabs: Suggestions (live pending changes fromextractChangesacross all segments, per-change Accept/Reject + Accept all/Reject all, click-to-jump) and History (the original version-diff view, unchanged).ChangeHoverCardgives in-editor click-a-mark → Accept/Reject; fixed a subtlety where the editor-box native click listener closed the card before the button's React onClick ran (now ignores clicks inside the card). Resolutions route throughsegmentRepo.update, so the Dexie→Yjs bridge + version history observe them and the mounted editor rebuilds via its externalRich effect. Tests:changes.resolveunit (all four resolutions + resolve-all, 7 cases);tracked-changes.spec.tse2e extended with hover-card accept and panel accept/reject (proposed target verified from IndexedDB). Green: 520 unit (+7) / 20 e2e (+3) / build; e2e stable twice. - 2026-07-18 — Phase 1.4 merged in PR #41. Phase 1.5 built: range-anchored threaded comments.
SegmentCommentgains additiveparentId/anchorId/quote(type +segmentCrdt.tsmapping/reconcile, round-tripped through Yjs).CommentMarkNode(inline ElementNode wrapping text, like LinkNode; getTextContent passes through so plaintargetis unchanged) highlights the anchored range;.rsg-commentCSS; registered in the live editor.rich/comments.ts: live$wrapSelectionAsComment/$removeCommentMarkByAnchor+ headlessremoveCommentMark.comments/commentOps.ts:groupThreads(roots+replies),addRootComment/addReply/setThreadResolved/deleteThread/dropAnchor/listProjectThreads— all routed through segmentRepo so the Yjs bridge + versioning observe them. FormatToolbar gains a 'Comment on selection' button + popover (wraps the selection, collects the body); SegmentComments reworked into threads (quote, replies, resolve-removes-highlight); new project-wideCommentsPanel+ acommentssidebar tab (revise stage). Two fixes while building: (1)editor.updatedefers its callback, so the popover-open decision now happens inside the update (after the pending Ctrl+A selection reconciles) rather than reading a return value synchronously. (2) The focus-time toolbar unmounted the instant its own popover input took focus — the editoronBlurnow keeps the toolbar mounted while focus stays within the editor container (also fixes the latent link-popover case). Deviations (noted): comment editor/ops helpers live infeatures/notcore/(they construct the feature-layer node). Tests:comments.threadsunit (grouping + anchor removal/plain-text preservation),segmentCrdtextended (new fields round-trip),comments-anchored.spec.tse2e (highlight→thread→reply→resolve, and the project Comments panel). Green: 526 unit (+13) / 22 e2e (+2) / build; e2e stable twice. - 2026-07-19 — Phase 1.5 merged in PR #42. Phase 1.6 built: review polish, completing Milestone 1. Confirm is now gated on no-pending-changes —
EditorPage.confirmreads the segment fresh and, ifhasPendingChanges(targetRich), shows a tool message and refuses, so unaccepted suggestions never reach the TM. Next/prev tracked-change navigation:jumpToNextChange/jumpToPrevChangeon the actions store (scan segments byhasPendingChanges), wired to command-palette commands (cmd-next-change/cmd-prev-change) and F8 / Shift+F8 inuseGlobalShortcuts. Show/hide marks:useChangesStore.showMarks+ a toggle in the ChangesPanel header; when off, EditorPage addsrsg-hide-marksto the segment list and CSS renders the clean proposed view (insertions plain, deletions hidden, comment highlights off). Revise stage pins the Changes + Comments panels (non-removable). Addeddata-focusedto the segment row for nav testing. Tests:tracked-changes.spec.tsextended (+3: confirm-gate block→accept→confirm, show/hide-marks class, palette next-change focus move). Green: 526 unit / 25 e2e (+3) / build; e2e stable twice. - Milestone 1 complete (pending PR #43 merge): Google-Docs/Word-style inline tracked changes + range-anchored threaded comments, with suggesting mode, accept/reject, review navigation and confirm-gating — all fully local, no backend.
- 2026-07-19 — Phase 1.6 merged in PR #43 (Milestone 1 complete). Phase 2.1 built: the document/block model.
core/documents/model.ts(DocumentEntity,Blockwith kind/depth/ordered/InlineRun[]/table+image attrs,Asset);core/documents/fromSegments.ts(purebuildBlocksFromSegmentsgrouping bysourceMeta.blockIndex);documentRepo/blockRepo/assetRepo. Dexie v6:documents/blocks/assetstables;migrateDocumentBlocksupgrade backfills existing monolingual projects (XLIFF skipped) and stampsSegment.blockId.blockIdadded to the type andsegmentCrdtSCALAR_FIELDS; the import flow creates a document + blocks for TXT/MD/DOCX; project-delete cascade extended to the new tables. No UI change (per DoD). Blocks are not mirrored into Yjs in v1 (immutable post-import). Backfilled blocks carry structure (kind/depth/ordered) but nosourceRuns— those are populated by the upgraded DOCX import in 2.2; preview (2.3) reconstructs source from segments otherwise. Tests:documents.fromSegments(grouping + XLIFF-skip),documentRepo(ordered reads, project scoping/cascade),db.migrationv5→v6 backfill. Green: 532 unit (+6) / 25 e2e / build; e2e stable twice. - 2026-07-19 — Phase 2.1 merged in PR #44. Phase 2.2 built: DOCX import fidelity.
core/documents/docxImport.ts— mammothconvertToHtmlwith an explicit styleMap + image capture (mammoth.images.imgElement, guarded so the segmentation test's minimal mock still works), then a purehtmlToParsedDocxwalker producing BOTH the flatParsedSegment[](contract unchanged) and structured blocks:extractRunscaptures bold/italic/underline/sub/sup/links intoInlineRun[]; tables becomekind:'table'blocks withattrs.rows(cell runs) whose non-empty cells are translatable segments; lone-image paragraphs becomekind:'image'blocks referencing a captured asset.segmentation/docx.tsnow delegates toparseDocxDocumentand returns.segments— the existingsegmentation.docx.testpasses unchanged. Import flow: DOCX projects persist the parsed blocks (runs/tables/images) + image assets + the original .docx blob (kind'original') for future re-processing; TXT/MD keep thebuildBlocksFromSegmentspath. Fidelity ceiling acknowledged (no headers/footers/text-boxes/exact footnote placement — bounded by mammoth). Tests:documents.docxImport(extractRuns formatting/nesting, heading/paragraph runs, table cells→segments, image block + index advance, empty-paragraph skip). Green: 539 unit (+7) / 25 e2e / build; e2e stable twice. - 2026-07-19 — Phase 2.2 merged in PR #45. Phase 2.3 built: live document preview.
core/documents/render.ts— purerenderDocument(blocks, segments)assembling the block tree with each block's translated segment targets (ordered bysentenceIndex, source fallback + tint for untranslated), table cells mapped to segments row-major, image asset ids carried;RenderBlock/RenderCell/RenderSegmentRefmodel.preview/usePreviewStore.ts(open + mode: source/target/split) andpreview/DocumentPreview.tsx(renders blocks with formatting runs on the source side, plain translated text on the target side, click-a-block →jumpToSegment). EditorPage shows a Preview toggle in the header row and the pane above the segment list, gated on the project having a document (monolingual only; XLIFF hidden). Live viauseLiveQuery. Deviation (noted): the toggle lives in the EditorPage header row (beside Stage/EditMode) rather than the stage-scoped EditorToolbar, so it's available in every stage. Tests:documents.render(order, target/source fallback, sentence-order, table→segment mapping + image asset),document-preview.spec.tse2e (toggle → edit → live update → click-to-focus → source mode). Green: 542 unit (+3) / 26 e2e (+1) / build; e2e stable twice. - 2026-07-19 — Phase 2.3 merged in PR #46. Phase 2.4 built: clean DOCX export (Milestone 2 complete). Added
docx(runtime dep, dynamic-import only — split into its own ~400KB chunks, zero initial-bundle cost) +jszip(devDep, for tests).core/documents/targetRuns.ts— puretargetRichToRunsdecodes Lexical text-format flags intoInlineRun[]with D2 accepted-preview (insertions kept, deletions dropped), hyperlink href, inline-tag placeholders.core/documents/toDocx.ts—exportProjectDocx(blocks, segments, assets)reconstructs a Document viaimport('docx'): headings (levels), lists (bullet + decimal numbering), blockquotes (indent), code (monospace), tables (translated cells), images (ImageRun from asset blobs); target formatting fromtargetRichruns else plain, source fallback for untranslated.hasPendingForExportflags unresolved changes.tools/ExportDocxButton.tsx(header row, gated on a document; downloads via object URL; warning marker when pending changes exist). Tests:documents.targetRuns(format flags, accepted-preview, links/tags),documents.toDocx(unzip document.xml via jszip -> assert translated text + Heading1 style + bold + table + source fallback). Green: 550 unit (+8) / 27 e2e (+1: export download) / build; docx confirmed as a separate lazy chunk; e2e stable twice. - Milestone 2 complete (pending PR #47 merge): document/block model, DOCX import fidelity (runs/tables/images), live preview, and clean DOCX export — the full Word round-trip in -> translate -> out.
- 2026-07-19 — Phase 2.4 merged in PR #47 (Milestone 2 complete). Phase 3.1 built: Supabase bootstrap + Google/magic-link auth (start of Milestone 3, accounts). Strictly additive behind
VITE_SUPABASE_URL/VITE_SUPABASE_ANON_KEY(D6):storage/cloud/supabaseClient.ts(env-gated lazy singleton;import type { SupabaseClient }erased at build; value lib loaded only via dynamicimport('@supabase/supabase-js')on first use;flowType:'pkce'+detectSessionInUrl:falsepinned;configuredProviders()fromVITE_AUTH_PROVIDERS, default['google']).storage/cloud/authBootstrap.ts(D7): purestripAuthQuery/hasAuthRedirect+maybeHandleAuthRedirect()— exchanges the PKCE?code=(which lands before the#/routefragment) andhistory.replaceStates the query away before the hash router mounts; a no-op that loads no Supabase code when unconfigured.features/account/:useAuthStore(zustand;statusunconfigured/loading/authenticated/unauthenticated,init()gets session + subscribesonAuthStateChangeonce,signInWithOAuth/signInWithMagicLink/signOut; redirects toorigin+pathname, never the hash),SignInDialog(configured OAuth buttons + magic-link email + sent state),AccountMenu(TopBar control, rendersnullunless cloud-configured).main.tsxawaits the redirect bootstrap before render;TopBarmountsAccountMenu.supabase/migrations/0001_profiles.sql(profiles + owner-scoped RLS + signup trigger seeding display name/avatar from provider metadata).docs/cloud.md(env vars, project + provider setup, exact redirect-URL allow-list incl.http://localhost:5173/, honest passkeys-are-fast-follow note, manual test matrix). CI gains a secondpnpm buildwith dummy cloud env so the signed-in code path can't regress unnoticed. Verified: flag OFF → zero Supabase code in the bundle (grep forGoTrueClient/SupabaseClientempty); flag ON → Supabase isolated to a lazy chunk, absent from the entry chunk. Deviations (noted): built on the designated session branch, notclaude/revamp-phase-3-1; broader profile-read RLS for invite-by-email deferred to 5.1 (least-privilege base install). Tests:cloud.authBootstrap(query-strip incl.?code=x#/project/y, redirect detection, unconfigured no-op leaves history untouched),cloud.supabaseClient(flag-off rejects before importing the lib, provider parsing, flag-on viaresetModules+stubEnv). Green: 564 unit (+14) / 27 e2e / build (both flag-off and flag-on); e2e stable twice. - 2026-07-19 — Cloud infra provisioned (human created the Supabase project
qutcuzlppbjbsymowavc, us-east-2, PG17). Migrations0001_profiles+0002_profiles_hardeningapplied via the Supabase MCP; security advisor clean (no lints). Added0002_profiles_hardening.sql(pins triggersearch_path, revokes RPC EXECUTE on the trigger-only functions),.env.example(real URL + publishable key — public by design; secret key never committed), and wireddeploy.ymlto injectVITE_SUPABASE_URL/ANON_KEY/AUTH_PROVIDERSfrom repo Variables (unset ⇒ still local-only).docs/cloud.mdupdated with this project's concrete values. Remaining human steps: add the repo Variables for production, set Supabase Site URL + redirect allow-list (https://verbalis.britx.me/,http://localhost:5173/), configure the Google provider, and rotate the secret key (it was shared in chat). No app source changed — infra/docs only. - 2026-07-19 — Phase 3.1 merged in PR #48 (STATUS was stale — recorded here). Phase 3.2 built: Microsoft + Apple providers + account settings. Provider buttons were already env-driven from 3.1 (
configuredProviders()→SignInDialogfilters to the natively-supportedgoogle/azure/apple); this phase adds recognisable brand marks (features/account/ProviderIcon.tsx— multi-colour Google "G", four-square Microsoft, monochrome Apple glyph that inherits button colour; unknown providers get no glyph) so the three "Continue with …" buttons are visually distinct. Unconfigured providers stay hidden (DoD). Newfeatures/account/AccountSettingsSection.tsx— the signed-in counterpart to the localProfileSettingsSection, surfaced as a Settings → Account section that only appears when the cloud is configured (visibleNav()filters it insettings/index.tsx;UserCogicon). Signed out it offers a Sign in button (reusesSignInDialog); signed in it shows: an editable display name persisted to theprofilesrow via an explicit Save (dirty-gated, "Saved" confirmation), linked sign-in methods (Google/Microsoft/Apple/Email), and Sign out.useAuthStoregainsprofileDisplayName/identitiesstate +loadAccount()(parallelprofilesselect +supabase.auth.getUserIdentities(), prefers the account name in the top-bar menu) andupdateDisplayName()(upsert toprofiles, trims → null when cleared, patches the menu user live, surfaces RLS errors); both reset on sign-out/session-end. Strictly additive: the new statically-imported components read only the env-gatedisCloudConfigured()/configuredProviders()— no static@supabase/supabase-js(verified: flag-offdisthas zeroGoTrueClient/SupabaseAuthClient; the lib stays behindgetSupabase()'s dynamic import). Docs:docs/cloud.md§7 manual-verification block (Azure app registration + Apple Developer Program requirement already in §4). Deviation (noted): built on the designated session branch, notclaude/revamp-phase-3-2. Tests:account.store(loadAccount name+identity mapping and provider-name fallback; updateDisplayName trim/clear/error via an injected mock client),account.settings(unconfigured/signed-out/authenticated renders + save-through-store + Account nav hidden when unconfigured). Green: 574 unit (+10) / 27 e2e / build (flag-off bundle Supabase-free); no new e2e (ROADMAP §4 asks none for 3.2). - 2026-07-19 — Phase 3.2 merged in PR #50. Phase 3.3 built: cloud settings sync.
supabase/migrations/0003_user_settings.sql(user_settings(user_id,key,value jsonb,updated_at), PK(user_id,key), owner-scoped RLS for select/insert/update/delete;updated_atis client-supplied so per-key LWW is meaningful across devices — no touch-trigger). Numbering deviation: ROADMAP §3.3 sketched0002_user_settings.sql, but0002was taken by0002_profiles_hardening.sql(a 3.1 follow-up), so it shipped as0003; §3.4's personal-resources migration shifts to0004(noted in ROADMAP).src/storage/cloud/settingsSync.ts:SYNCED_SETTINGS_KEYSallowlist =editor.prefs/lookup.defaults/spell.dictsonly — MTmt.providersexcluded (API keys never sync), and so are web-search providers (keys),profile.identity(device-local author id), and semantic-TM (device model). PurereconcileSettings(local, remote)does per-key LWW (newerupdatedAtwins; present-on-one-side flows to the other; equal = untouched; non-allowlisted ignored).pullAndReconcile(client, userId)+pushKey(client, userId, key)take an injected Supabase client (vitest never hits the network). Wiring:startSettingsSync()subscribes touseAuthStore(pull once per authenticated user id — token refreshes don't re-pull) and to a new settings-change fan-out, debouncing pushes (500ms);stopSettingsSync()tears down.settingsRepogainedupdatedAtstamping onset, a silentapplyRemote(key,value,updatedAt)for pulls (no echo),getRow, andsubscribeSettingsChange;SettingsRow.updatedAt?added todb.ts(unindexed — no Dexie version bump; pre-3.3 rows read as "oldest").main.tsxstarts the reconciler via a guarded dynamicimport('./storage/cloud/settingsSync')only whenisCloudConfigured(). Dexie stays the read path — settings sections useuseLiveQuery, so a pull'sapplyRemoterepaints the UI. Verified: flag-OFFdisthas zero Supabase code and nouser_settings/SYNCED_SETTINGS(settingsSync tree-shaken); flag-ON keepsGoTrueClientout of the entry chunk and putssettingsSync+user_settingsin their own lazy chunks. Deviations (noted): built on the designated session branch, notclaude/revamp-phase-3-3; sidebar layout sync deferred to Phase 3.3.1 (appended to ROADMAP §4 + the table) — the layout lives in a zustand/localStoragepersiststore with no LWW timestamps or Dexie read path, so folding it into the reconciler (per-mutation timestamps + post-pull rehydration) is its own slice; the engine is already generic, so 3.3.1 is just an adapter.docs/cloud.md(§3 migration list + §8 manual verification). Tests:cloud.settingsSync(allowlist excludes MT; reconciler push/pull/newer-wins/equal/non-allowlisted;pullAndReconcileapplies remote-wins + pushes local-wins against real fake-indexeddb Dexie with a mock client;pushKeyallowlist gating + absent no-op;readLocalSyncedEntries),cloud.settingsSync.wiring(pull-on-signin lands in Dexie, debounced push-on-change while signed in, no push when signed out). Green: 588 unit (+14) / 27 e2e / build (flag-off Supabase-free, flag-on lazy); no new e2e (ROADMAP §4 asks none). - 2026-07-20 — Phase 3.3 merged in PR #51. Phase 3.4 built: personal term bank + TM cloud sync (Milestone 3 complete).
supabase/migrations/0004_personal_resources.sql(personal_glossary+personal_tm, each a generic sync envelopeid uuid PK, user_id, updated_at timestamptz, deleted bool, payload jsonb;(user_id, updated_at)index for cursor pulls; owner-scoped RLS select/insert/update/delete). Numbering deviation: ROADMAP §3.4 sketched0003_personal_resources.sql, but0003was taken by0003_user_settings.sql(3.3), so it shipped as0004; deletes are soft (deleted=trueupsert), so tombstones live server-side in the same row.src/storage/cloud/rowSync.ts: a generic cursor-based incremental reconciler driving both resources through one engine via aRowSyncAdapter(glossary + tm). Pure-ishpushResource(upsert personal rows changed since the cursor + soft-delete tombstones, then clear pushed tombstones),pullResource(fetchupdated_at > cursor, apply under per-row LWW: newer remote wins, same-or-newer local kept — which also skips our own just-pushed rows — remotedeletedremoves locally; returns the max ts as the next cursor),syncResource(push→pull→persist cursor viasettingsRepo, a device-local key not in the 3.3 synced allowlist),syncAllResources. Bundled corpora never sync:tmrows carry acorpusIdand are filtered out oflocalChangedSince; the term bank is entirely user-authored, andcorpusRepowritesdb.tmdirectly (bypassingtmRepo), so corpus install/uninstall records no tombstones. Dexie v7:updatedAtindex onglossary/tm+ newsyncTombstonestable (compound PK[resource+rowId]);backfillSyncTimestampsupgrade stamps existing rows so a signed-in device pushes them once.glossaryRepo/tmReponow stampupdatedAton every write, record a tombstone on delete, and fan out a resource-change event — all viastorage/cloud/rowSyncState.ts(a tiny storage-layer seam:setRowSyncEnabled/recordTombstone/subscribeResourceChange, so the repos never import the engine orfeatures/; tombstones are gated on being signed in, keeping local-only inert).TMEntry/GlossaryEntrygain optionalupdatedAt. Wiring:startRowSync()(guarded dynamic import frommain.tsx, only whenisCloudConfigured()) subscribes touseAuthStore(enable tombstones + full reconcile once per new sign-in) and to resource changes (debounced 800ms push/pull);stopRowSync()tears down. Verified: flag-OFFdisthas zeroGoTrueClient/personal_glossary/supabase-js (only the localsyncTombstonesschema + minifiedrowSyncStateseam remain — intended local code); flag-ON puts the engine +personal_glossaryin a lazyrowSync-*.jschunk withGoTrueClientout of the entry. Deviations (noted): built on the designated session branch, notclaude/revamp-phase-3-4.docs/cloud.md(§3 migration list + §9 manual verification). Tests:cloud.rowSync(push personal-only + tombstone push/clear, corpus-exclude; pull LWW newer-remote/newer-local/delete/edit-after-delete;syncResourceround-trip + cursor persist; two full syncs converge without resurrecting a deleted row — the DoD, via a real fake-indexeddb Dexie + in-memory server),cloud.rowSync.repo(updatedAt stamping; tombstone gated on signed-in),cloud.rowSync.wiring(pull-on-sign-in lands in Dexie; delete propagates as a soft-delete once signed in). Green: 602 unit (+14) / 27 e2e / build (flag-off sync-free, flag-on lazy); no new e2e (ROADMAP §4 asks none). - Milestone 3 complete (pending PR merge): accounts — Supabase auth (Google/Microsoft/Apple/magic-link), account settings, synced preferences, and personal term-bank + TM sync, all strictly additive behind
VITE_SUPABASE_URL. - 2026-07-20 — Phase 3.4 merged in PR #52 (Milestone 3 complete). Phase 3.3.1 built: sidebar layout sync (the layout deferred from 3.3).
useSidebarPanelStoregains a persisted LWWupdatedAtstamped on every layout/collapsed mutation (setTab/setLayout/showPanel/togglePanel/toggleCollapsed/movePanel/resetLayout; transient open/active-tab never bump it) +partialize/mergecarry it;defaultLayoutexported for rehydration backfill. Newfeatures/editor/layoutSync.tssyncs the layout through the sameuser_settingscloud table under thesidebar.layoutkey with the same client-supplied LWW:syncLayout(client, userId)(pull-newer applies to the live store viasetState— rehydrating machine B without a reload, backfilling any stage a stale remote misses — else push local-newer), plusstartLayoutSync()/stopLayoutSync()wiring (reconcile once per new sign-in; debounced push on store change, guarded by anapplyingRemoteflag so a pull'ssetStatenever echoes back as a push).main.tsxstarts it via a guarded dynamic import only whenisCloudConfigured(). Deviation (noted in ROADMAP §4): the 3.3settingsSyncengine is bound to DexiesettingsRepokeys (not a source-agnostic adapter engine as the 3.3.1 note optimistically implied), so the layout — a zustand/localStorage store — syncs as a small sibling reconciler reusinguser_settings+ LWW rather than a settingsSync "adapter". No 3.3 code refactored (keeps that phase's tests untouched).settingsSyncnever sees thesidebar.layoutrow (its.in()query is scoped toSYNCED_SETTINGS_KEYS). Verified: flag-OFFdisthas zeroGoTrueClient/syncLayout/user_settings(layoutSync tree-shaken; the onlysidebar.layoutsubstring is the store'sverbalis.sidebar.layoutlocalStorage key — local code); flag-ON keepsGoTrueClientout of the entry and putslayoutSyncin its own lazy chunk. Built on the designated session branch, notclaude/revamp-phase-3-3-1.docs/cloud.md§8.1 verification. Tests:layoutSync(LWW: apply-newer-remote rehydrates the store, push-local-newer, push-when-no-remote-row, equal-noop),layoutSync.wiring(sign-in applies the cloud layout live; debounced push on a local layout change). Green: 608 unit (+6) / 27 e2e / build (flag-off layout-sync-free, flag-on lazy). - 2026-07-20 — Phase 3.3.1 merged in PR #53. Phase 4.1 built: cloud project schema + RLS + publish/open (start of Milestone 4, real-time collaboration). Migrations applied to the live project via the Supabase MCP (advisor clean bar the unrelated leaked-password Auth toggle):
0005_projects.sql—projects,project_members(role project_role enum: project_manager|translator|revisor),ydoc_state(state bytea, seq), append-onlyydoc_updates(update bytea, author_id default auth.uid())(tamper-evident attribution, D8), member-scoped RLS viais_project_member/has_project_rolehelpers, a privateproject-filesstorage bucket.0006_projects_helpers_private.sql— moves the two SECURITY DEFINER membership helpers into a non-APIprivateschema (repoints every 0005 policy) so they stop being RPC-exposed while RLS keeps calling them, clearing the linter's 0029 lints. Numbering deviation: ROADMAP §4.1 sketched0004_projects.sql, but0003/0004were taken by the 3.3/3.4 migrations, so this shipped as0005+ a0006hardening follow-up.src/core/types:ProjectRole+ additiveProject.cloud?: {id, role}(unindexed; local-only projects never carry it).src/storage/cloud/bytea.ts: pure hex-literal encode/decode for bytea-over-PostgREST (base64 decode fallback).src/storage/cloud/projectCloud.ts: pure client-injected data layer (insertCloudProjectseeds the project row + ownerproject_managermembership +ydoc_statesnapshot;listCloudProjects;fetchCloudProject→ summary+role;fetchYdocState→ decoded bytes) + orchestration (publishProjectencodes the project's live Yjs doc viaacquireProjectDoc+Y.encodeStateAsUpdate, inserts, stampsproject.cloud;openCloudProjectfetches metadata+snapshot, hydrates a fresh local Dexie project + its segments viareadAllSegments, reusing an existing local copy rather than duplicating). Strictly additive —getSupabase()dynamic, gated onisCloudConfigured(). UI (self-gating, cloud logic dynamically imported so the projects route stays lean):features/projects/cloud/CloudControls.tsx(CloudBadge, ownerPublishCloudButtonon a card,OpenFromCloudButtonheader action — all render null unless configured + signed-in) +OpenCloudProjectDialog.tsx(lists member projects, opens → navigates to the local copy); wired intoProjectCard+ projectsindex.tsx. Verified: flag-OFFdisthas zeroGoTrueClient; flag-ON keeps Supabase in a lazy chunk out of the entry. Deviations (noted): built on the designated session branch, notclaude/revamp-phase-4-1; the two-account publish→open→RLS-deny round-trip is the manual DoD (needs two real sessions; vitest uses an injected mock client). Tests:cloud.bytea(hex round-trip incl. all 256 byte values + empty + base64 fallback),cloud.projectCloud(insert creates all three rows with the owner as project_manager and a decodable snapshot; list camelCase mapping; fetch summary+role incl. translator default + null-when-absent; error propagation — all via an in-memory client mirroring the PostgREST chains). Green: 618 unit (+10) / 27 e2e twice / build (flag-off Supabase-free, flag-on lazy); no new e2e (ROADMAP §4 asks none for 4.1). - 2026-07-20 — Phase 4.1 merged in PR #55. Phase 4.2 built:
SupabaseRealtimeTransport+ chunking (pure client, no schema change).src/storage/sync/transport/chunking.ts— the protocol core: base64 wire encoding (bytesToBase64/base64ToBytes),encodeMessage(SyncMessage, limit=60000)frames a message intoWireChunks (Realtime is JSON + ~250KB/msg, so binary payloads are base64 +33% and split), and an order-independentChunkReassemblerthat yields aSyncMessageonce all chunks of a message id arrive (single-chunk pass-through; payloadless kindshello/bye/state-requestcarryd:'').src/storage/sync/transport/supabaseRealtime.ts—SupabaseRealtimeTransport implements SyncTransportover a private Realtime broadcast channelproject:{cloudId}(broadcast:{self:false}, so no self-echo, matching BroadcastChannel);getSupabase()is dynamic so no eager supabase-js. Outboundupdates are coalesced byOutboundUpdateBatcher(debounce 300ms →Y.mergeUpdates→ one broadcast); other kinds send immediately; sends beforeSUBSCRIBEDbuffer in an outbox flushed on subscribe. The channel is injectable (channelProvider) as a test seam.createTransport(projectId, {cloudId?})gains a cloud branch (Supabase whencloudIdset; BroadcastChannel/Tauri paths byte-identical otherwise);syncManager.startProjectSynccomputescloudIdfromproject.cloudwhen signed-in +isCloudConfigured(), elseundefined. Deviations (noted): built on the designated session branch, notclaude/revamp-phase-4-2; the transport is complete + unit-covered now, but the end-to-end two-browser live edit DoD needs the Postgres initial-state loop (4.3) + the auto-start-on-open UI (4.4) — the transport is selectable but not yet auto-wired for cloud projects (that's 4.4), stated honestly. No migration (4.2 is transport-only). Tests:sync.chunking(base64 all-bytes/empty round-trip; single + multi-chunk encode/reassemble; out-of-order arrival; two interleaved messages stay separate; payloadless pass-through),sync.realtimeTransport(OutboundUpdateBatchermerges a burst into one flush that replays both edits via fake timers + no-fire-after-destroy; transport delivers a 50KB multi-chunkstateto the other peer only via a linked in-memory channel pair — caught + fixed a real ordering bug where the outbox flushed beforethis.channelwas assigned; pre-subscribe sends buffer then flush). Green: 629 unit (+11) / 27 e2e twice / build (flag-off has zeroGoTrueClient; flag-on keeps Supabase a lazy chunk out of the entry). - 2026-07-20 — Phase 4.2 merged in PR #56 (STATUS was stale — recorded here). Phase 4.3 built: the Postgres persistence loop (Realtime is latency, Postgres is truth).
supabase/migrations/0007_compaction.sql— theclaim_compaction(project_id, expected_seq, state, up_to_id)RPC (SECURITY DEFINER, membership-checked): atomically bumps theydoc_state.seqgeneration only while it still matchesexpected_seq, installs the new compacted snapshot, and prunesydoc_updateswithid <= up_to_id. Numbering deviation: ROADMAP §4.3 sketched0005_compaction.sql, but0005/0006were taken by the 4.1 project migrations, so it ships as0007; and the RPC signature is widened from the sketch's(project_id, expected_seq)to also carry the new snapshot + pruned-through id, so the whole compaction is one atomic RLS-safe transaction (the append log has no DELETE policy by design — D8 tamper-evidence — so pruning goes through this controlled definer path, membership re-checked inside).src/storage/cloud/ydocPersistence.ts— pure client-injected data layer (fetchSnapshot/fetchUpdates/appendUpdate/countUpdates/claimCompaction, hex-bytea via 4.1'sbytea.ts) + thecreateYdocPersistence({cloudId, doc, client})loop: catch-up fetchesydoc_state+ the wholeydoc_updateslog, merges (Y.mergeUpdates) and applies asORIGIN_REMOTE(so neither the transport nor this loop re-broadcasts/re-appends it), then pushes anything the local doc holds that the cloud lacks (offline edits / pre-publish history) by diffing state vectors (Y.encodeStateAsUpdate(doc, remoteSV)whenlocalAheadOf); append buffers localdoc.on('update')deltas (ignoringORIGIN_REMOTE), debounce-merges them (500ms) into one attributedydoc_updatesrow, and re-buffers/retries on a failed (offline) append; compaction triggers once the log passes 200 rows — it re-reads the authoritative snapshot+updates from Postgres (never the possibly-lagging local doc) to build the new snapshot, so it subsumes exactly the rows it prunes, then optimisticallyclaim_compactions (loser getsnulland no-ops — the log is already bounded). Catch-up is re-runnable (idempotent Yjs replay), which is what converges an offline member on reconnect. Wiring:syncManager.startProjectSyncstarts the loop (dynamicstartYdocPersistence, only whencloudIdset + signed-in +isCloudConfigured()) alongside the realtime transport, kicks off a non-blockingcatchUp(), stores the handle on the ref-counted session, and destroys it instopProjectSync; local-only projects never touch it. Deviations (noted): built on the designated session branch, notclaude/revamp-phase-4-3; the RPC signature widening above; live reconnect re-sync (re-hello/re-catchUpwhen Realtime drops-then-resubscribes) is left to 4.4, which owns the transport/auto-start UX — 4.3 converges on reopen (each open runs catch-up), the DoD proven at the persistence layer.docs/cloud.md(§3 migration list + §10 manual verification). Tests:cloud.ydocPersistence(catch-up converges a fresh doc from snapshot+log; append coalesces a burst into one row; applied-remote is never re-appended; offline-A/online-B converge through Postgres with no edit lost — the DoD, on a shared in-memory server; compaction folds+prunes past the threshold and bumps seq, and a stale optimistic claim returnsnull;fetchSnapshot/fetchUpdatesbytea round-trips). Green: 636 unit (+7) / build (flag-off: zeroGoTrueClient/claim_compaction— the loop tree-shakes out entirely when the cloud is unconfigured; flag-on:GoTrueClientand the persistence loop each in their own lazy chunk, out of the entry). No new e2e (ROADMAP §4 asks none for 4.3; the two-member live DoD needs a real backend, per the §10 manual matrix). - 2026-07-20 — Phase 4.3 merged in PR #57. Phase 4.4 built: live-collab UX — per-segment edit leases, attribution, and the stale-LWW guard (transport-agnostic, so it works for local BroadcastChannel peers and cloud Realtime peers identically).
src/storage/sync/presence.tsgains a stableuserId(Supabase user id when signed in, else the device-localauthorId— so attribution survives a peer reconnecting under a fresh peerId, D8) and aCaretRangeon bothPresenceWireandPeerPresence;syncSessionbroadcasts them (setActiveSegment(id, caret?)) andsyncManagerresolvesuserIdviaensureLocalAuthor()/auth.src/storage/sync/lease.ts— puresegmentLease(segmentId, selfPeerId, selfActiveSegmentId, peers): among everyone editing a segment the lowest peerId owns (D4), the rest are viewers; symmetric + deterministic so two peers entering at once always agree, and the lease releases the moment the owner blurs (itsactiveSegmentIdmoves off) and the next-lowest peer takes over. Wiring:usePresenceStorecarriesselfPeerId;useProjectSyncpublishes it;EditorPagecomputesleaseHolderFor(segmentId)and passesleaseLockedByto eachSegmentRow, which renders the target read-only (a separateeditLocked = locked || leaseLockedBy, kept distinct from the persistent segment lock) with a coloured "{name} is editing" chip. Attribution "everyone's changes tracked" falls out of M1 + sync for free — remote suggesting-mode edits already arrive as pre-attributedChangeMarkNodes.reverseBridge.tsstale-LWW guard (risk #5):target(char-level Y.Text) andtargetRichare separate CRDT fields, so a concurrent merge can land atargetRichthat no longer derives to the merged plaintarget; plain is authoritative, so whenrichStateToPlain(targetRich) !== targetthe guard drops the stale rich and the editor rebuilds it from plain. Deviations (noted): built on the designated session branch, notclaude/revamp-phase-4-4; the live remote-caret overlay is deferred to Phase 4.4.1 (appended to the table + ROADMAP §4) — the presence model already carriescaretas the seam, but rendering it needs editor→session caret reporting + offset→DOM measurement (a finicky, separable slice), so 4.4 ships the load-bearing lease/attribution/guard core (the "one editor + one viewer; lease releases on blur" DoD) and the per-segment "is editing" chip as the visible presence cue. Tests:sync.lease(alone→own; higher-peerId co-editor→own; lower-peerId→viewer names holder; not-on-segment reports the editor; symmetric one-owner; release;peersOnSegment),sync.presence(userId + caret round-trip),sync.reverseBridge(guard drops a stale targetRich once the merged plain diverges, no-op while they agree), and e2elan-collaboration.spec.tsextended (simultaneous entry → exactly one read-only viewer + one editable owner; owner blur releases the lease via two BroadcastChannel tabs). Green: 645 unit (+9) / 28 e2e (+1) / build (flag-off has zeroGoTrueClient; leases are local-capable so they ship in the entry as intended). - 2026-07-20 — Phase 4.4 merged in PR #58. Phase 4.4.1 built: the live remote-caret overlay deferred from 4.4 (Milestone 4 fully complete).
src/core/spell/offsets.tsgains puremapOffsetToNode(spans, offset)— the caret counterpart tomapTokenToNode, resolving a plain-text offset to a text node + local offset (a text/text boundary snaps to the earlier node's end; a tag boundary or past-the-end returns null → the overlay skips that frame).src/features/editor/rich/richOffsets.ts—selectionToCaretRange(selection)converts a live Lexical selection to plain anchor/focus offsets andbuildLeafSpans(paragraph)produces the ordered leaf spans, both reproducing exactly the plain projectionrichStateToPlainderives (a tag contributes{id}, a delete change-mark projects to '' and its subtree is skipped) so a caret one peer reports lands on the same character in another peer's editor.RemoteCaretOverlay.tsx(a Lexical plugin mounted inRichSegmentEditorbeside the spell overlay) paints a coloured caret + name label for each remote peer whose presence puts them on the segment with a caret, measured viagetElementByKey+DOM range likeSpellUnderlinePlugin— it never mutates the node tree and every measurement is guarded (renders nothing on failure). Reporting: the focused segment's editor (the only one givenonCaret) computesselectionToCaretRangein its update listener and reports it throughEditorPage→useProjectSync→setActiveSegment(id, caret), throttled to one send per 100ms;useProjectSync.setActiveSegmentnow forwards the caret. Deviations (noted): built on the designated session branch, notclaude/revamp-phase-4-4-1. Tests:spell.offsetsextended (mapOffsetToNode: inside/boundary/after-tag/end/out-of-range),richOffsets(headless: caret offsets + leaf spans agree with the plain projection for text+tag, and a delete change-mark projects to nothing so a caret past it collapses correctly), and e2elan-collaboration.spec.tsextended (a peer's caret renders as a live remote cursor in the other tab). Green: 653 unit (+8) / 29 e2e (+1) / build (flag-off zeroGoTrueClient; the overlay is local-capable and ships in the entry, no Supabase). Milestone 4 (cloud projects + real-time collaboration) complete. - 2026-07-20 — Phase 4.4.1 merged in PR #59. Phase 5.1 built: members & roles management (start of Milestone 5, roles & workflow).
supabase/migrations/0008_member_policies.sql— addsprofiles.email(backfilled fromauth.users+ seeded on signup) and a co-memberprofilesSELECT policy via a newprivate.shares_project(other)SECURITY DEFINER helper (the "broader read for member lookup" 0001 deferred to 5.1); switchesproject_membersupdate/delete to project_manager-only (private.has_project_role), keepinginsertreachable by the owner's publish self-seed OR a PM; aenforce_min_one_pmBEFORE UPDATE/DELETE trigger that blocks removing/demoting a project's last manager; and theinvite_member(project_id, email, role)RPC (SECURITY DEFINER, PM-checked) that resolves the email → user id and upserts membership, returning NULL when no account matches (no id/email enumeration surface). Numbering deviation: ROADMAP §5.1 sketched0006_member_policies.sql, but0006/0007were taken (4.1 helpers + 4.3 compaction), so it ships as0008; and invite resolves the email inside the RPC rather than a client-side profiles lookup, keeping it server-side.src/storage/cloud/members.ts— pure client-injectedlistMembers(joins the roster to profiles for names/emails, marks self, sorts managers first),inviteMember(RPC →{status:'invited'|'not_found'}),changeMemberRole/removeMember(direct writes gated by PM-only RLS + the trigger, surfaced as thrown errors). UI:features/projects/cloud/ManageMembersDialog.tsx(roster; a PM gets an invite-by-email form + per-member role<Select>+ remove; a non-PM sees a read-only roster) opened from a newManageMembersButtonon the cloud project card (CloudControls), the dialog + members logic lazy-loaded so the projects route stays lean. Deviations (noted): built on the designated session branch, notclaude/revamp-phase-5-1; the RLS/trigger/RPC enforcement is server-side, proven by the unit suite's injected-client mirror + thedocs/cloud.md§11 manual matrix (vitest never hits real Supabase). Verified: flag-OFFdisthas zeroGoTrueClient(the members code is a lazymembers-*.jschunk never fetched when the cloud is off / the button renders null).docs/cloud.md(§3 migration list + §11 manual verification). Tests:cloud.members(listMembers join+self+sort, empty roster short-circuit; inviteMember invited/not_found/forbidden-propagates; changeMemberRole in place; removeMember; ≥1-manager trigger error surfaces as a throw). Green: 661 unit (+8) / build (flag-off Supabase-free). No new e2e (ROADMAP §5.1 asks none; the PM/role/RLS DoD needs two real accounts, per the §11 manual matrix). - 2026-07-21 — Phase 5.1 merged in PR #60. Phase 5.2 built: role-gated editing workflow.
src/core/workflow/rules.ts— the pure heart:workflowCapabilities(role, stage, status) → {canEditDirect, mustSuggest, canResolveChanges, canReview, canManage}(PM manages + edits through translation/review and is the only role that touches afinalproject; translator edits directly in translation but is forced to suggest in review, and must suggest on an already-reviewedsegment; revisor waits during translation then edits + accepts/rejects in review; accept/reject is revisor/PM-only), pluseffectiveRoleStage(cloud)(local-only → PM-of-self: project_manager/translation, every capability granted → zero regression) andforcesSuggesting(role, stage).supabase/migrations/0009_workflow.sql— aproject_stageenum +projects.stage(defaulttranslation) +deadline; writes are project_manager-only through the existingprojects_updatepolicy (no new policy). Numbering deviation: ROADMAP §5.2 sketched0007_workflow.sql, but0007/0008were taken (compaction + members), so it ships as0009.WorkflowStageadded tocore/types;Project.cloudgainsstage?/deadline?(read on open).projectCloud.ts—fetchCloudProjectnow returnsstage/deadline,openCloudProjectstores them, andsetCloudProjectStage+ thechangeProjectStage(projectId, stage)orchestration (writes Postgres — RLS rejects a non-PM — then patches the localproject.cloud.stage). Wiring: a tinyfeatures/editor/workflow/useWorkflowStore(default permissive PM/translation) is published byEditorPagefromeffectiveRoleStage(project.cloud);EditModeTogglegains aforcedprop (locked + disabled) driven byforcesSuggesting, andEditorPagepinseditMode='suggesting'when forced;ChangesPanel(accept-all/reject-all + per-suggestion buttons) andChangeHoverCardhide their resolve affordances when!canResolveChanges; a PM stage<Select>(+ read-only badge for non-PMs) lives inManageMembersDialog. Deviations (noted): built on the designated session branch, notclaude/revamp-phase-5-2; content-level rules are client-enforced per D8 (the append-only authored log is the audit trail), stated honestly.docs/cloud.md(§3 migration list + §12 manual role-matrix). Tests:workflow.rules(full role × stage × status matrix incl. reviewed-segment suggest, final lock, local-only PM-of-self,forcesSuggesting),cloud.projectCloudextended (stage read defaults to translation +setCloudProjectStageupdate). Verified: flag-OFFdisthas zeroGoTrueClient; the tracked-changes e2e still green (local-only = PM keeps accept/reject). Green: 671 unit (+10) / 8 tracked-changes e2e / build. No new e2e (ROADMAP §5.2 asks none; the full role matrix needs three real accounts, per the §12 manual matrix). - 2026-07-21 — Phase 5.2 merged in PR #61. Phase 5.3 built: approval workflow + unified attribution in versioning (Milestone 5 complete → web v2 launch candidate).
ProjectVersiongainsauthorId?(the stableprofile.identityauthor id — the same identity tracked-change marks, comments, and presence use, so attribution is unified across the app, D8) andapproval?: { authorId, authorName, at }.versionRepo.capturenow stampsauthorIdon every version (viaensureLocalAuthor()), and a newversionRepo.signOff(projectId, liveDoc?)records anamedversion labeledApproved by {name}with theapprovalmetadata set (its presence marks an approval milestone). Wiring:useWorkflowStoregains auseCanReview()selector (revisor/PM; local-only → PM-of-self → true);VersionHistoryPanelshows a Sign off button beside Save version only whencanReview, and renders approval versions with an accentBadgeCheck+ theirApproved by …label. No migration + no schema bump (versions are local Dexie;authorId/approvalare additive unindexed fields; pre-5.3 rows simply lack them). Deviations (noted): built on the designated session branch, notclaude/revamp-phase-5-3; sign-off gating is client-enforced via the workflow rules at the call site (D8) — the append-only authored update log remains the tamper-evident audit trail. Tests:versionRepoextended (every capture stamps a stable authorId consistent across captures;signOffrecords a labeled approval version whoseapproval.authorId/atmatch the version'sauthorId/createdAt, while a plainsaveNamedhas noapproval). Verified: flag-OFFdisthas zeroGoTrueClient; the version-history e2e still green. Green: 673 unit (+2) / 1 version e2e / build. No new e2e (ROADMAP §5.3 asks none). Milestone 5 (roles & workflow) complete — the web v2 launch candidate. - 2026-07-21 — Phase 5.3 merged in PR #62. Phase 6.1 built: the typed extension registry + the four MT providers re-registered as built-in addons (start of Milestone 6, extensions & connectors; D9 — in-process registry now, sandboxed hosting later, but the manifest contract is final).
src/core/extensions/types.ts—ExtensionManifest {id, name, version, kinds, permissions, builtIn, description?};ExtensionKind = mt-provider|qa-rule|panel|import-format|export-format|storage-connector;ExtensionPermission = network|credentials|storage|clipboard|filesystem.src/core/extensions/registry.ts— a dependency-free (no storage/React imports) singletonextensionRegistry:register/unregister/get/has/list(kind?)(id-sorted), enablement as a disabled set so a registered extension is enabled by default and an unknown id is treated as enabled (behaviour unchanged until something is explicitly turned off),isEnabled/setEnabled/getDisabledIds/setDisabledIds, andsubscribe.src/core/extensions/builtins.ts— the four MT manifests (mt.mymemory/mt.libretranslate/mt.ollama/mt.claude; permissionsnetwork+credentialsfor the keyed ones) + idempotentregisterBuiltinExtensions().src/extensions/mt/index.ts— thin wrappers pairing each manifest to its existingcore/mtprovider (no provider logic moved — the existing MT unit tests are untouched and keep proving behaviour) +registryEnabledMtProviderIds().core/mt/index.tsgainsMT_EXTENSION_ID+isProviderExtensionEnabled(id)(defaults enabled when the manifest isn't registered), andenabledProvidersnow ANDs the user setting with the registry — so disabling the addon removes the provider;useMTSettings.enabledIdsdoes the same and re-derives viauseSyncExternalStore(extensionRegistry.subscribe)so the MT panel updates live. Wiring:features/addons/registryPersistence.ts(registerExtensions()sync +startExtensionRegistry()hydrating the disabled set from the new device-localextensions.disabledsettings key and persisting subsequent changes), started frommain.tsxbefore render. Deviations (noted): built on the designated session branch, notclaude/revamp-phase-6-1; no Add-ons page yet (that's 6.2) — 6.1 ships the registry + reactive resolution and proves the disable→panel path via unit tests.docs/cloud.mdunchanged (no migration). Tests:extensions.registry(register/list-by-kind/default-enabled/toggle/unknown-id/hydrate+notify/unregister),extensions.mt(the four register asmt-provider; wrappers point at the realMT_PROVIDERS; disablingmt.clauderemoves it fromenabledProviderseven with the user setting on — the DoD — and re-enabling restores it). Verified: flag-OFFdistSupabase-free; the untouched MT unit tests +mt-flowe2e stay green. Green: 682 unit (+9) / 3 mt-flow e2e / build. No new e2e (ROADMAP §6.1 asks none). - 2026-07-21 — Phase 6.1 merged in PR #63. Phase 6.2 built: QA rules + formats as addons + the Add-ons page.
core/extensions/builtins.tsnow also registersQA_EXTENSION_MANIFESTS(oneqa.<code>per QA rule, kindqa-rule, no permissions — labels/descriptions fromQA_RULE_LABELS) andFORMAT_EXTENSION_MANIFESTS(format.xliff/format.docx/format.tmx/format.tbx/format.csv, kindsimport-format/export-format,filesystempermission);BUILTIN_MANIFESTS= MT + QA + formats, all registered byregisterBuiltinExtensions().core/qa/registryRules.ts—isQaRuleEnabled(code)(registry, defaults on when the manifest isn't registered sorunQAstays pure and its tests are untouched) +effectiveQaRules(toggles)which ANDs the per-project rule toggles with the registry;QAPanelrunsrunQAwitheffectiveQaRules(settings.qaRules)and re-derives viauseSyncExternalStore(extensionRegistry.subscribe)so toggling a QA addon changes QA output live.features/addons/AddonsPage.tsx— the/addonscatalogue: sections per kind (Machine translation, Quality assurance, Import & export formats), each row showing the name, description, permission chips, and a Built-in badge; MT + QA rows get a working enable/disable toggle (enforced — MT panel + QA output react), formats are shown Always on (their round-trip logic stays incore/*; format-level gating is a follow-up, kept out to avoid touching every import/export path). New/addonsroute (lazyAddonsPage) + a Puzzle nav item. Deviations (noted): built on the designated session branch, notclaude/revamp-phase-6-2; format addons are catalogue/display-only in v1 (no toggle) — only the DoD's QA + the 6.1 MT toggles are enforced. No migration. Tests:qa.registryRules(falls back all-on when unregistered; disablingqa.double_spaceforces it off + removes the finding fromrunQAoutput — the DoD; others unaffected),AddonsPage(lists MT + QA + format sections/rows; toggling a QA addon flips registry enablement; formats have no toggle). Verified: flag-OFFdistSupabase-free (AddonsPage its own lazy chunk); full e2e green after the nav/route addition. Green: 688 unit (+6) / 29 e2e / build. No new e2e (ROADMAP §6.2 asks none). - 2026-07-21 — Phase 6.2 merged in PR #64. Phase 6.3 built: the Google Drive storage-connector addon (start of the connector half of Milestone 6). Pure client OAuth via Google Identity Services (GIS) — independent of Supabase, so the connector works for 100%-local users too (ROADMAP §6.3). New generic
src/extensions/connectors/types.ts— a provider-agnosticStorageConnector(isConfigured/listFiles/downloadFile/uploadFile) +ConnectorFile/ConnectorUpload+ a coarseConnectorError(mirrorsMTError), written once so 6.4's OneDrive reuses it.src/extensions/connectors/gdrive/:config.ts(lightweight gate —googleClientId()fromVITE_GOOGLE_CLIENT_ID,isGdriveConfigured(),isGdriveAvailable()= configured AND registry-enabled; no GIS/REST imports so UI can decide whether to offer Drive without pulling the heavy code);driveApi.ts(the pure, unit-testable REST layer —listDriveFiles/downloadDriveFile/uploadDriveFile, each taking an OAuth token + an injectablefetchImpllikecore/mt/*, using the narrowdrive.filescope to dodge Google's restricted-scope verification, multipart upload, status→code error mapping);gis.ts(thin DOM boundary — injects the GSI script on first use, requests adrive.filetoken via popup, token held in memory only, never Dexie/localStorage);index.ts(createGdriveConnector({getToken, fetchImpl})factory pairing GIS+REST, tested with a fake token getter + fetch, plus the realgdriveConnectorsingleton). GenericConnectorFilePicker.tsxlists a connector's files, filters by anacceptpredicate, and returns the downloadedFile.core/extensions/builtins.tsregistersCONNECTOR_EXTENSION_MANIFESTS(connector.gdrive, kindstorage-connector, permissionsnetwork/credentials/filesystem);BUILTIN_MANIFESTS= MT + QA + formats + connectors. Wiring:ImportDialoggains a From Google Drive button (gated onisGdriveAvailable(); dynamically imports the connector on click → picker → sets the import file, then the existing import flow runs unchanged);ExportDocxButtongains Save to Drive (builds the same clean.docx, uploads via the connector);AddonsPagegains a Storage connectors section with a working enable/disable toggle (disabling hides both affordances).deploy.yml+.env.examplecarryVITE_GOOGLE_CLIENT_ID(public by design — a Web OAuth client id gated by the console's authorised-origins allow-list). Deviations (noted): built on the designated session branch, notclaude/revamp-phase-6-3; the connector is intentionally not hidden behind the Supabase flag (it is pure client OAuth, so it ships in the entry like the collab leases — the heavy Drive REST is still code-split into a lazy chunk, and no connector code executes until the user clicks, via the dynamicimport()+ theisGdriveAvailable()gate).docs/cloud.md§13 (Google Cloud console setup + manual verification). Tests:connectors.driveApi(list builds the right query/Bearer + maps files; downloadalt=media; multipart upload asserts metadata+media in the body; 401→auth / 429→rate_limit / network-throw mapping),connectors.gdrive(config gating on the env var; manifest registration;isGdriveAvailableANDs config with registry enablement; the factory lists/downloads-into-a-named-File/uploads through injected token+fetch),ConnectorFilePicker(lists accepted files, hides rejected, pick→download→onPick+close, error surfaced),AddonsPageextended (storage-connector section +connector.gdriverow + toggle). Verified: flag-OFFdiststill zeroGoTrueClient; the Drive REST is a separate lazy chunk. Green: 705 unit (+17) / build. No new e2e (ROADMAP §6.3 asks none; the real Drive round-trip is thedocs/cloud.md§13 manual matrix — vitest never hits GIS or the network). - 2026-07-21 — Phase 6.3 merged in PR #65. Phase 6.4 built: the OneDrive storage-connector addon (Milestone 6 complete → extensions + connectors shipped). Reuses the generic
StorageConnector/ConnectorFilePicker/ConnectorErrorseam from 6.3 — the OneDrive slice is just a second connector paired to the same picker + import/export wiring. Pure client OAuth, independent of Supabase (works for 100%-local users too), via@azure/msal-browser(added as a runtime dep, dynamic-import only likedocx, so MSAL never touches the initial bundle) withloginPopup(ROADMAP §6.4 — a popup, not a redirect, so no HashRouter interplay) against Microsoft Graph with the delegatedFiles.ReadWritescope.src/extensions/connectors/onedrive/:config.ts(lightweight gate —msalClientId()fromVITE_MS_CLIENT_ID,isOnedriveConfigured(),isOnedriveAvailable()= configured AND registry-enabled; no MSAL/Graph imports so UI can gate cheaply);graphApi.ts(the pure, unit-testable REST layer —listOnedriveFiles[root children, orsearch(q=…); folders filtered out],downloadOnedriveFile[/items/{id}/content],uploadOnedriveFile[simplePUT /root:/{name}:/content], each taking a Graph token + injectablefetchImpllikecore/mt/*, status→code error mapping);msal.ts(thin boundary — dynamicimport('@azure/msal-browser'), aPublicClientApplicationwithsessionStoragecache,acquireTokenSilentthenloginPopup; token never persisted to Dexie);index.ts(createOnedriveConnector({getToken, fetchImpl})factory + the realonedriveConnectorsingleton).core/extensions/builtins.tsadds theconnector.onedrivemanifest (kindstorage-connector, permissionsnetwork/credentials/filesystem). Wiring:ImportDialoggains a From OneDrive button beside the Drive one (both gated on theiris…Available(); each dynamically imports its connector on click → the shared picker → sets the import file);ExportDocxButtonrefactors its "Save to Drive" into a reusableSaveToCloudButtonand adds Save to OneDrive (each button owns its saving/saved/error state, connector loaded on demand); the Add-ons Storage connectors section lists OneDrive automatically (with a working toggle — no page change needed).deploy.yml+.env.example+vite-env.d.tscarryVITE_MS_CLIENT_ID(an Azure AD SPA client id — public by design, gated by the app registration's redirect-URI allow-list). Deviations (noted): built on the designated session branch, notclaude/revamp-phase-6-4; like Drive, the connector is intentionally not behind the Supabase flag (pure client OAuth) — MSAL + the Graph layer are dynamic-import only, so the entry stays free of@azure/msal-browser(verified:loginPopup/PublicClientApplicationabsent from the entry chunk; the msal library sits in its own lazy chunk reached only via the connector).docs/cloud.md§14 (Azure app-registration setup + manual verification). Tests:connectors.graphApi(list maps files + skips folders, root-children URL + Bearer,search(q=…)when queried, 401→auth; download/content;PUTupload asserts path/Content-Type/body + returns the item; network-throw mapping),connectors.onedrive(config gating onVITE_MS_CLIENT_ID; both connectors register asstorage-connector;isOnedriveAvailableANDs config with registry enablement; the factory lists/downloads-into-a-named-File/uploads through injected token+fetch),AddonsPageextended (connector.onedriverow + both connector toggles). Verified: flag-OFFdiststill zeroGoTrueClient;@azure/msal-browserabsent from the entry (its own lazy chunk). Green: 717 unit (+12) / build. No new e2e (ROADMAP §6.4 asks none; the real OneDrive round-trip is thedocs/cloud.md§14 manual matrix — vitest never loads MSAL or hits the network). Milestone 6 (extensions & connectors) complete.