Skip to content

Latest commit

 

History

History
85 lines (79 loc) · 69.2 KB

File metadata and controls

85 lines (79 loc) · 69.2 KB

Revamp status

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:

  1. Work on exactly one phase per session, chosen as: the first pending phase (top-to-bottom) whose Depends on entries are all done.
  2. If a phase is in-review, check its PR for CI failures or review comments and fix them instead of starting a new phase.
  3. Never mark a phase done yourself — only the owner merging the PR does that (the next session records the merge here).
  4. 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)

Log

  • 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_request only (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 duplicate peers-panel testid in SidebarPanel.tsx broke 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, pure extractChanges/hasPendingChanges), src/features/editor/rich/ChangeMarkNode.ts (inline ElementNode modeled on LinkNode; @lexical/mark is NOT installed so no new dep; getTextContent()→'' for deletes gives D2 semantics), richStateToOriginal() added to src/core/editor/richText.ts (inverse projection sharing the same headless engine), .rsg-change-ins/.rsg-change-del CSS, 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.richEditing to true (plain is now the opt-out; code segments stay plain), updated the settings copy, and migrated all 6 target-touching e2e specs off textarea.fill()/toHaveValue() onto contenteditable helpers in new tests/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 in RichSegmentEditor.tsx (proven 6/6 reliable, and a UX win: no layout jump on focus). Opt-out (plain) path still covered by SegmentRow.confirm/segmentCounter unit tests. Green twice consecutively: 15 e2e / 507 unit / build.
  • 2026-07-18 — Phase 1.2 CI (PR #39) surfaced a pre-existing mt-flow flake unrelated to the toolbar fix: the MT settings checkboxes update optimistically and persist to Dexie fire-and-forget, so page.goto right after a toggle could abort the write (CI showed MyMemory still running despite being unchecked). Fixed by a waitForMTPersisted helper 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' on useEditorModeStore; a global EditModeToggle beside the stage switcher + a command-palette toggle (cmd-toggle-suggesting); stable per-author id minted/persisted in profile.identity via ensureLocalAuthor() + useLocalAuthor(). TrackedChangesPlugin intercepts CONTROLLED_TEXT_INSERTION/BACKSPACE/DELETE/PASTE/CUT at CRITICAL priority when suggesting (and not mid-IME): typing wraps text in an insert ChangeMarkNode (extending an adjacent own insertion), deletion wraps the range in a delete mark (or really removes the author's own pending insertion). Pure $-helpers in src/features/editor/rich/suggest.ts.
    • Deviations from ROADMAP §4 (noted): (a) suggest helpers live in features/editor/rich/ not core/changes/ — they must construct the feature-layer ChangeMarkNode, which core/ cannot import; still pure + headlessly unit-tested. (b) Built on the designated session branch, not claude/revamp-phase-1-3.
    • Two bugs found + fixed while building: (1) a nested editor.update inside 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 via richStateToPlain(serializedState) (a fresh parse, no DOM cache) in RichSegmentEditor'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.suggest unit (6 cases: insert/extend/replace/range-delete/own-insert-removal/edge), tracked-changes.spec.ts e2e (insertion + deletion, proposed target verified from IndexedDB). Green: 513 unit (+6) / 17 e2e (+2) / build; new tests 5/5 stable.
  • 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/$resolveAllChanges live helpers + headless resolveChangeInRich/resolveAllInRich for unmounted segments (accept-insert & reject-delete keep text by unwrapping; accept-delete & reject-insert drop it). changes/resolveOps.ts orchestrates read→resolve→segmentRepo.update (kept OUT of segmentRepo so the storage layer never imports the Lexical node — a noted deviation from ROADMAP's segmentRepo.resolveChange) + listPendingChanges/resolveProjectAll. ChangesPanel reworked into two tabs: Suggestions (live pending changes from extractChanges across all segments, per-change Accept/Reject + Accept all/Reject all, click-to-jump) and History (the original version-diff view, unchanged). ChangeHoverCard gives 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 through segmentRepo.update, so the Dexie→Yjs bridge + version history observe them and the mounted editor rebuilds via its externalRich effect. Tests: changes.resolve unit (all four resolutions + resolve-all, 7 cases); tracked-changes.spec.ts e2e 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. SegmentComment gains additive parentId/anchorId/quote (type + segmentCrdt.ts mapping/reconcile, round-tripped through Yjs). CommentMarkNode (inline ElementNode wrapping text, like LinkNode; getTextContent passes through so plain target is unchanged) highlights the anchored range; .rsg-comment CSS; registered in the live editor. rich/comments.ts: live $wrapSelectionAsComment/$removeCommentMarkByAnchor + headless removeCommentMark. 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-wide CommentsPanel + a comments sidebar tab (revise stage). Two fixes while building: (1) editor.update defers 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 editor onBlur now 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 in features/ not core/ (they construct the feature-layer node). Tests: comments.threads unit (grouping + anchor removal/plain-text preservation), segmentCrdt extended (new fields round-trip), comments-anchored.spec.ts e2e (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.confirm reads the segment fresh and, if hasPendingChanges(targetRich), shows a tool message and refuses, so unaccepted suggestions never reach the TM. Next/prev tracked-change navigation: jumpToNextChange/jumpToPrevChange on the actions store (scan segments by hasPendingChanges), wired to command-palette commands (cmd-next-change/cmd-prev-change) and F8 / Shift+F8 in useGlobalShortcuts. Show/hide marks: useChangesStore.showMarks + a toggle in the ChangesPanel header; when off, EditorPage adds rsg-hide-marks to 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). Added data-focused to the segment row for nav testing. Tests: tracked-changes.spec.ts extended (+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, Block with kind/depth/ordered/InlineRun[]/table+image attrs, Asset); core/documents/fromSegments.ts (pure buildBlocksFromSegments grouping by sourceMeta.blockIndex); documentRepo/blockRepo/assetRepo. Dexie v6: documents/blocks/assets tables; migrateDocumentBlocks upgrade backfills existing monolingual projects (XLIFF skipped) and stamps Segment.blockId. blockId added to the type and segmentCrdt SCALAR_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 no sourceRuns — 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.migration v5→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 — mammoth convertToHtml with an explicit styleMap + image capture (mammoth.images.imgElement, guarded so the segmentation test's minimal mock still works), then a pure htmlToParsedDocx walker producing BOTH the flat ParsedSegment[] (contract unchanged) and structured blocks: extractRuns captures bold/italic/underline/sub/sup/links into InlineRun[]; tables become kind:'table' blocks with attrs.rows (cell runs) whose non-empty cells are translatable segments; lone-image paragraphs become kind:'image' blocks referencing a captured asset. segmentation/docx.ts now delegates to parseDocxDocument and returns .segments — the existing segmentation.docx.test passes 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 the buildBlocksFromSegments path. 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 — pure renderDocument(blocks, segments) assembling the block tree with each block's translated segment targets (ordered by sentenceIndex, source fallback + tint for untranslated), table cells mapped to segments row-major, image asset ids carried; RenderBlock/RenderCell/RenderSegmentRef model. preview/usePreviewStore.ts (open + mode: source/target/split) and preview/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 via useLiveQuery. 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.ts e2e (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 — pure targetRichToRuns decodes Lexical text-format flags into InlineRun[] with D2 accepted-preview (insertions kept, deletions dropped), hyperlink href, inline-tag placeholders. core/documents/toDocx.tsexportProjectDocx(blocks, segments, assets) reconstructs a Document via import('docx'): headings (levels), lists (bullet + decimal numbering), blockquotes (indent), code (monospace), tables (translated cells), images (ImageRun from asset blobs); target formatting from targetRich runs else plain, source fallback for untranslated. hasPendingForExport flags 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 dynamic import('@supabase/supabase-js') on first use; flowType:'pkce' + detectSessionInUrl:false pinned; configuredProviders() from VITE_AUTH_PROVIDERS, default ['google']). storage/cloud/authBootstrap.ts (D7): pure stripAuthQuery/hasAuthRedirect + maybeHandleAuthRedirect() — exchanges the PKCE ?code= (which lands before the #/route fragment) and history.replaceStates the query away before the hash router mounts; a no-op that loads no Supabase code when unconfigured. features/account/: useAuthStore (zustand; status unconfigured/loading/authenticated/unauthenticated, init() gets session + subscribes onAuthStateChange once, signInWithOAuth/signInWithMagicLink/signOut; redirects to origin+pathname, never the hash), SignInDialog (configured OAuth buttons + magic-link email + sent state), AccountMenu (TopBar control, renders null unless cloud-configured). main.tsx awaits the redirect bootstrap before render; TopBar mounts AccountMenu. 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 second pnpm build with dummy cloud env so the signed-in code path can't regress unnoticed. Verified: flag OFF → zero Supabase code in the bundle (grep for GoTrueClient/SupabaseClient empty); flag ON → Supabase isolated to a lazy chunk, absent from the entry chunk. Deviations (noted): built on the designated session branch, not claude/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 via resetModules+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). Migrations 0001_profiles + 0002_profiles_hardening applied via the Supabase MCP; security advisor clean (no lints). Added 0002_profiles_hardening.sql (pins trigger search_path, revokes RPC EXECUTE on the trigger-only functions), .env.example (real URL + publishable key — public by design; secret key never committed), and wired deploy.yml to inject VITE_SUPABASE_URL/ANON_KEY/AUTH_PROVIDERS from repo Variables (unset ⇒ still local-only). docs/cloud.md updated 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()SignInDialog filters to the natively-supported google/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). New features/account/AccountSettingsSection.tsx — the signed-in counterpart to the local ProfileSettingsSection, surfaced as a Settings → Account section that only appears when the cloud is configured (visibleNav() filters it in settings/index.tsx; UserCog icon). Signed out it offers a Sign in button (reuses SignInDialog); signed in it shows: an editable display name persisted to the profiles row via an explicit Save (dirty-gated, "Saved" confirmation), linked sign-in methods (Google/Microsoft/Apple/Email), and Sign out. useAuthStore gains profileDisplayName/identities state + loadAccount() (parallel profiles select + supabase.auth.getUserIdentities(), prefers the account name in the top-bar menu) and updateDisplayName() (upsert to profiles, 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-gated isCloudConfigured()/configuredProviders() — no static @supabase/supabase-js (verified: flag-off dist has zero GoTrueClient/SupabaseAuthClient; the lib stays behind getSupabase()'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, not claude/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_at is client-supplied so per-key LWW is meaningful across devices — no touch-trigger). Numbering deviation: ROADMAP §3.3 sketched 0002_user_settings.sql, but 0002 was taken by 0002_profiles_hardening.sql (a 3.1 follow-up), so it shipped as 0003; §3.4's personal-resources migration shifts to 0004 (noted in ROADMAP). src/storage/cloud/settingsSync.ts: SYNCED_SETTINGS_KEYS allowlist = editor.prefs / lookup.defaults / spell.dicts only — MT mt.providers excluded (API keys never sync), and so are web-search providers (keys), profile.identity (device-local author id), and semantic-TM (device model). Pure reconcileSettings(local, remote) does per-key LWW (newer updatedAt wins; 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 to useAuthStore (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. settingsRepo gained updatedAt stamping on set, a silent applyRemote(key,value,updatedAt) for pulls (no echo), getRow, and subscribeSettingsChange; SettingsRow.updatedAt? added to db.ts (unindexed — no Dexie version bump; pre-3.3 rows read as "oldest"). main.tsx starts the reconciler via a guarded dynamic import('./storage/cloud/settingsSync') only when isCloudConfigured(). Dexie stays the read path — settings sections use useLiveQuery, so a pull's applyRemote repaints the UI. Verified: flag-OFF dist has zero Supabase code and no user_settings/SYNCED_SETTINGS (settingsSync tree-shaken); flag-ON keeps GoTrueClient out of the entry chunk and puts settingsSync + user_settings in their own lazy chunks. Deviations (noted): built on the designated session branch, not claude/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/localStorage persist store 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; pullAndReconcile applies remote-wins + pushes local-wins against real fake-indexeddb Dexie with a mock client; pushKey allowlist 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 envelope id 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 sketched 0003_personal_resources.sql, but 0003 was taken by 0003_user_settings.sql (3.3), so it shipped as 0004; deletes are soft (deleted=true upsert), 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 a RowSyncAdapter (glossary + tm). Pure-ish pushResource (upsert personal rows changed since the cursor + soft-delete tombstones, then clear pushed tombstones), pullResource (fetch updated_at > cursor, apply under per-row LWW: newer remote wins, same-or-newer local kept — which also skips our own just-pushed rows — remote deleted removes locally; returns the max ts as the next cursor), syncResource (push→pull→persist cursor via settingsRepo, a device-local key not in the 3.3 synced allowlist), syncAllResources. Bundled corpora never sync: tm rows carry a corpusId and are filtered out of localChangedSince; the term bank is entirely user-authored, and corpusRepo writes db.tm directly (bypassing tmRepo), so corpus install/uninstall records no tombstones. Dexie v7: updatedAt index on glossary/tm + new syncTombstones table (compound PK [resource+rowId]); backfillSyncTimestamps upgrade stamps existing rows so a signed-in device pushes them once. glossaryRepo/tmRepo now stamp updatedAt on every write, record a tombstone on delete, and fan out a resource-change event — all via storage/cloud/rowSyncState.ts (a tiny storage-layer seam: setRowSyncEnabled/recordTombstone/subscribeResourceChange, so the repos never import the engine or features/; tombstones are gated on being signed in, keeping local-only inert). TMEntry/GlossaryEntry gain optional updatedAt. Wiring: startRowSync() (guarded dynamic import from main.tsx, only when isCloudConfigured()) subscribes to useAuthStore (enable tombstones + full reconcile once per new sign-in) and to resource changes (debounced 800ms push/pull); stopRowSync() tears down. Verified: flag-OFF dist has zero GoTrueClient/personal_glossary/supabase-js (only the local syncTombstones schema + minified rowSyncState seam remain — intended local code); flag-ON puts the engine + personal_glossary in a lazy rowSync-*.js chunk with GoTrueClient out of the entry. Deviations (noted): built on the designated session branch, not claude/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; syncResource round-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). useSidebarPanelStore gains a persisted LWW updatedAt stamped on every layout/collapsed mutation (setTab/setLayout/showPanel/togglePanel/toggleCollapsed/movePanel/resetLayout; transient open/active-tab never bump it) + partialize/merge carry it; defaultLayout exported for rehydration backfill. New features/editor/layoutSync.ts syncs the layout through the same user_settings cloud table under the sidebar.layout key with the same client-supplied LWW: syncLayout(client, userId) (pull-newer applies to the live store via setState — rehydrating machine B without a reload, backfilling any stage a stale remote misses — else push local-newer), plus startLayoutSync()/stopLayoutSync() wiring (reconcile once per new sign-in; debounced push on store change, guarded by an applyingRemote flag so a pull's setState never echoes back as a push). main.tsx starts it via a guarded dynamic import only when isCloudConfigured(). Deviation (noted in ROADMAP §4): the 3.3 settingsSync engine is bound to Dexie settingsRepo keys (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 reusing user_settings + LWW rather than a settingsSync "adapter". No 3.3 code refactored (keeps that phase's tests untouched). settingsSync never sees the sidebar.layout row (its .in() query is scoped to SYNCED_SETTINGS_KEYS). Verified: flag-OFF dist has zero GoTrueClient/syncLayout/user_settings (layoutSync tree-shaken; the only sidebar.layout substring is the store's verbalis.sidebar.layout localStorage key — local code); flag-ON keeps GoTrueClient out of the entry and puts layoutSync in its own lazy chunk. Built on the designated session branch, not claude/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.sqlprojects, project_members(role project_role enum: project_manager|translator|revisor), ydoc_state(state bytea, seq), append-only ydoc_updates(update bytea, author_id default auth.uid()) (tamper-evident attribution, D8), member-scoped RLS via is_project_member/has_project_role helpers, a private project-files storage bucket. 0006_projects_helpers_private.sql — moves the two SECURITY DEFINER membership helpers into a non-API private schema (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 sketched 0004_projects.sql, but 0003/0004 were taken by the 3.3/3.4 migrations, so this shipped as 0005 + a 0006 hardening follow-up. src/core/types: ProjectRole + additive Project.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 (insertCloudProject seeds the project row + owner project_manager membership + ydoc_state snapshot; listCloudProjects; fetchCloudProject → summary+role; fetchYdocState → decoded bytes) + orchestration (publishProject encodes the project's live Yjs doc via acquireProjectDoc+Y.encodeStateAsUpdate, inserts, stamps project.cloud; openCloudProject fetches metadata+snapshot, hydrates a fresh local Dexie project + its segments via readAllSegments, reusing an existing local copy rather than duplicating). Strictly additive — getSupabase() dynamic, gated on isCloudConfigured(). UI (self-gating, cloud logic dynamically imported so the projects route stays lean): features/projects/cloud/CloudControls.tsx (CloudBadge, owner PublishCloudButton on a card, OpenFromCloudButton header action — all render null unless configured + signed-in) + OpenCloudProjectDialog.tsx (lists member projects, opens → navigates to the local copy); wired into ProjectCard + projects index.tsx. Verified: flag-OFF dist has zero GoTrueClient; flag-ON keeps Supabase in a lazy chunk out of the entry. Deviations (noted): built on the designated session branch, not claude/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 into WireChunks (Realtime is JSON + ~250KB/msg, so binary payloads are base64 +33% and split), and an order-independent ChunkReassembler that yields a SyncMessage once all chunks of a message id arrive (single-chunk pass-through; payloadless kinds hello/bye/state-request carry d:''). src/storage/sync/transport/supabaseRealtime.tsSupabaseRealtimeTransport implements SyncTransport over a private Realtime broadcast channel project:{cloudId} (broadcast:{self:false}, so no self-echo, matching BroadcastChannel); getSupabase() is dynamic so no eager supabase-js. Outbound updates are coalesced by OutboundUpdateBatcher (debounce 300ms → Y.mergeUpdates → one broadcast); other kinds send immediately; sends before SUBSCRIBED buffer in an outbox flushed on subscribe. The channel is injectable (channelProvider) as a test seam. createTransport(projectId, {cloudId?}) gains a cloud branch (Supabase when cloudId set; BroadcastChannel/Tauri paths byte-identical otherwise); syncManager.startProjectSync computes cloudId from project.cloud when signed-in + isCloudConfigured(), else undefined. Deviations (noted): built on the designated session branch, not claude/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 (OutboundUpdateBatcher merges a burst into one flush that replays both edits via fake timers + no-fire-after-destroy; transport delivers a 50KB multi-chunk state to the other peer only via a linked in-memory channel pair — caught + fixed a real ordering bug where the outbox flushed before this.channel was assigned; pre-subscribe sends buffer then flush). Green: 629 unit (+11) / 27 e2e twice / build (flag-off has zero GoTrueClient; 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 — the claim_compaction(project_id, expected_seq, state, up_to_id) RPC (SECURITY DEFINER, membership-checked): atomically bumps the ydoc_state.seq generation only while it still matches expected_seq, installs the new compacted snapshot, and prunes ydoc_updates with id <= up_to_id. Numbering deviation: 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 + 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's bytea.ts) + the createYdocPersistence({cloudId, doc, client}) loop: catch-up fetches ydoc_state + the whole ydoc_updates log, merges (Y.mergeUpdates) and applies as ORIGIN_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) when localAheadOf); append buffers local doc.on('update') deltas (ignoring ORIGIN_REMOTE), debounce-merges them (500ms) into one attributed ydoc_updates row, 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 optimistically claim_compactions (loser gets null and 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.startProjectSync starts the loop (dynamic startYdocPersistence, only when cloudId set + signed-in + isCloudConfigured()) alongside the realtime transport, kicks off a non-blocking catchUp(), stores the handle on the ref-counted session, and destroys it in stopProjectSync; local-only projects never touch it. Deviations (noted): built on the designated session branch, not claude/revamp-phase-4-3; the RPC signature widening above; live reconnect re-sync (re-hello/re-catchUp when 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 returns null; fetchSnapshot/fetchUpdates bytea round-trips). Green: 636 unit (+7) / build (flag-off: zero GoTrueClient/claim_compaction — the loop tree-shakes out entirely when the cloud is unconfigured; flag-on: GoTrueClient and 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.ts gains a stable userId (Supabase user id when signed in, else the device-local authorId — so attribution survives a peer reconnecting under a fresh peerId, D8) and a CaretRange on both PresenceWire and PeerPresence; syncSession broadcasts them (setActiveSegment(id, caret?)) and syncManager resolves userId via ensureLocalAuthor()/auth. src/storage/sync/lease.ts — pure segmentLease(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 (its activeSegmentId moves off) and the next-lowest peer takes over. Wiring: usePresenceStore carries selfPeerId; useProjectSync publishes it; EditorPage computes leaseHolderFor(segmentId) and passes leaseLockedBy to each SegmentRow, which renders the target read-only (a separate editLocked = 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-attributed ChangeMarkNodes. reverseBridge.ts stale-LWW guard (risk #5): target (char-level Y.Text) and targetRich are separate CRDT fields, so a concurrent merge can land a targetRich that no longer derives to the merged plain target; plain is authoritative, so when richStateToPlain(targetRich) !== target the guard drops the stale rich and the editor rebuilds it from plain. Deviations (noted): built on the designated session branch, not claude/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 carries caret as 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 e2e lan-collaboration.spec.ts extended (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 zero GoTrueClient; 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.ts gains pure mapOffsetToNode(spans, offset) — the caret counterpart to mapTokenToNode, 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.tsselectionToCaretRange(selection) converts a live Lexical selection to plain anchor/focus offsets and buildLeafSpans(paragraph) produces the ordered leaf spans, both reproducing exactly the plain projection richStateToPlain derives (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 in RichSegmentEditor beside the spell overlay) paints a coloured caret + name label for each remote peer whose presence puts them on the segment with a caret, measured via getElementByKey+DOM range like SpellUnderlinePlugin — it never mutates the node tree and every measurement is guarded (renders nothing on failure). Reporting: the focused segment's editor (the only one given onCaret) computes selectionToCaretRange in its update listener and reports it through EditorPageuseProjectSyncsetActiveSegment(id, caret), throttled to one send per 100ms; useProjectSync.setActiveSegment now forwards the caret. Deviations (noted): built on the designated session branch, not claude/revamp-phase-4-4-1. Tests: spell.offsets extended (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 e2e lan-collaboration.spec.ts extended (a peer's caret renders as a live remote cursor in the other tab). Green: 653 unit (+8) / 29 e2e (+1) / build (flag-off zero GoTrueClient; 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 — adds profiles.email (backfilled from auth.users + seeded on signup) and a co-member profiles SELECT policy via a new private.shares_project(other) SECURITY DEFINER helper (the "broader read for member lookup" 0001 deferred to 5.1); switches project_members update/delete to project_manager-only (private.has_project_role), keeping insert reachable by the owner's publish self-seed OR a PM; a enforce_min_one_pm BEFORE UPDATE/DELETE trigger that blocks removing/demoting a project's last manager; and the invite_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 sketched 0006_member_policies.sql, but 0006/0007 were taken (4.1 helpers + 4.3 compaction), so it ships as 0008; 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-injected listMembers (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 new ManageMembersButton on 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, not claude/revamp-phase-5-1; the RLS/trigger/RPC enforcement is server-side, proven by the unit suite's injected-client mirror + the docs/cloud.md §11 manual matrix (vitest never hits real Supabase). Verified: flag-OFF dist has zero GoTrueClient (the members code is a lazy members-*.js chunk 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 a final project; translator edits directly in translation but is forced to suggest in review, and must suggest on an already-reviewed segment; revisor waits during translation then edits + accepts/rejects in review; accept/reject is revisor/PM-only), plus effectiveRoleStage(cloud) (local-only → PM-of-self: project_manager/translation, every capability granted → zero regression) and forcesSuggesting(role, stage). supabase/migrations/0009_workflow.sql — a project_stage enum + projects.stage (default translation) + deadline; writes are project_manager-only through the existing projects_update policy (no new policy). Numbering deviation: ROADMAP §5.2 sketched 0007_workflow.sql, but 0007/0008 were taken (compaction + members), so it ships as 0009. WorkflowStage added to core/types; Project.cloud gains stage?/deadline? (read on open). projectCloud.tsfetchCloudProject now returns stage/deadline, openCloudProject stores them, and setCloudProjectStage + the changeProjectStage(projectId, stage) orchestration (writes Postgres — RLS rejects a non-PM — then patches the local project.cloud.stage). Wiring: a tiny features/editor/workflow/useWorkflowStore (default permissive PM/translation) is published by EditorPage from effectiveRoleStage(project.cloud); EditModeToggle gains a forced prop (locked + disabled) driven by forcesSuggesting, and EditorPage pins editMode='suggesting' when forced; ChangesPanel (accept-all/reject-all + per-suggestion buttons) and ChangeHoverCard hide their resolve affordances when !canResolveChanges; a PM stage <Select> (+ read-only badge for non-PMs) lives in ManageMembersDialog. Deviations (noted): built on the designated session branch, not claude/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.projectCloud extended (stage read defaults to translation + setCloudProjectStage update). Verified: flag-OFF dist has zero GoTrueClient; 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). ProjectVersion gains authorId? (the stable profile.identity author id — the same identity tracked-change marks, comments, and presence use, so attribution is unified across the app, D8) and approval?: { authorId, authorName, at }. versionRepo.capture now stamps authorId on every version (via ensureLocalAuthor()), and a new versionRepo.signOff(projectId, liveDoc?) records a named version labeled Approved by {name} with the approval metadata set (its presence marks an approval milestone). Wiring: useWorkflowStore gains a useCanReview() selector (revisor/PM; local-only → PM-of-self → true); VersionHistoryPanel shows a Sign off button beside Save version only when canReview, and renders approval versions with an accent BadgeCheck + their Approved by … label. No migration + no schema bump (versions are local Dexie; authorId/approval are additive unindexed fields; pre-5.3 rows simply lack them). Deviations (noted): built on the designated session branch, not claude/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: versionRepo extended (every capture stamps a stable authorId consistent across captures; signOff records a labeled approval version whose approval.authorId/at match the version's authorId/createdAt, while a plain saveNamed has no approval). Verified: flag-OFF dist has zero GoTrueClient; 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.tsExtensionManifest {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) singleton extensionRegistry: 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, and subscribe. src/core/extensions/builtins.ts — the four MT manifests (mt.mymemory/mt.libretranslate/mt.ollama/mt.claude; permissions network + credentials for the keyed ones) + idempotent registerBuiltinExtensions(). src/extensions/mt/index.ts — thin wrappers pairing each manifest to its existing core/mt provider (no provider logic moved — the existing MT unit tests are untouched and keep proving behaviour) + registryEnabledMtProviderIds(). core/mt/index.ts gains MT_EXTENSION_ID + isProviderExtensionEnabled(id) (defaults enabled when the manifest isn't registered), and enabledProviders now ANDs the user setting with the registry — so disabling the addon removes the provider; useMTSettings.enabledIds does the same and re-derives via useSyncExternalStore(extensionRegistry.subscribe) so the MT panel updates live. Wiring: features/addons/registryPersistence.ts (registerExtensions() sync + startExtensionRegistry() hydrating the disabled set from the new device-local extensions.disabled settings key and persisting subsequent changes), started from main.tsx before render. Deviations (noted): built on the designated session branch, not claude/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.md unchanged (no migration). Tests: extensions.registry (register/list-by-kind/default-enabled/toggle/unknown-id/hydrate+notify/unregister), extensions.mt (the four register as mt-provider; wrappers point at the real MT_PROVIDERS; disabling mt.claude removes it from enabledProviders even with the user setting on — the DoD — and re-enabling restores it). Verified: flag-OFF dist Supabase-free; the untouched MT unit tests + mt-flow e2e 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.ts now also registers QA_EXTENSION_MANIFESTS (one qa.<code> per QA rule, kind qa-rule, no permissions — labels/descriptions from QA_RULE_LABELS) and FORMAT_EXTENSION_MANIFESTS (format.xliff/format.docx/format.tmx/format.tbx/format.csv, kinds import-format/export-format, filesystem permission); BUILTIN_MANIFESTS = MT + QA + formats, all registered by registerBuiltinExtensions(). core/qa/registryRules.tsisQaRuleEnabled(code) (registry, defaults on when the manifest isn't registered so runQA stays pure and its tests are untouched) + effectiveQaRules(toggles) which ANDs the per-project rule toggles with the registry; QAPanel runs runQA with effectiveQaRules(settings.qaRules) and re-derives via useSyncExternalStore(extensionRegistry.subscribe) so toggling a QA addon changes QA output live. features/addons/AddonsPage.tsx — the /addons catalogue: 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 in core/*; format-level gating is a follow-up, kept out to avoid touching every import/export path). New /addons route (lazy AddonsPage) + a Puzzle nav item. Deviations (noted): built on the designated session branch, not claude/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; disabling qa.double_space forces it off + removes the finding from runQA output — the DoD; others unaffected), AddonsPage (lists MT + QA + format sections/rows; toggling a QA addon flips registry enablement; formats have no toggle). Verified: flag-OFF dist Supabase-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-agnostic StorageConnector (isConfigured/listFiles/downloadFile/uploadFile) + ConnectorFile/ConnectorUpload + a coarse ConnectorError (mirrors MTError), written once so 6.4's OneDrive reuses it. src/extensions/connectors/gdrive/: config.ts (lightweight gate — googleClientId() from VITE_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 injectable fetchImpl like core/mt/*, using the narrow drive.file scope 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 a drive.file token 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 real gdriveConnector singleton). Generic ConnectorFilePicker.tsx lists a connector's files, filters by an accept predicate, and returns the downloaded File. core/extensions/builtins.ts registers CONNECTOR_EXTENSION_MANIFESTS (connector.gdrive, kind storage-connector, permissions network/credentials/filesystem); BUILTIN_MANIFESTS = MT + QA + formats + connectors. Wiring: ImportDialog gains a From Google Drive button (gated on isGdriveAvailable(); dynamically imports the connector on click → picker → sets the import file, then the existing import flow runs unchanged); ExportDocxButton gains Save to Drive (builds the same clean .docx, uploads via the connector); AddonsPage gains a Storage connectors section with a working enable/disable toggle (disabling hides both affordances). deploy.yml + .env.example carry VITE_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, not claude/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 dynamic import() + the isGdriveAvailable() gate). docs/cloud.md §13 (Google Cloud console setup + manual verification). Tests: connectors.driveApi (list builds the right query/Bearer + maps files; download alt=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; isGdriveAvailable ANDs 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), AddonsPage extended (storage-connector section + connector.gdrive row + toggle). Verified: flag-OFF dist still zero GoTrueClient; 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 the docs/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/ConnectorError seam 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 like docx, so MSAL never touches the initial bundle) with loginPopup (ROADMAP §6.4 — a popup, not a redirect, so no HashRouter interplay) against Microsoft Graph with the delegated Files.ReadWrite scope. src/extensions/connectors/onedrive/: config.ts (lightweight gate — msalClientId() from VITE_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, or search(q=…); folders filtered out], downloadOnedriveFile [/items/{id}/content], uploadOnedriveFile [simple PUT /root:/{name}:/content], each taking a Graph token + injectable fetchImpl like core/mt/*, status→code error mapping); msal.ts (thin boundary — dynamic import('@azure/msal-browser'), a PublicClientApplication with sessionStorage cache, acquireTokenSilent then loginPopup; token never persisted to Dexie); index.ts (createOnedriveConnector({getToken, fetchImpl}) factory + the real onedriveConnector singleton). core/extensions/builtins.ts adds the connector.onedrive manifest (kind storage-connector, permissions network/credentials/filesystem). Wiring: ImportDialog gains a From OneDrive button beside the Drive one (both gated on their is…Available(); each dynamically imports its connector on click → the shared picker → sets the import file); ExportDocxButton refactors its "Save to Drive" into a reusable SaveToCloudButton and 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.ts carry VITE_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, not claude/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/PublicClientApplication absent 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; PUT upload asserts path/Content-Type/body + returns the item; network-throw mapping), connectors.onedrive (config gating on VITE_MS_CLIENT_ID; both connectors register as storage-connector; isOnedriveAvailable ANDs config with registry enablement; the factory lists/downloads-into-a-named-File/uploads through injected token+fetch), AddonsPage extended (connector.onedrive row + both connector toggles). Verified: flag-OFF dist still zero GoTrueClient; @azure/msal-browser absent 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 the docs/cloud.md §14 manual matrix — vitest never loads MSAL or hits the network). Milestone 6 (extensions & connectors) complete.