From 138b21ee54fb6aa41b74d32ca49161725183b4e8 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 22 Jul 2026 00:32:55 +0000
Subject: [PATCH 1/8] =?UTF-8?q?Phase=20A=20=E2=80=94=20corrections:=20cont?=
=?UTF-8?q?act=20email=20+=20personal=20domain?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes the contact email (PR #67 changed it the wrong way) and moves the
personal-site link to the new britx.me domain.
- Email pedrohbrito@me.com -> pedrobritx@gmail.com in the canonical
AUTHOR_EMAIL (features/about/links.ts), README license contact, and
LICENSE.md.
- "Website" brand link -> https://britx.me (was pedrobritx.github.io/EwP);
updated tests/unit/about.test.tsx which guards the href.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_013kzE8hPSJxu19tzs7kuMMa
---
LICENSE.md | 2 +-
README.md | 2 +-
src/features/about/links.ts | 6 +++---
tests/unit/about.test.tsx | 2 +-
4 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/LICENSE.md b/LICENSE.md
index 18aa994..526a6c9 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -49,7 +49,7 @@ If you are an Organization (any entity larger than one person), please contact
the Author to arrange terms. Pricing is set individually and scaled to the size
and nature of your use:
-- **Email:** pedrohbrito@me.com
+- **Email:** pedrobritx@gmail.com
- **GitHub:**
- **LinkedIn:**
diff --git a/README.md b/README.md
index 40d2cc6..6777d3c 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,7 @@ license — see [`LICENSE.md`](LICENSE.md):
**credit the developer** and keep the link back to this project.
- **Organizations — get in touch.** Any team or company larger than one person
needs a commercial license. Pricing is set individually and fairly —
- contact **pedrohbrito@me.com**.
+ contact **pedrobritx@gmail.com**.
---
diff --git a/src/features/about/links.ts b/src/features/about/links.ts
index 5a48244..5f587af 100644
--- a/src/features/about/links.ts
+++ b/src/features/about/links.ts
@@ -11,7 +11,7 @@ export interface BrandLink {
/** Canonical project + author links, shared by the About page and footer. */
export const REPO_URL = 'https://github.com/pedrobritx/verbalis'
export const AUTHOR_NAME = 'Pedro Brito'
-export const AUTHOR_EMAIL = 'pedrohbrito@me.com'
+export const AUTHOR_EMAIL = 'pedrobritx@gmail.com'
export const LICENSE_URL = `${REPO_URL}/blob/main/LICENSE.md`
export const BRAND_LINKS: BrandLink[] = [
@@ -29,8 +29,8 @@ export const BRAND_LINKS: BrandLink[] = [
},
{
label: 'Website',
- description: 'pedrobritx — portfolio',
- href: 'https://pedrobritx.github.io/EwP/',
+ description: 'Pedro Brito — britx.me',
+ href: 'https://britx.me',
icon: Globe,
},
{
diff --git a/tests/unit/about.test.tsx b/tests/unit/about.test.tsx
index f6d92d6..dd5f34d 100644
--- a/tests/unit/about.test.tsx
+++ b/tests/unit/about.test.tsx
@@ -20,7 +20,7 @@ describe('AboutPage', () => {
const hrefs = Array.from(document.querySelectorAll('a')).map((a) => a.getAttribute('href'))
expect(hrefs).toContain('https://github.com/pedrobritx/verbalis')
expect(hrefs).toContain('https://www.linkedin.com/in/pedrobritx/')
- expect(hrefs).toContain('https://pedrobritx.github.io/EwP/')
+ expect(hrefs).toContain('https://britx.me')
expect(hrefs).toContain('https://buymeacoffee.com/pedrobritx')
})
})
From 258fd2369c241eec5015d6a4db58977b711b5807 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 22 Jul 2026 00:38:54 +0000
Subject: [PATCH 2/8] =?UTF-8?q?Phase=20B=20=E2=80=94=20quick=20lookup=20fo?=
=?UTF-8?q?llows=20the=20open=20project's=20languages?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The quick lookup seeded its languages from the global lookup defaults,
ignoring the project you're working in; the import dialog hardcoded en→es.
Both now use the context that's already available.
- useQuickLookupStore carries the active project's languages (published by
EditorPage while mounted, cleared on unmount) and seeds openWith() from
them; an explicit openWith(langs) argument still wins. TranslateWorkspace
gains initialSourceLang/initialTargetLang props that take precedence over
the global defaults, with auto-detection/manual selection unchanged.
- ImportDialog seeds its language pair from the saved lookup defaults /
last-used languages (distinct fallback pt→en for this PT↔EN-oriented tool
instead of a fixed en→es) and persists the chosen pair on import so the
next import and the lookup remember it.
- New tests/unit/lookup.store.test.ts covers the seeding precedence.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_013kzE8hPSJxu19tzs7kuMMa
---
src/features/editor/EditorPage.tsx | 11 ++++
src/features/import/ImportDialog.tsx | 32 +++++++++++-
src/features/lookup/QuickLookupDialog.tsx | 9 +++-
src/features/lookup/useQuickLookupStore.ts | 31 +++++++++--
src/features/translate/TranslateWorkspace.tsx | 14 +++--
tests/e2e/import-and-edit.spec.ts | 3 +-
tests/unit/lookup.store.test.ts | 52 +++++++++++++++++++
7 files changed, 143 insertions(+), 9 deletions(-)
create mode 100644 tests/unit/lookup.store.test.ts
diff --git a/src/features/editor/EditorPage.tsx b/src/features/editor/EditorPage.tsx
index 36a9661..4232cf0 100644
--- a/src/features/editor/EditorPage.tsx
+++ b/src/features/editor/EditorPage.tsx
@@ -34,6 +34,7 @@ import { AddTermDialog } from './glossary/AddTermDialog'
import { GlossaryEditController } from './glossary/GlossaryEditController'
import { ConcordanceDialog } from './concordance/ConcordanceDialog'
import { useProjectDialogsStore } from '@/features/projects/useProjectDialogsStore'
+import { useQuickLookupStore } from '@/features/lookup/useQuickLookupStore'
import { translateWith, resolveDefaultProvider, MTError } from '@/core/mt'
import {
getMTSettings,
@@ -108,6 +109,7 @@ export default function EditorPage() {
const openFindReplace = useFindReplaceStore((s) => s.setOpen)
const openAnalysis = useAnalysisStore((s) => s.setOpen)
const openProjectDialog = useProjectDialogsStore((s) => s.open)
+ const setLookupProjectLangs = useQuickLookupStore((s) => s.setProjectLangs)
const [toolMsg, setToolMsg] = useState(null)
const registerHandle = useCallback((index: number, handle: SegmentEditorHandle | null) => {
@@ -127,6 +129,15 @@ export default function EditorPage() {
return () => resetWorkflow()
}, [project?.cloud?.role, project?.cloud?.stage, setWorkflow, resetWorkflow])
+ // Publish the project's languages so every quick-lookup trigger (top bar,
+ // command palette, Ctrl-selection) defaults to them instead of the global
+ // lookup defaults. Cleared on unmount so lookups outside a project fall back.
+ useEffect(() => {
+ if (!project) return
+ setLookupProjectLangs({ source: project.sourceLang, target: project.targetLang })
+ return () => setLookupProjectLangs(null)
+ }, [project?.sourceLang, project?.targetLang, setLookupProjectLangs])
+
// A translator in the review stage may only suggest — pin the edit mode.
useEffect(() => {
if (forceSuggest) setEditMode('suggesting')
diff --git a/src/features/import/ImportDialog.tsx b/src/features/import/ImportDialog.tsx
index 6343c2d..fdde9e7 100644
--- a/src/features/import/ImportDialog.tsx
+++ b/src/features/import/ImportDialog.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import {
Dialog,
DialogContent,
@@ -18,6 +18,11 @@ import type { ConnectorFile, StorageConnector } from '@/extensions/connectors/ty
import { isGdriveAvailable } from '@/extensions/connectors/gdrive/config'
import { isOnedriveAvailable } from '@/extensions/connectors/onedrive/config'
import { useImportProject } from './useImportProject'
+import {
+ getLookupSettings,
+ settingsRepo,
+ LOOKUP_SETTINGS_KEY,
+} from '@/storage/repositories/settingsRepo'
interface ImportDialogProps {
open: boolean
@@ -54,6 +59,25 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
const gdrive = isGdriveAvailable()
const onedrive = isOnedriveAvailable()
+ // Seed the language pair from the user's saved lookup defaults / last-used
+ // languages when the dialog opens, instead of a fixed guess. Falls back to a
+ // sensible distinct pair (this is a PT↔EN-oriented tool) so the monolingual
+ // "source ≠ target" guard is satisfied out of the box.
+ useEffect(() => {
+ if (!open) return
+ void getLookupSettings().then((l) => {
+ const target = l.defaultTargetLang
+ const source =
+ l.lastSourceLang && l.lastSourceLang !== target
+ ? l.lastSourceLang
+ : target === 'en'
+ ? 'pt'
+ : 'en'
+ setSourceLang(source)
+ setTargetLang(target)
+ })
+ }, [open])
+
function reset() {
setName('')
setSourceLang('en')
@@ -96,6 +120,12 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
if (!bilingual && sourceLang === targetLang) return
try {
await importProject({ file, name: name.trim(), sourceLang, targetLang })
+ // Remember the working languages so the next import and the quick lookup
+ // default to them.
+ void settingsRepo.set(LOOKUP_SETTINGS_KEY, {
+ defaultTargetLang: targetLang,
+ lastSourceLang: sourceLang,
+ })
reset()
onOpenChange(false)
} catch {
diff --git a/src/features/lookup/QuickLookupDialog.tsx b/src/features/lookup/QuickLookupDialog.tsx
index 99d9c30..10a948a 100644
--- a/src/features/lookup/QuickLookupDialog.tsx
+++ b/src/features/lookup/QuickLookupDialog.tsx
@@ -6,6 +6,8 @@ import { useQuickLookupStore } from './useQuickLookupStore'
export function QuickLookupDialog() {
const open = useQuickLookupStore((s) => s.open)
const prefill = useQuickLookupStore((s) => s.prefill)
+ const sourceLang = useQuickLookupStore((s) => s.sourceLang)
+ const targetLang = useQuickLookupStore((s) => s.targetLang)
const close = useQuickLookupStore((s) => s.close)
return (
@@ -52,7 +54,12 @@ export function QuickLookupDialog() {
-
+
diff --git a/src/features/lookup/useQuickLookupStore.ts b/src/features/lookup/useQuickLookupStore.ts
index f4560d4..ab95b0b 100644
--- a/src/features/lookup/useQuickLookupStore.ts
+++ b/src/features/lookup/useQuickLookupStore.ts
@@ -1,15 +1,40 @@
import { create } from 'zustand'
+export interface LookupLangs {
+ source: string
+ target: string
+}
+
interface QuickLookupState {
open: boolean
prefill: string
- openWith: (prefill?: string) => void
+ /** Effective languages to seed the lookup with (project langs when opened from
+ * inside a project, else undefined → fall back to the global lookup defaults). */
+ sourceLang?: string
+ targetLang?: string
+ /** The active project's languages, published by the editor while mounted so
+ * every lookup trigger defaults to them without prop-drilling. */
+ projectLangs: LookupLangs | null
+ openWith: (prefill?: string, langs?: LookupLangs) => void
+ setProjectLangs: (langs: LookupLangs | null) => void
close: () => void
}
-export const useQuickLookupStore = create((set) => ({
+export const useQuickLookupStore = create((set, get) => ({
open: false,
prefill: '',
- openWith: (prefill = '') => set({ open: true, prefill }),
+ sourceLang: undefined,
+ targetLang: undefined,
+ projectLangs: null,
+ openWith: (prefill = '', langs) => {
+ const effective = langs ?? get().projectLangs ?? undefined
+ set({
+ open: true,
+ prefill,
+ sourceLang: effective?.source,
+ targetLang: effective?.target,
+ })
+ },
+ setProjectLangs: (projectLangs) => set({ projectLangs }),
close: () => set({ open: false, prefill: '' }),
}))
diff --git a/src/features/translate/TranslateWorkspace.tsx b/src/features/translate/TranslateWorkspace.tsx
index 22d4989..57f781f 100644
--- a/src/features/translate/TranslateWorkspace.tsx
+++ b/src/features/translate/TranslateWorkspace.tsx
@@ -34,6 +34,10 @@ interface TranslateWorkspaceProps {
prefill?: string
autoFocus?: boolean
testIdPrefix?: string
+ /** Seed languages (e.g. the open project's), taking precedence over the global
+ * lookup defaults. Auto-detection and manual selection still apply after. */
+ initialSourceLang?: string
+ initialTargetLang?: string
}
export function TranslateWorkspace({
@@ -41,6 +45,8 @@ export function TranslateWorkspace({
prefill = '',
autoFocus = true,
testIdPrefix = 'quick-lookup',
+ initialSourceLang,
+ initialTargetLang,
}: TranslateWorkspaceProps) {
const online = useNetworkStatus()
@@ -74,13 +80,15 @@ export function TranslateWorkspace({
if (cancelled) return
setMtSettings(mt)
setLookupSettings(lookup)
- setTargetLang(lookup.defaultTargetLang)
- if (lookup.lastSourceLang) setSourceLang(lookup.lastSourceLang)
+ // Injected (project) languages win over the global lookup defaults.
+ setTargetLang(initialTargetLang ?? lookup.defaultTargetLang)
+ const seedSource = initialSourceLang ?? lookup.lastSourceLang
+ if (seedSource) setSourceLang(seedSource)
})
return () => {
cancelled = true
}
- }, [active])
+ }, [active, initialSourceLang, initialTargetLang])
useEffect(() => {
if (!active) return
diff --git a/tests/e2e/import-and-edit.spec.ts b/tests/e2e/import-and-edit.spec.ts
index 0d72b0b..c2972b1 100644
--- a/tests/e2e/import-and-edit.spec.ts
+++ b/tests/e2e/import-and-edit.spec.ts
@@ -14,7 +14,8 @@ test('import a markdown file, edit a segment, confirm with Ctrl+Enter', async ({
await page.setInputFiles('#import-file', FIXTURE)
await expect(page.locator('#import-name')).toHaveValue('sample')
- // Defaults are en → es; just submit.
+ // Default pair (pt → en with no saved lookup history) is source ≠ target, so
+ // the monolingual guard is satisfied — just submit.
await page.getByRole('button', { name: 'Import', exact: true }).click()
await expect(page).toHaveURL(/#\/project\//)
diff --git a/tests/unit/lookup.store.test.ts b/tests/unit/lookup.store.test.ts
new file mode 100644
index 0000000..26e70ac
--- /dev/null
+++ b/tests/unit/lookup.store.test.ts
@@ -0,0 +1,52 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { useQuickLookupStore } from '@/features/lookup/useQuickLookupStore'
+
+/**
+ * The quick-lookup store seeds its languages from (in precedence order) an
+ * explicit `openWith` argument, then the active project's languages published by
+ * the editor, then nothing (so the workspace falls back to the global lookup
+ * defaults). This proves that precedence.
+ */
+describe('useQuickLookupStore language seeding', () => {
+ beforeEach(() => {
+ useQuickLookupStore.setState({
+ open: false,
+ prefill: '',
+ sourceLang: undefined,
+ targetLang: undefined,
+ projectLangs: null,
+ })
+ })
+
+ it('leaves languages unset when no project langs and no explicit langs', () => {
+ useQuickLookupStore.getState().openWith('hello')
+ const s = useQuickLookupStore.getState()
+ expect(s.open).toBe(true)
+ expect(s.prefill).toBe('hello')
+ expect(s.sourceLang).toBeUndefined()
+ expect(s.targetLang).toBeUndefined()
+ })
+
+ it('uses the published project languages when present', () => {
+ useQuickLookupStore.getState().setProjectLangs({ source: 'pt', target: 'en' })
+ useQuickLookupStore.getState().openWith('termo')
+ const s = useQuickLookupStore.getState()
+ expect(s.sourceLang).toBe('pt')
+ expect(s.targetLang).toBe('en')
+ })
+
+ it('lets an explicit openWith argument win over the project languages', () => {
+ useQuickLookupStore.getState().setProjectLangs({ source: 'pt', target: 'en' })
+ useQuickLookupStore.getState().openWith('mot', { source: 'fr', target: 'de' })
+ const s = useQuickLookupStore.getState()
+ expect(s.sourceLang).toBe('fr')
+ expect(s.targetLang).toBe('de')
+ })
+
+ it('clears project languages when the editor unmounts', () => {
+ useQuickLookupStore.getState().setProjectLangs({ source: 'pt', target: 'en' })
+ useQuickLookupStore.getState().setProjectLangs(null)
+ useQuickLookupStore.getState().openWith('x')
+ expect(useQuickLookupStore.getState().sourceLang).toBeUndefined()
+ })
+})
From ac38224dba53f81aaf687ee46a8ccf9af3191cb7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 22 Jul 2026 00:45:53 +0000
Subject: [PATCH 3/8] =?UTF-8?q?Phase=20C=20=E2=80=94=20use=20the=20signed-?=
=?UTF-8?q?in=20account=20name=20for=20review=20&=20collaboration?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Attribution ignored the account name even when signed in: tracked changes
and comments showed "You", presence showed "Anonymous", and Settings asked
for a separate local name. Now the account name is used automatically.
- New features/account/displayName.ts: a pure resolveDisplayName(local, auth,
fallback) + reactive useDisplayName() that prefer the account name
(profileDisplayName ?? user.displayName) when authenticated, else the
device-local name, else a friendly fallback. Two fallbacks kept for their
distinct audiences: SELF_NAME_FALLBACK ("You") and PEER_NAME_FALLBACK
("Anonymous"). authorId stays device-local (D8) — only the name is
account-aware.
- useLocalAuthor (tracked-change/comment attribution) and syncManager
(collaboration presence) route their name through the resolver.
- versionRepo.saveNamed/signOff/capture accept an optional caller-resolved
name so "Approved by {name}" and version author use the account name; the
storage layer stays account-agnostic (name injected by VersionHistoryPanel
via useLocalAuthor). authorId unchanged.
- ProfileSettingsSection stops asking for a local name when signed in (shows
the account name + a pointer to Account settings) and drops a stale
"once LAN collaboration lands" comment (it shipped).
- Tests: displayName resolver matrix (7). flag-off build verified
GoTrueClient-free — the wider auth-store imports don't pull Supabase in.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_013kzE8hPSJxu19tzs7kuMMa
---
src/features/account/displayName.ts | 49 +++++++++++++
.../editor/history/VersionHistoryPanel.tsx | 8 ++-
src/features/editor/useLocalAuthor.ts | 5 +-
.../settings/ProfileSettingsSection.tsx | 22 +++++-
src/storage/repositories/versionRepo.ts | 23 +++++--
src/storage/sync/syncManager.ts | 5 +-
tests/unit/displayName.test.ts | 68 +++++++++++++++++++
7 files changed, 167 insertions(+), 13 deletions(-)
create mode 100644 src/features/account/displayName.ts
create mode 100644 tests/unit/displayName.test.ts
diff --git a/src/features/account/displayName.ts b/src/features/account/displayName.ts
new file mode 100644
index 0000000..b63ab65
--- /dev/null
+++ b/src/features/account/displayName.ts
@@ -0,0 +1,49 @@
+import { useAuthStore, type AuthUser, type AuthStatus } from './useAuthStore'
+
+/**
+ * The effective display name for authored actions — tracked changes, comments,
+ * collaboration presence, version sign-off. When signed in, the account name is
+ * used automatically, so a signed-in user never has to enter a separate local
+ * name. Otherwise the device-local name is used, and only when neither exists
+ * does a friendly fallback show.
+ *
+ * Two fallbacks, because the word differs by audience: your own unnamed
+ * attribution reads as "You"; an unnamed peer shown to *others* reads as
+ * "Anonymous".
+ */
+export const SELF_NAME_FALLBACK = 'You'
+export const PEER_NAME_FALLBACK = 'Anonymous'
+
+/** The auth fields the resolver reads — a slice, so it is pure and testable. */
+export interface AuthNameSlice {
+ status: AuthStatus
+ user: AuthUser | null
+ profileDisplayName: string | null
+}
+
+/**
+ * Resolve the effective name: signed-in account name → device-local name →
+ * `fallback`. Pure over the auth slice.
+ */
+export function resolveDisplayName(
+ local: string | null | undefined,
+ auth: AuthNameSlice,
+ fallback: string = SELF_NAME_FALLBACK,
+): string {
+ if (auth.status === 'authenticated') {
+ const account = (auth.profileDisplayName ?? auth.user?.displayName ?? '').trim()
+ if (account) return account
+ }
+ return (local ?? '').trim() || fallback
+}
+
+/** Reactive `resolveDisplayName` bound to the auth store. */
+export function useDisplayName(
+ local: string | null | undefined,
+ fallback: string = SELF_NAME_FALLBACK,
+): string {
+ const status = useAuthStore((s) => s.status)
+ const user = useAuthStore((s) => s.user)
+ const profileDisplayName = useAuthStore((s) => s.profileDisplayName)
+ return resolveDisplayName(local, { status, user, profileDisplayName }, fallback)
+}
diff --git a/src/features/editor/history/VersionHistoryPanel.tsx b/src/features/editor/history/VersionHistoryPanel.tsx
index ad09532..8da47b6 100644
--- a/src/features/editor/history/VersionHistoryPanel.tsx
+++ b/src/features/editor/history/VersionHistoryPanel.tsx
@@ -4,6 +4,7 @@ import { History, RotateCcw, Save, BadgeCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { versionRepo } from '@/storage/repositories/versionRepo'
import { useCanReview } from '../workflow/useWorkflowStore'
+import { useLocalAuthor } from '../useLocalAuthor'
import { relativeTime } from './relativeTime'
interface VersionHistoryPanelProps {
@@ -26,11 +27,14 @@ export function VersionHistoryPanel({ projectId }: VersionHistoryPanelProps) {
// Sign-off is a review action — revisor / project_manager only (§5.3). A
// local-only project resolves to PM-of-self, so it's always available there.
const canReview = useCanReview()
+ // Account-aware name (account name when signed in, else the device-local one)
+ // so versions and sign-offs are attributed consistently across the app.
+ const { authorName } = useLocalAuthor()
async function handleSave() {
setSaving(true)
try {
- await versionRepo.saveNamed(projectId, label)
+ await versionRepo.saveNamed(projectId, label, undefined, authorName)
setLabel('')
} finally {
setSaving(false)
@@ -40,7 +44,7 @@ export function VersionHistoryPanel({ projectId }: VersionHistoryPanelProps) {
async function handleSignOff() {
setSigningOff(true)
try {
- await versionRepo.signOff(projectId)
+ await versionRepo.signOff(projectId, undefined, authorName)
} finally {
setSigningOff(false)
}
diff --git a/src/features/editor/useLocalAuthor.ts b/src/features/editor/useLocalAuthor.ts
index 97b9ddf..873b0d3 100644
--- a/src/features/editor/useLocalAuthor.ts
+++ b/src/features/editor/useLocalAuthor.ts
@@ -7,6 +7,7 @@ import {
type LocalAuthor,
type ProfileSettings,
} from '@/storage/repositories/settingsRepo'
+import { useDisplayName } from '@/features/account/displayName'
// Mint/persist the stable author id once for the whole app, shared across every
// segment editor so we don't write it N times on load.
@@ -39,6 +40,8 @@ export function useLocalAuthor(): LocalAuthor {
// Prefer the reactive stored id once available; fall back to the minted one.
const id = profile?.authorId ?? authorId
- const name = (profile?.displayName ?? '').trim() || 'You'
+ // When signed in, attribute to the account name automatically; else the
+ // device-local name, else "You".
+ const name = useDisplayName(profile?.displayName)
return { authorId: id, authorName: name }
}
diff --git a/src/features/settings/ProfileSettingsSection.tsx b/src/features/settings/ProfileSettingsSection.tsx
index f0129d6..262667f 100644
--- a/src/features/settings/ProfileSettingsSection.tsx
+++ b/src/features/settings/ProfileSettingsSection.tsx
@@ -8,6 +8,7 @@ import {
PROFILE_SETTINGS_KEY,
type ProfileSettings,
} from '@/storage/repositories/settingsRepo'
+import { useAuthStore } from '@/features/account/useAuthStore'
export function ProfileSettingsSection() {
const stored = useLiveQuery(
@@ -15,6 +16,8 @@ export function ProfileSettingsSection() {
[],
)
const [draft, setDraft] = useState(DEFAULT_PROFILE_SETTINGS)
+ const signedIn = useAuthStore((s) => s.status === 'authenticated')
+ const accountName = useAuthStore((s) => s.profileDisplayName ?? s.user?.displayName ?? null)
useEffect(() => {
if (stored !== undefined) setDraft(mergeProfileSettings(stored ?? undefined))
@@ -26,11 +29,26 @@ export function ProfileSettingsSection() {
await settingsRepo.set(PROFILE_SETTINGS_KEY, next)
}
+ // Signed in: attribution uses your account name automatically, so there's no
+ // separate local name to enter — point at Account settings instead of asking.
+ if (signedIn) {
+ return (
+
+ Your name for comments, tracked changes and collaboration comes from your
+ account{accountName ? ` — ${accountName}` : ''}. Change it in Account settings.
+
+ )
+ }
+
return (
- The name attached to comments you write. It will also identify you to
- peers once local-network collaboration lands. Stored on this device only.
+ The name attached to your comments, tracked changes and collaboration
+ presence. Stored on this device only.
- )
-}
diff --git a/src/features/editor/rich/RichSegmentEditor.tsx b/src/features/editor/rich/RichSegmentEditor.tsx
index ae5bf0f..7a47162 100644
--- a/src/features/editor/rich/RichSegmentEditor.tsx
+++ b/src/features/editor/rich/RichSegmentEditor.tsx
@@ -392,7 +392,7 @@ function EditorLogic(props: RichSegmentEditorProps) {
unregisters.forEach((u) => u())
propsRef.current.registerHandle(null)
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [editor])
// When the DB target diverges from what we last saved, an external action
diff --git a/src/features/editor/rich/TrackedChangesPlugin.tsx b/src/features/editor/rich/TrackedChangesPlugin.tsx
index 1f8fac8..5b0af98 100644
--- a/src/features/editor/rich/TrackedChangesPlugin.tsx
+++ b/src/features/editor/rich/TrackedChangesPlugin.tsx
@@ -98,7 +98,7 @@ export function TrackedChangesPlugin({ locked }: { locked?: boolean }) {
),
]
return () => unregister.forEach((u) => u())
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [editor])
return null
diff --git a/src/storage/schemas.ts b/src/storage/schemas.ts
deleted file mode 100644
index b29432d..0000000
--- a/src/storage/schemas.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type { Project, Segment, TMEntry, GlossaryEntry } from '@/core/types'
diff --git a/src/storage/sync/syncSession.ts b/src/storage/sync/syncSession.ts
index 0473160..8c68ab2 100644
--- a/src/storage/sync/syncSession.ts
+++ b/src/storage/sync/syncSession.ts
@@ -61,7 +61,7 @@ export function startSyncSession(opts: SyncSessionOptions): SyncSessionHandle {
const presence = new PresenceTracker(peerId)
const subscribers = new Set<(peers: PeerPresence[]) => void>()
- let displayName = opts.displayName
+ const displayName = opts.displayName
let activeSegmentId: string | undefined
let caret: CaretRange | undefined
let heartbeat: ReturnType | null = null
From 91c11503a665307c4ce2f5bb5b6a15ef2e16fa75 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 22 Jul 2026 01:05:48 +0000
Subject: [PATCH 6/8] =?UTF-8?q?Phase=20E2=20=E2=80=94=20style:=20prettier?=
=?UTF-8?q?=20--write=20across=20the=20repo?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pure formatting, no logic changes — typecheck, eslint, 728 unit tests and the
build are all green before and after. Isolated into its own commit so it can be
added to .git-blame-ignore-revs and skipped in review.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_013kzE8hPSJxu19tzs7kuMMa
---
.github/ISSUE_TEMPLATE/bug_report.md | 6 +-
.vscode/settings.json | 6 +-
LICENSE.md | 8 +-
docs/architecture.md | 83 ++---
docs/cloud.md | 72 ++---
docs/refactor-audit.md | 63 ++--
docs/revamp/ROADMAP.md | 57 ++--
docs/revamp/STATUS.md | 73 ++---
docs/revamp/prompts/phase-0-1.md | 4 +
docs/revamp/prompts/phase-1-1.md | 4 +
docs/revamp/prompts/phase-1-2.md | 4 +
docs/revamp/prompts/phase-1-3.md | 4 +
docs/revamp/prompts/phase-1-4.md | 4 +
docs/revamp/prompts/phase-1-5.md | 4 +
docs/revamp/prompts/phase-1-6.md | 4 +
docs/revamp/prompts/phase-2-1.md | 4 +
docs/revamp/prompts/phase-2-2.md | 4 +
docs/revamp/prompts/phase-2-3.md | 4 +
docs/revamp/prompts/phase-2-4.md | 4 +
docs/revamp/prompts/phase-3-1.md | 4 +
docs/revamp/prompts/phase-3-2.md | 4 +
docs/revamp/prompts/phase-3-3.md | 4 +
docs/revamp/prompts/phase-3-4.md | 4 +
docs/revamp/prompts/phase-4-1.md | 4 +
docs/revamp/prompts/phase-4-2.md | 4 +
docs/revamp/prompts/phase-4-3.md | 4 +
docs/revamp/prompts/phase-4-4.md | 4 +
docs/revamp/prompts/phase-5-1.md | 4 +
docs/revamp/prompts/phase-5-2.md | 4 +
docs/revamp/prompts/phase-5-3.md | 4 +
docs/revamp/prompts/phase-6-1.md | 4 +
docs/revamp/prompts/phase-6-2.md | 4 +
docs/revamp/prompts/phase-6-3.md | 4 +
docs/revamp/prompts/phase-6-4.md | 4 +
docs/roadmap-professional-features.md | 165 +++++-----
docs/vision/architecture-vision.md | 63 +++-
docs/vision/overview.md | 26 ++
docs/vision/product-features.md | 99 ++++++
docs/vision/product-vision.md | 66 ++++
docs/vision/ux-vision.md | 76 +++++
public/guide/standards.md | 54 ++--
public/guide/theory-glossary.md | 8 +-
public/guide/workflow.md | 33 +-
public/manifest.webmanifest | 14 +-
scripts/build-corpora.mjs | 12 +-
src-tauri/README.md | 16 +-
src-tauri/tauri.conf.json | 5 +-
src/app/ErrorBoundary.tsx | 4 +-
src/app/providers.tsx | 8 +-
src/components/brand/VerbalisMark.tsx | 9 +-
src/components/layout/BrandFooter.tsx | 5 +-
src/components/layout/PageHeader.tsx | 6 +-
src/components/layout/Sidebar.tsx | 2 +-
src/components/layout/TopBar.tsx | 7 +-
src/components/ui/badge.tsx | 9 +-
src/components/ui/button.tsx | 18 +-
src/components/ui/command.tsx | 4 +-
src/components/ui/input.tsx | 36 +--
src/components/ui/select.tsx | 40 +--
src/core/bilingual/inlineTags.ts | 4 +-
src/core/bilingual/xliff12.ts | 8 +-
src/core/corpus/manifest.ts | 5 +-
src/core/documents/docxImport.ts | 38 ++-
src/core/documents/fromSegments.ts | 4 +-
src/core/documents/render.ts | 4 +-
src/core/documents/targetRuns.ts | 6 +-
src/core/documents/toDocx.ts | 17 +-
src/core/embeddings/index.ts | 6 +-
src/core/extensions/types.ts | 7 +-
src/core/glossary/match.ts | 5 +-
src/core/glossary/tbx.ts | 10 +-
src/core/glossary/tsv.ts | 4 +-
src/core/glossary/wiktionary.ts | 5 +-
src/core/mt/claude.ts | 7 +-
src/core/mt/mymemory.ts | 40 ++-
src/core/mt/types.ts | 5 +-
src/core/qa/checks.ts | 13 +-
src/core/qa/registryRules.ts | 4 +-
src/core/segmentation/sbdOptions.ts | 176 ++++++++++-
src/core/segmentation/txt.ts | 5 +-
src/core/spell/loader.ts | 5 +-
src/core/spell/offsets.ts | 6 +-
src/core/text/autocorrect.ts | 3 +-
src/core/tm/semantic.ts | 6 +-
src/core/websearch/providers.ts | 42 ++-
src/core/workflow/rules.ts | 6 +-
.../connectors/ConnectorFilePicker.tsx | 12 +-
src/extensions/connectors/gdrive/config.ts | 4 +-
src/extensions/connectors/gdrive/driveApi.ts | 20 +-
src/extensions/connectors/gdrive/gis.ts | 46 ++-
src/extensions/connectors/gdrive/index.ts | 14 +-
src/extensions/connectors/onedrive/config.ts | 5 +-
.../connectors/onedrive/graphApi.ts | 18 +-
src/extensions/connectors/onedrive/index.ts | 7 +-
src/extensions/connectors/onedrive/msal.ts | 3 +-
src/extensions/mt/index.ts | 10 +-
src/features/about/index.tsx | 63 ++--
src/features/account/AccountMenu.tsx | 5 +-
.../account/AccountSettingsSection.tsx | 13 +-
src/features/account/SignInDialog.tsx | 4 +-
src/features/account/useAuthStore.ts | 7 +-
src/features/addons/AddonsPage.tsx | 31 +-
.../command-palette/CommandPalette.tsx | 53 +---
src/features/corpora/CorpusImportButton.tsx | 18 +-
src/features/corpora/CorpusPackCard.tsx | 7 +-
src/features/editor/EditModeToggle.tsx | 6 +-
src/features/editor/EditorPage.tsx | 14 +-
src/features/editor/EditorTips.tsx | 35 ++-
src/features/editor/MobileSidebarSheet.tsx | 10 +-
src/features/editor/SegmentRow.tsx | 20 +-
.../editor/analysis/AnalysisDialog.tsx | 11 +-
src/features/editor/changes/ChangesPanel.tsx | 17 +-
src/features/editor/changes/resolveOps.ts | 16 +-
.../editor/comments/CommentsPanel.tsx | 6 +-
.../editor/comments/SegmentComments.tsx | 17 +-
src/features/editor/comments/commentOps.ts | 12 +-
.../editor/concordance/ConcordanceDialog.tsx | 14 +-
.../editor/findReplace/FindReplaceDialog.tsx | 16 +-
.../editor/glossary/GlossaryPanel.tsx | 21 +-
.../editor/history/SegmentHistoryDialog.tsx | 5 +-
src/features/editor/layoutSync.ts | 6 +-
src/features/editor/lookup/LookupPanel.tsx | 7 +-
src/features/editor/mt/MTPanel.tsx | 5 +-
src/features/editor/mt/useMTSettings.ts | 11 +-
src/features/editor/mt/useMTTranslate.ts | 3 +-
.../editor/peers/PeersPresenceChip.tsx | 4 +-
src/features/editor/peers/useProjectSync.ts | 5 +-
.../editor/preview/DocumentPreview.tsx | 28 +-
src/features/editor/rich/ChangeHoverCard.tsx | 5 +-
src/features/editor/rich/ChangeMarkNode.ts | 4 +-
src/features/editor/rich/CommentMarkNode.ts | 4 +-
src/features/editor/rich/FormatToolbar.tsx | 4 +-
src/features/editor/rich/InlineTagNode.tsx | 5 +-
.../editor/rich/RemoteCaretOverlay.tsx | 10 +-
.../editor/rich/RichSegmentEditor.tsx | 13 +-
.../editor/rich/SpellUnderlinePlugin.tsx | 9 +-
.../editor/rich/TrackedChangesPlugin.tsx | 1 -
src/features/editor/rich/comments.ts | 6 +-
src/features/editor/rich/resolve.ts | 7 +-
src/features/editor/rich/richOffsets.ts | 3 +-
src/features/editor/rich/suggest.ts | 4 +-
src/features/editor/segmentAutosave.ts | 5 +-
.../editor/segments/SegmentActionsMenu.tsx | 4 +-
.../editor/segments/SplitSegmentDialog.tsx | 4 +-
src/features/editor/spell/SpellPanel.tsx | 5 +-
src/features/editor/tm/TMPanel.tsx | 5 +-
.../editor/tools/ExportDocxButton.tsx | 25 +-
src/features/editor/useEditorModeStore.ts | 3 +-
src/features/editor/useSidebarPanelStore.ts | 5 +-
.../editor/workflow/useWorkflowStore.ts | 4 +-
src/features/guide/Markdown.tsx | 5 +-
src/features/guide/index.tsx | 5 +-
src/features/import/ImportDialog.tsx | 288 +++++++++---------
src/features/lookup/useQuickLookupStore.ts | 1 -
src/features/projects/DeleteProjectDialog.tsx | 10 +-
.../projects/DuplicateProjectDialog.tsx | 6 +-
src/features/projects/EditProjectDialog.tsx | 9 +-
src/features/projects/ProjectCard.tsx | 13 +-
src/features/projects/cloud/CloudControls.tsx | 7 +-
.../projects/cloud/ManageMembersDialog.tsx | 38 ++-
.../projects/cloud/OpenCloudProjectDialog.tsx | 22 +-
src/features/projects/index.tsx | 15 +-
.../settings/EditorSettingsSection.tsx | 6 +-
.../settings/LookupSettingsSection.tsx | 4 +-
src/features/settings/MTSettingsSection.tsx | 56 ++--
.../settings/ProfileSettingsSection.tsx | 8 +-
src/features/settings/SemanticTMSection.tsx | 10 +-
.../settings/SpellSettingsSection.tsx | 4 +-
.../settings/WebSearchSettingsSection.tsx | 13 +-
src/features/settings/index.tsx | 15 +-
src/features/shortcuts/ShortcutsDialog.tsx | 9 +-
.../terminology/GlossaryEditDialog.tsx | 14 +-
src/features/terminology/GlossaryTable.tsx | 15 +-
src/features/terminology/WiktionaryLookup.tsx | 19 +-
src/features/terminology/index.tsx | 10 +-
src/features/tm/TMEditDialog.tsx | 5 +-
src/features/tm/TMImportButton.tsx | 12 +-
src/features/tm/TMTable.tsx | 2 +-
src/features/translate/TranslateWorkspace.tsx | 79 ++---
src/features/translate/index.tsx | 6 +-
src/storage/cloud/members.ts | 7 +-
src/storage/cloud/projectCloud.ts | 7 +-
src/storage/cloud/rowSync.ts | 5 +-
src/storage/cloud/settingsSync.ts | 8 +-
src/storage/cloud/ydocPersistence.ts | 10 +-
src/storage/repositories/corpusRepo.ts | 3 +-
src/storage/repositories/documentRepo.ts | 9 +-
src/storage/repositories/glossaryRepo.ts | 5 +-
src/storage/repositories/projectRepo.ts | 3 +-
src/storage/repositories/segmentRepo.ts | 7 +-
src/storage/repositories/settingsRepo.ts | 14 +-
src/storage/repositories/versionRepo.ts | 5 +-
src/storage/sync/crypto.ts | 5 +-
src/storage/sync/syncManager.ts | 3 +-
src/storage/sync/transport/chunking.ts | 5 +-
src/storage/sync/transport/index.ts | 4 +-
.../sync/transport/supabaseRealtime.ts | 4 +-
src/storage/sync/transport/tauri.ts | 5 +-
src/styles/globals.css | 3 +-
tailwind.config.ts | 20 +-
tests/e2e/comments-anchored.spec.ts | 4 +-
tests/e2e/fixtures/sample.md | 4 +-
tests/e2e/mt-flow.spec.ts | 12 +-
tests/e2e/tm-flow.spec.ts | 4 +-
tests/e2e/tracked-changes.spec.ts | 4 +-
tests/unit/AddonsPage.test.tsx | 4 +-
tests/unit/ConnectorFilePicker.test.tsx | 16 +-
tests/unit/MTPanel.test.tsx | 7 +-
tests/unit/OfflineBadge.test.tsx | 5 +-
tests/unit/TMEditDialog.test.tsx | 10 +-
tests/unit/analysis.report.test.ts | 12 +-
tests/unit/bilingual.xliff12.test.ts | 5 +-
tests/unit/changes.extract.test.ts | 7 +-
tests/unit/changes.resolve.test.ts | 8 +-
tests/unit/cloud.members.test.ts | 39 ++-
tests/unit/cloud.projectCloud.test.ts | 6 +-
tests/unit/cloud.rowSync.repo.test.ts | 7 +-
tests/unit/cloud.rowSync.test.ts | 32 +-
tests/unit/cloud.settingsSync.test.ts | 9 +-
tests/unit/cloud.supabaseClient.test.ts | 6 +-
tests/unit/connectors.driveApi.test.ts | 36 ++-
tests/unit/connectors.gdrive.test.ts | 20 +-
tests/unit/connectors.graphApi.test.ts | 34 ++-
tests/unit/connectors.onedrive.test.ts | 24 +-
tests/unit/corpus.customPack.test.ts | 13 +-
tests/unit/corpus.match.test.ts | 6 +-
tests/unit/corpusRepo.test.ts | 7 +-
tests/unit/crdtBridge.test.ts | 4 +-
tests/unit/db.migration.test.ts | 56 +++-
tests/unit/documentRepo.test.ts | 20 +-
tests/unit/documents.docxImport.test.ts | 19 +-
tests/unit/documents.fromSegments.test.ts | 8 +-
tests/unit/documents.render.test.ts | 70 ++++-
tests/unit/documents.targetRuns.test.ts | 12 +-
tests/unit/documents.toDocx.test.ts | 49 ++-
tests/unit/embeddingsRepo.test.ts | 5 +-
tests/unit/glossary.csv.multiterm.test.ts | 4 +-
tests/unit/glossary.csv.test.ts | 10 +-
tests/unit/glossary.match.test.ts | 18 +-
tests/unit/glossary.tsv.test.ts | 4 +-
tests/unit/glossaryRepo.test.ts | 6 +-
tests/unit/history.diff.test.ts | 14 +-
tests/unit/i18n.languages.test.ts | 6 +-
tests/unit/mt.ollama.test.ts | 13 +-
tests/unit/numbers.test.ts | 4 +-
tests/unit/projectRepo.test.ts | 43 ++-
tests/unit/qa.checks.test.ts | 12 +-
tests/unit/qa.registryRules.test.ts | 5 +-
tests/unit/richText.test.ts | 14 +-
tests/unit/segmentCrdt.test.ts | 14 +-
tests/unit/segmentRepo.test.ts | 28 +-
tests/unit/segmentation.docx.test.ts | 9 +-
tests/unit/segmentation.test.ts | 6 +-
tests/unit/segments.test.ts | 16 +-
tests/unit/settings.about.test.tsx | 4 +-
tests/unit/sync.chunking.test.ts | 10 +-
tests/unit/sync.presence.test.ts | 8 +-
tests/unit/sync.reverseBridge.test.ts | 20 +-
tests/unit/tm.concordance.test.ts | 9 +-
tests/unit/tm.match.test.ts | 4 +-
tests/unit/tm.semantic.test.ts | 26 +-
tests/unit/tmRepo.test.ts | 6 +-
tests/unit/useNetworkStatus.test.ts | 5 +-
tests/unit/versionRepo.changes.test.ts | 6 +-
tests/unit/versionRepo.test.ts | 18 +-
tests/unit/workflow.rules.test.ts | 24 +-
tsconfig.json | 5 +-
vite.config.ts | 14 +-
268 files changed, 2708 insertions(+), 1561 deletions(-)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index d139126..c4dc6c4 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -9,15 +9,13 @@ assignees: ''
**What happened**
A clear description of the bug.
-**Steps to reproduce**
-1.
-2.
-3.
+**Steps to reproduce** 1. 2. 3.
**Expected behaviour**
What you expected to happen instead.
**Environment**
+
- Build SHA (from the About page):
- Browser / OS:
- Mode: local-only / signed-in
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 03adc8d..c5ee1a9 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,4 +1,4 @@
{
- "IDX.aI.enableInlineCompletion": true,
- "IDX.aI.enableCodebaseIndexing": true
-}
\ No newline at end of file
+ "IDX.aI.enableInlineCompletion": true,
+ "IDX.aI.enableCodebaseIndexing": true
+}
diff --git a/LICENSE.md b/LICENSE.md
index 526a6c9..d0ea845 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -33,8 +33,8 @@ licence to use, copy, modify, and self-host the Software for their own work,
> existing "Created by Pedro Brito" credit and the link to
> intact in the application's
> About/credits screen, and — where you publish work, a fork, or a derivative
-> built on the Software — state that it is *"powered by Verbalis, created by
-> Pedro Brito"* with a link back to this repository.
+> built on the Software — state that it is _"powered by Verbalis, created by
+> Pedro Brito"_ with a link back to this repository.
That attribution is the only thing asked of Individuals.
@@ -78,6 +78,6 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
-*Not sure which tier applies to you? If you are one person using Verbalis for
+_Not sure which tier applies to you? If you are one person using Verbalis for
your own translation work, you are covered for free under Section 2 — just keep
-the credit. Anything bigger, reach out and we'll sort out fair terms.*
+the credit. Anything bigger, reach out and we'll sort out fair terms._
diff --git a/docs/architecture.md b/docs/architecture.md
index d588438..540c59d 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -6,20 +6,20 @@ VERBALIS is a local-first, browser-native CAT (Computer-Assisted Translation) to
## Tech Stack
-| Layer | Choice | Notes |
-|---|---|---|
-| Framework | React 18 + TypeScript | Strict mode, full type safety |
-| Bundler | Vite 6 | base: '/' for the custom-domain root (override with `BASE_PATH` for subdirectory builds) |
-| Styling | Tailwind CSS 3 + shadcn/ui | Owned components, Radix primitives |
-| State | Zustand (UI) + TanStack Query v5 (async) | No Redux boilerplate |
-| Routing | React Router v6 HashRouter | GH Pages SPA compatibility |
-| Storage | Dexie.js v4 (IndexedDB) | Promise API, live hooks, migrations |
-| Parsing | unified + remark-parse + remark-gfm + sbd | AST-based, not regex |
-| DOCX | mammoth | Only viable browser-side parser |
-| Fuzzy search | Fuse.js (TM) + MiniSearch (terminology) | Different tools for different jobs |
-| Workers | Comlink | Makes Web Worker calls look like async functions |
-| PWA | vite-plugin-pwa (Workbox) | Offline support, installable |
-| Fonts | geist npm package | Self-hosted, works fully offline |
+| Layer | Choice | Notes |
+| ------------ | ----------------------------------------- | ---------------------------------------------------------------------------------------- |
+| Framework | React 18 + TypeScript | Strict mode, full type safety |
+| Bundler | Vite 6 | base: '/' for the custom-domain root (override with `BASE_PATH` for subdirectory builds) |
+| Styling | Tailwind CSS 3 + shadcn/ui | Owned components, Radix primitives |
+| State | Zustand (UI) + TanStack Query v5 (async) | No Redux boilerplate |
+| Routing | React Router v6 HashRouter | GH Pages SPA compatibility |
+| Storage | Dexie.js v4 (IndexedDB) | Promise API, live hooks, migrations |
+| Parsing | unified + remark-parse + remark-gfm + sbd | AST-based, not regex |
+| DOCX | mammoth | Only viable browser-side parser |
+| Fuzzy search | Fuse.js (TM) + MiniSearch (terminology) | Different tools for different jobs |
+| Workers | Comlink | Makes Web Worker calls look like async functions |
+| PWA | vite-plugin-pwa (Workbox) | Offline support, installable |
+| Fonts | geist npm package | Self-hosted, works fully offline |
## Key Folder Map
@@ -41,22 +41,22 @@ src/
## Storage Schema (v7)
-| Table | Indexes | Since |
-|---|---|---|
-| projects | id, name, updatedAt | v1 |
-| segments | id, projectId, index, status, [projectId+status], [projectId+index] | v1 (compound idx v4) |
-| tm | id, source, sourceLang, targetLang, projectId, corpusId, updatedAt | v1 |
-| glossary | id, term, projectId, updatedAt | v1 |
-| settings | &key | v2 |
-| embeddings | id, tmId, model, [tmId+model] | v2 |
-| corpusTerms | id, corpusId | v3 |
-| corpusPacks | id | v3 |
-| projectTemplates | projectId | v4 |
-| versions | id, projectId, createdAt, [projectId+createdAt] | v5 |
-| documents | id, projectId | v6 |
-| blocks | id, documentId, projectId, [documentId+index] | v6 |
-| assets | id, documentId, projectId | v6 |
-| syncTombstones | [resource+rowId], resource, deletedAt | v7 |
+| Table | Indexes | Since |
+| ---------------- | ------------------------------------------------------------------- | -------------------- |
+| projects | id, name, updatedAt | v1 |
+| segments | id, projectId, index, status, [projectId+status], [projectId+index] | v1 (compound idx v4) |
+| tm | id, source, sourceLang, targetLang, projectId, corpusId, updatedAt | v1 |
+| glossary | id, term, projectId, updatedAt | v1 |
+| settings | &key | v2 |
+| embeddings | id, tmId, model, [tmId+model] | v2 |
+| corpusTerms | id, corpusId | v3 |
+| corpusPacks | id | v3 |
+| projectTemplates | projectId | v4 |
+| versions | id, projectId, createdAt, [projectId+createdAt] | v5 |
+| documents | id, projectId | v6 |
+| blocks | id, documentId, projectId, [documentId+index] | v6 |
+| assets | id, documentId, projectId | v6 |
+| syncTombstones | [resource+rowId], resource, deletedAt | v7 |
Migrations are handled by Dexie's versioning system (`this.version(N).stores(...)`). Always increment, never modify existing version blocks. Notable steps: v3 adds the bundled-corpora tables + a `corpusId` index on `tm`; v4 adds compound segment indexes and moves the XLIFF template blob to its own table; v5 adds version snapshots; v6 adds the document/block model (backfilled from `sourceMeta.blockIndex`); v7 adds `updatedAt` indexes on `tm`/`glossary` and the `syncTombstones` table for the personal-resource cloud reconciler.
@@ -67,6 +67,7 @@ Migrations are handled by Dexie's versioning system (`this.version(N).stores(...
Static build → GitHub Actions → GitHub Pages at `https://verbalis.britx.me/`.
Critical GH Pages constraints:
+
- `base: '/'` in vite.config.ts, overridable via the `BASE_PATH` env var (e.g.
`BASE_PATH=/verbalis/ pnpm build`) for anyone forking this project into a
path-based GitHub Pages deployment instead
@@ -82,17 +83,17 @@ DOCX import uses `mammoth.convertToHtml`, then a small DOM walker (`src/core/seg
## Phase Roadmap
-| Phase | Scope |
-|---|---|
-| 0 | Foundation — scaffold, CI/CD, PWA, app shell ✅ |
-| 1 | TXT + MD import, segmentation, side-by-side editor ✅ |
-| 2 | Translation Memory — store, exact/fuzzy match, TMX import/export ✅ |
-| 3 | Terminology — glossary CRUD, CSV + TBX I/O, inline editor panel, Wiktionary adapter ✅ |
-| 4 | DOCX import, command palette, review modes ✅ |
-| 5 | PWA hardening, offline edge cases, update notification ✅ |
-| 6 | AI integrations (Ollama, Claude, LibreTranslate), semantic TM ✅ |
-| 7+ | Project-level exports, terminology extraction, collaborative TM |
-| 8+ | Professional CAT features — rich editor, segment handling, versioning, LAN collaboration, document standards. See [`roadmap-professional-features.md`](roadmap-professional-features.md) |
+| Phase | Scope |
+| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 0 | Foundation — scaffold, CI/CD, PWA, app shell ✅ |
+| 1 | TXT + MD import, segmentation, side-by-side editor ✅ |
+| 2 | Translation Memory — store, exact/fuzzy match, TMX import/export ✅ |
+| 3 | Terminology — glossary CRUD, CSV + TBX I/O, inline editor panel, Wiktionary adapter ✅ |
+| 4 | DOCX import, command palette, review modes ✅ |
+| 5 | PWA hardening, offline edge cases, update notification ✅ |
+| 6 | AI integrations (Ollama, Claude, LibreTranslate), semantic TM ✅ |
+| 7+ | Project-level exports, terminology extraction, collaborative TM |
+| 8+ | Professional CAT features — rich editor, segment handling, versioning, LAN collaboration, document standards. See [`roadmap-professional-features.md`](roadmap-professional-features.md) |
## Phase 5 — PWA Layer
diff --git a/docs/cloud.md b/docs/cloud.md
index 3301120..09da39c 100644
--- a/docs/cloud.md
+++ b/docs/cloud.md
@@ -15,10 +15,10 @@ never talks to a real Supabase instance.
The live Verbalis project is already provisioned:
-| | |
-| --- | --- |
-| Project ref | `qutcuzlppbjbsymowavc` (region `us-east-2`, Postgres 17) |
-| `VITE_SUPABASE_URL` | `https://qutcuzlppbjbsymowavc.supabase.co` |
+| | |
+| ------------------------ | ----------------------------------------------------------------------------------- |
+| Project ref | `qutcuzlppbjbsymowavc` (region `us-east-2`, Postgres 17) |
+| `VITE_SUPABASE_URL` | `https://qutcuzlppbjbsymowavc.supabase.co` |
| `VITE_SUPABASE_ANON_KEY` | `sb_publishable_c8YdrT2-abKdW1Kl7FDKyA_v5OwqLdY` (publishable — safe in the bundle) |
These two values are also in `.env.example` — copy it to `.env.local` for local
@@ -35,8 +35,8 @@ production, the repository Variables in §1.
Set these at build time (e.g. in the CI/deploy environment). Leaving
`VITE_SUPABASE_URL` or `VITE_SUPABASE_ANON_KEY` unset keeps the app local-only.
-**Production (GitHub Pages / `deploy.yml`)**: add them under *Settings → Secrets
-and variables → Actions*. A repository **Variable** is the natural home (all
+**Production (GitHub Pages / `deploy.yml`)**: add them under _Settings → Secrets
+and variables → Actions_. A repository **Variable** is the natural home (all
three are public — they end up in the client bundle), but `deploy.yml` accepts
either a Variable or a Secret via a fallback chain, and also accepts the URL
under the unprefixed name `SUPABASE_URL`:
@@ -51,11 +51,11 @@ build; the `sb_secret_...` key must never reach the client bundle.
**Local dev**: `cp .env.example .env.local` (git-ignored) and run `pnpm dev`.
-| Variable | Required | Purpose |
-| --- | --- | --- |
-| `VITE_SUPABASE_URL` | yes (to enable cloud) | Supabase project URL, e.g. `https://xxxx.supabase.co` |
-| `VITE_SUPABASE_ANON_KEY` | yes (to enable cloud) | The project's anon / publishable key (safe to ship in a client bundle) |
-| `VITE_AUTH_PROVIDERS` | no | Comma-separated OAuth providers to show, using **Supabase provider ids**: `google,azure,apple`. Defaults to `google`. Providers not configured server-side are simply hidden. |
+| Variable | Required | Purpose |
+| ------------------------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `VITE_SUPABASE_URL` | yes (to enable cloud) | Supabase project URL, e.g. `https://xxxx.supabase.co` |
+| `VITE_SUPABASE_ANON_KEY` | yes (to enable cloud) | The project's anon / publishable key (safe to ship in a client bundle) |
+| `VITE_AUTH_PROVIDERS` | no | Comma-separated OAuth providers to show, using **Supabase provider ids**: `google,azure,apple`. Defaults to `google`. Providers not configured server-side are simply hidden. |
> Note on provider ids: Microsoft is `azure` (not `microsoft`) in Supabase Auth.
@@ -92,7 +92,7 @@ and safe to re-run:
allowlist lives in `src/storage/cloud/settingsSync.ts`).
- `0004_personal_resources.sql` — the user's personal term bank + TM
(`personal_glossary` / `personal_tm`, each `id, user_id, updated_at, deleted,
- payload jsonb`) + owner-scoped RLS. A generic cursor-based reconciler
+payload jsonb`) + owner-scoped RLS. A generic cursor-based reconciler
(`src/storage/cloud/rowSync.ts`) syncs them with per-row LWW and soft-delete
tombstones. Backs Phase 3.4; **bundled corpora never sync** (corpus-seeded TM
carries a `corpusId` and is filtered out).
@@ -105,32 +105,32 @@ and safe to re-run:
`has_project_role` RLS helpers into a non-API `private` schema so they are not
reachable as PostgREST RPCs (clears the database linter). Apply after `0005`.
- `0007_compaction.sql` — the `claim_compaction(project_id, expected_seq, state,
- up_to_id)` RPC (SECURITY DEFINER, membership-checked): optimistically installs
+up_to_id)` RPC (SECURITY DEFINER, membership-checked): optimistically installs
a fresh compacted `ydoc_state` snapshot (guarded by the `seq` generation) and
prunes the subsumed `ydoc_updates` rows, so the append log stays bounded. Backs
- Phase 4.3. Apply after `0006`. *(Numbering note: ROADMAP §4.3 sketched
+ Phase 4.3. Apply after `0006`. _(Numbering note: ROADMAP §4.3 sketched
`0005_compaction.sql`, but `0005`/`0006` were taken by the 4.1 project
migrations, so it ships as `0007`; and the RPC signature is widened from the
sketch's `(project_id, expected_seq)` to also carry the new snapshot + the
pruned-through id, so the whole compaction is one atomic, RLS-safe
- transaction.)*
+ transaction.)_
- `0008_member_policies.sql` — member management: adds `profiles.email` (backfilled
- + seeded on signup) and a co-member `profiles` SELECT policy (`private.shares_project`);
- switches `project_members` update/delete to **project_manager-only** (insert still
- allows the owner's publish self-seed); a `enforce_min_one_pm` trigger that blocks
- removing or demoting a project's **last** manager; and the `invite_member(project_id,
- email, role)` RPC (SECURITY DEFINER, PM-checked) that resolves the email to a user id
- and upserts the membership (returns NULL when no account has that email — no
- enumeration surface). Backs Phase 5.1. Apply after `0007`. *(Numbering note: ROADMAP
- §5.1 sketched `0006_member_policies.sql`, but `0006`/`0007` were taken, so it ships as
- `0008`; invite resolves the email inside the RPC rather than via a client-side profiles
- lookup, keeping emails/ids server-side.)*
+ - seeded on signup) and a co-member `profiles` SELECT policy (`private.shares_project`);
+ switches `project_members` update/delete to **project_manager-only** (insert still
+ allows the owner's publish self-seed); a `enforce_min_one_pm` trigger that blocks
+ removing or demoting a project's **last** manager; and the `invite_member(project_id,
+email, role)` RPC (SECURITY DEFINER, PM-checked) that resolves the email to a user id
+ and upserts the membership (returns NULL when no account has that email — no
+ enumeration surface). Backs Phase 5.1. Apply after `0007`. _(Numbering note: ROADMAP
+ §5.1 sketched `0006_member_policies.sql`, but `0006`/`0007` were taken, so it ships as
+ `0008`; invite resolves the email inside the RPC rather than via a client-side profiles
+ lookup, keeping emails/ids server-side.)_
- `0009_workflow.sql` — adds a `project_stage` enum + `projects.stage`
(default `translation`) and `projects.deadline`, gating role-based editing (§5.2).
Writes are project_manager-only through the existing `projects_update` policy —
- no new policy. Backs Phase 5.2. Apply after `0008`. *(Numbering note: ROADMAP
+ no new policy. Backs Phase 5.2. Apply after `0008`. _(Numbering note: ROADMAP
§5.2 sketched `0007_workflow.sql`, but `0007`/`0008` were taken, so it ships as
- `0009`.)*
+ `0009`.)_
> Note: the linter's "Leaked Password Protection Disabled" warning is unrelated
> to these migrations — it's an optional **Authentication → Policies** toggle
@@ -153,7 +153,7 @@ In the Supabase dashboard under **Authentication → Providers**:
### Redirect URL allow-list (critical)
Verbalis is a **hash-routed static site**, so we pin Supabase's **PKCE** flow:
-the `?code=` lands in the query string *before* the `#/route` fragment, and a
+the `?code=` lands in the query string _before_ the `#/route` fragment, and a
pre-router bootstrap (`src/storage/cloud/authBootstrap.ts`) exchanges it and
strips the query before React mounts. For this to work, add every origin you
serve from to **Authentication → URL Configuration → Redirect URLs**:
@@ -284,7 +284,7 @@ project card (`ManageMembersDialog` → `src/storage/cloud/members.ts`). Apply
a second signed-in account:
- [ ] **PM invites**: as the manager, open Members → invite the second account's
- email as *translator*, then again as *revisor* → they appear in the roster
+ email as _translator_, then again as _revisor_ → they appear in the roster
with the chosen role, and can open the project.
- [ ] **Unknown email**: inviting an email with no Verbalis account yet shows the
"no account uses that email" notice (the RPC returns NULL) — no row added.
@@ -308,13 +308,13 @@ first. With a project_manager, a translator, and a revisor on one cloud project:
- [ ] **PM sets the stage**: in Members, the manager moves the project through
Translation → Review → Final; a non-PM sees the stage read-only.
-- [ ] **Translator forced to suggest in review**: with the stage on *Review*, the
+- [ ] **Translator forced to suggest in review**: with the stage on _Review_, the
translator's edit-mode toggle is locked to **Suggesting** (disabled) and
typing produces tracked suggestions, not direct edits.
- [ ] **Accept/reject is reviewer-only**: the translator sees suggestions but **no**
Accept/Reject (panel buttons hidden, hover card suppressed); the revisor and
the PM see and can resolve them.
-- [ ] **Final locks editing**: in *Final*, translator + revisor can't edit; only the
+- [ ] **Final locks editing**: in _Final_, translator + revisor can't edit; only the
PM can (e.g. to reopen by moving the stage back).
- [ ] **Local-only unchanged**: a project with no cloud link behaves as
"PM-of-self" — direct editing, suggesting, and accept/reject all available,
@@ -336,7 +336,7 @@ verification burden. The access token lives in memory only (gone on reload).
testing; add yourself as a test user). Add the scope
`https://www.googleapis.com/auth/drive.file`.
4. **APIs & Services → Credentials → Create credentials → OAuth client ID →
- Web application**. Under *Authorised JavaScript origins* add the exact
+ Web application**. Under _Authorised JavaScript origins_ add the exact
origins the app runs on, e.g. `http://localhost:5173` and
`https://verbalis.britx.me`. (No redirect URI is needed — GIS uses the token
flow, not a redirect.)
@@ -350,8 +350,8 @@ script is loaded and the connector code stays out of the initial bundle.
With `VITE_GOOGLE_CLIENT_ID` set:
-- [ ] **Addon listed**: the Add-ons page shows **Google Drive** under *Storage
- connectors* with Network / API key / Files permission chips and a working
+- [ ] **Addon listed**: the Add-ons page shows **Google Drive** under _Storage
+ connectors_ with Network / API key / Files permission chips and a working
on/off toggle.
- [ ] **Import from Drive**: in the Import dialog, **From Google Drive** opens the
Google consent popup, then a file list; picking a `.docx`/`.txt`/`.md`/XLIFF
@@ -396,8 +396,8 @@ never loaded and the connector code stays out of the initial bundle.
With `VITE_MS_CLIENT_ID` set:
-- [ ] **Addon listed**: the Add-ons page shows **OneDrive** under *Storage
- connectors* with Network / API key / Files permission chips and a working
+- [ ] **Addon listed**: the Add-ons page shows **OneDrive** under _Storage
+ connectors_ with Network / API key / Files permission chips and a working
on/off toggle.
- [ ] **Import from OneDrive**: in the Import dialog, **From OneDrive** opens the
Microsoft login popup, then a file list; picking a
diff --git a/docs/refactor-audit.md b/docs/refactor-audit.md
index 5ed63dc..eb35776 100644
--- a/docs/refactor-audit.md
+++ b/docs/refactor-audit.md
@@ -13,10 +13,11 @@ everything else — so the rest can be picked up one slice at a time.
**Verbalis is in excellent health.** ~30,900 lines across 309 TS/TSX files, a
clean feature/core/storage separation, `tsc -b` passes, and **717/717 unit tests
-+ ~29 e2e tests are green**. There are **zero** `TODO`/`FIXME`/`HACK` markers,
-only 5 `console.*` calls, and disciplined code-splitting keeps the optional
-cloud/AI/DOCX code out of the initial bundle. This is not a codebase that needs
-rescuing — it needs *organising and finishing touches*.
+
+- ~29 e2e tests are green**. There are **zero** `TODO`/`FIXME`/`HACK` markers,
+ only 5 `console.*` calls, and disciplined code-splitting keeps the optional
+ cloud/AI/DOCX code out of the initial bundle. This is not a codebase that needs
+ rescuing — it needs _organising and finishing touches_.
The findings below are therefore mostly **polish, tooling, and documentation**,
not structural rework. They are ranked by value ÷ risk so the highest-leverage,
@@ -24,18 +25,18 @@ lowest-danger items come first.
### Health snapshot
-| Signal | Value | Notes |
-|---|---|---|
-| Source size | ~30,900 LOC / 309 files | Well-factored |
-| Unit tests | 717 passing / 118 files | Excellent coverage of `core/*` + `storage/*` |
-| E2E tests | ~29 (Playwright) | Cover the real user journeys |
-| Typecheck | ✅ clean | Strict mode |
-| `TODO`/`FIXME`/`HACK` | 0 | — |
-| `console.*` | 5 | Acceptable; a couple could be dropped |
-| `any` / `ts-ignore` | 22 | Mostly legit (markdown/docx AST walkers) |
-| Dexie schema | v7 | Migrations are additive & tested |
-| Linter | ❌ none | See §3.1 |
-| Bundler | Vite 6, aggressive lazy chunks | Cloud/AI/DOCX all code-split |
+| Signal | Value | Notes |
+| --------------------- | ------------------------------ | -------------------------------------------- |
+| Source size | ~30,900 LOC / 309 files | Well-factored |
+| Unit tests | 717 passing / 118 files | Excellent coverage of `core/*` + `storage/*` |
+| E2E tests | ~29 (Playwright) | Cover the real user journeys |
+| Typecheck | ✅ clean | Strict mode |
+| `TODO`/`FIXME`/`HACK` | 0 | — |
+| `console.*` | 5 | Acceptable; a couple could be dropped |
+| `any` / `ts-ignore` | 22 | Mostly legit (markdown/docx AST walkers) |
+| Dexie schema | v7 | Migrations are additive & tested |
+| Linter | ❌ none | See §3.1 |
+| Bundler | Vite 6, aggressive lazy chunks | Cloud/AI/DOCX all code-split |
---
@@ -75,7 +76,7 @@ tests):
**3.1 Add a linter (and wire it into CI).**
There is no ESLint config, no `lint` script, and no eslint dependency. The code
-is consistent today (clearly hand-disciplined), but nothing *enforces* it — no
+is consistent today (clearly hand-disciplined), but nothing _enforces_ it — no
guard against unused vars/imports, `react-hooks/exhaustive-deps` mistakes,
floating promises, or accidental `console.log`s. Add a flat-config ESLint
(`typescript-eslint`, `eslint-plugin-react-hooks`, `eslint-plugin-react-refresh`)
@@ -84,8 +85,7 @@ plus a `pnpm lint` step in `ci.yml`. Low risk, compounding value.
**3.2 Pin the package manager.**
`package.json` has no `packageManager` field, and its `pnpm.onlyBuiltDependencies`
lives in the location **pnpm 11 no longer reads** (it warns and ignores it), while
-CI pins `pnpm/action-setup@v3` to v9. So a fresh local checkout (corepack → pnpm
-11) and CI (pnpm 9) resolve builds differently. Add `"packageManager": "pnpm@9.x"`
+CI pins `pnpm/action-setup@v3` to v9. So a fresh local checkout (corepack → pnpm 11) and CI (pnpm 9) resolve builds differently. Add `"packageManager": "pnpm@9.x"`
(matching CI) and move `onlyBuiltDependencies` to `pnpm-workspace.yaml` if you
bump to pnpm ≥10. Reconcile the two workflow files at the same time (action-setup
warns if both `version:` and `packageManager` are set).
@@ -134,6 +134,7 @@ The 22 `any`/`ts-ignore` sites are concentrated in `core/documents/toDocx.ts` an
node type. Not urgent, but it removes the last untyped seams.
**3.9 Accessibility & UX polish.**
+
- The theme toggle and several icon buttons rely on `aria-label` only — good, but
a visible focus ring audit across the new hero + cards is worth one pass.
- Consider `prefers-reduced-motion` for the sidebar/topbar transitions (the CSS
@@ -145,7 +146,7 @@ node type. Not urgent, but it removes the last untyped seams.
### P3 — larger / strategic (maintainer's call)
**3.10 A dedicated marketing landing page.**
-Today the app *is* the landing page (Excalidraw-style), and the elevated welcome
+Today the app _is_ the landing page (Excalidraw-style), and the elevated welcome
hero now serves that well for first-run. If you want SEO/marketing reach (feature
tour, screenshots, pricing, testimonials) without loading the app, a separate
static route or a small companion site is the way — but it is a product decision,
@@ -162,19 +163,19 @@ were explicitly deferred (see `architecture.md` Phase 6). Natural next addons.
## 4. Documentation inventory
-| Path | Role | Recommendation |
-|---|---|---|
-| `README.md` | Front door | ✅ refreshed this pass |
-| `docs/architecture.md` | Living architecture | ✅ refreshed; keep current |
-| `docs/cloud.md` | Cloud/signed-in setup | Living; keep |
-| `docs/refactor-audit.md` | This file | Living backlog |
-| `docs/revamp/ROADMAP.md` + `STATUS.md` | Revamp record | Keep as history; link from architecture |
-| `docs/revamp/prompts/*` (30 files) | Build scaffolding | Archive or delete — served their purpose |
-| `docs/vision/*` (6 files) | Pre-build product vision | Mark historical; superseded by shipped app |
-| `docs/roadmap-professional-features.md` | Pre-revamp plan | Mark historical; largely delivered |
+| Path | Role | Recommendation |
+| --------------------------------------- | ------------------------ | ------------------------------------------ |
+| `README.md` | Front door | ✅ refreshed this pass |
+| `docs/architecture.md` | Living architecture | ✅ refreshed; keep current |
+| `docs/cloud.md` | Cloud/signed-in setup | Living; keep |
+| `docs/refactor-audit.md` | This file | Living backlog |
+| `docs/revamp/ROADMAP.md` + `STATUS.md` | Revamp record | Keep as history; link from architecture |
+| `docs/revamp/prompts/*` (30 files) | Build scaffolding | Archive or delete — served their purpose |
+| `docs/vision/*` (6 files) | Pre-build product vision | Mark historical; superseded by shipped app |
+| `docs/roadmap-professional-features.md` | Pre-revamp plan | Mark historical; largely delivered |
The revamp is done, so several planning docs are now historical rather than
-directive. Nothing here is *wrong* — it just needs a clear "this was the plan /
+directive. Nothing here is _wrong_ — it just needs a clear "this was the plan /
this is the current state" boundary so a newcomer isn't misled.
---
diff --git a/docs/revamp/ROADMAP.md b/docs/revamp/ROADMAP.md
index 93488ce..fca69dc 100644
--- a/docs/revamp/ROADMAP.md
+++ b/docs/revamp/ROADMAP.md
@@ -20,26 +20,26 @@ This roadmap delivers the **web version**; desktop (Tauri) stays on the roadmap
- **Roles per project**: `project_manager` / `translator` / `revisor`, enforced by Postgres RLS where enforceable and by UI gating elsewhere.
- **Extensions & plugins**: built-in addon registry first (MT providers, QA rules, formats become addons), Google Drive + OneDrive connectors; marketplace later.
-The repo already contains real foundations to **extend, not replace**: per-project Yjs docs (y-indexeddb), Dexie↔Yjs bridges with Dexie as local source of truth, a tiny `SyncTransport` seam (`src/storage/sync/transport/types.ts`), version snapshots + per-segment history + read-only changes panel with author colours (`src/storage/repositories/versionRepo.ts`, `src/core/history/diff.ts`), per-segment un-anchored comments, and the opt-in per-segment Lexical editor with the load-bearing contract *"Lexical state is truth, `richStateToPlain()` derives plain `target`"* (`src/core/editor/richText.ts` — it uses `$getRoot().getTextContent()`, so mark nodes overriding `getTextContent()` shape plain text in both live and headless paths automatically). Genuinely new axes: real document/block model, in-editor accept/reject change tracking, Supabase identity + cloud transport, roles, addons.
+The repo already contains real foundations to **extend, not replace**: per-project Yjs docs (y-indexeddb), Dexie↔Yjs bridges with Dexie as local source of truth, a tiny `SyncTransport` seam (`src/storage/sync/transport/types.ts`), version snapshots + per-segment history + read-only changes panel with author colours (`src/storage/repositories/versionRepo.ts`, `src/core/history/diff.ts`), per-segment un-anchored comments, and the opt-in per-segment Lexical editor with the load-bearing contract _"Lexical state is truth, `richStateToPlain()` derives plain `target`"_ (`src/core/editor/richText.ts` — it uses `$getRoot().getTextContent()`, so mark nodes overriding `getTextContent()` shape plain text in both live and headless paths automatically). Genuinely new axes: real document/block model, in-editor accept/reject change tracking, Supabase identity + cloud transport, roles, addons.
## 2. Locked decisions (owner-confirmed)
-| Decision | Choice |
-| --- | --- |
-| Backend | **Supabase** (Auth + Postgres/RLS + Realtime + Storage); strictly additive, local-only mode never loads it |
-| v1 cloud connectors | **Google Drive + OneDrive** (storage-connector addons); iCloud deferred to desktop phase |
-| Build automation | **Prompt pack in this folder + a scheduled Routine** firing autonomous sessions, one phase per session, each ending in a draft PR |
-| Rate budget | **Claude Pro** → small surgical phases (one slice, ≤ ~15–20 files, tests included), max 1 phase/day |
+| Decision | Choice |
+| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| Backend | **Supabase** (Auth + Postgres/RLS + Realtime + Storage); strictly additive, local-only mode never loads it |
+| v1 cloud connectors | **Google Drive + OneDrive** (storage-connector addons); iCloud deferred to desktop phase |
+| Build automation | **Prompt pack in this folder + a scheduled Routine** firing autonomous sessions, one phase per session, each ending in a draft PR |
+| Rate budget | **Claude Pro** → small surgical phases (one slice, ≤ ~15–20 files, tests included), max 1 phase/day |
## 3. Architecture decisions
- **D1 — Tracked changes via Lexical mark nodes per segment cell, not a document-wide editor rewrite.** A custom `ChangeMarkNode` (modeled on `@lexical/mark`'s `MarkNode`, following the `InlineTagNode` precedent) is incremental, serializes inside `targetRich`, and survives versioning/restore for free. A document-wide editor would invalidate the entire per-segment stack (autosave, TM/QA, CRDT, XLIFF chips).
- **D2 — Plain-text semantics: `target` = "proposed text"** (pending insertions included, pending deletions excluded), implemented by `ChangeMarkNode.getTextContent() → ''` for deletes — the same trick `InlineTagNode` uses. Live editor and headless `richStateToPlain` agree with zero changes to TM/QA/search call sites. A pure `richStateToOriginal()` walker (exclude inserts, include deletes) feeds diff rendering. Confirm-to-TM is gated on "no pending changes" (Phase 1.6) so suggestions never contaminate the TM.
- **D3 — Change metadata inline in the mark attrs** (`{changeId, type: insert|delete, authorId, authorName, createdAt}`); no parallel structure to keep consistent. Panels use a pure `extractChanges(targetRich)`. Author colours reuse `src/core/history/authorColor.ts`.
-- **D4 — Collab granularity: per-segment edit leases + LWW `targetRich`**; character-level same-segment rich co-editing (`@lexical/yjs`/`Y.XmlText`) is roadmap. Leases (lowest-peerId tie-break) match CAT-industry row-locking norms; concurrent edits on *different* segments already merge perfectly.
+- **D4 — Collab granularity: per-segment edit leases + LWW `targetRich`**; character-level same-segment rich co-editing (`@lexical/yjs`/`Y.XmlText`) is roadmap. Leases (lowest-peerId tie-break) match CAT-industry row-locking norms; concurrent edits on _different_ segments already merge perfectly.
- **D5 — Document model: new `documents` + `blocks` Dexie tables above flat segments**; segments stay the unit of translation/TM/QA/sync and gain an unindexed `blockId`. Existing projects backfilled from `sourceMeta.blockIndex`; XLIFF projects get no document tree (round-trip untouched).
- **D6 — Supabase strictly additive**: env-flag + dynamic `import()` — zero bundle cost when unset. Cloud collab source of truth = Yjs blobs (`ydoc_state` snapshot + `ydoc_updates` append log with `author_id`); structured rows only where Postgres must query/enforce (`profiles`, `user_settings`, `projects`, `project_members`, `personal_termbank`/`personal_tm`).
-- **D7 — Auth: Supabase PKCE flow** (`flowType: 'pkce'` pinned) — the `?code=` query lands *before* the `#/route` fragment, so HashRouter never sees it; a pre-router bootstrap exchanges the code and strips the query. Native Supabase Auth covers Google/Apple/Azure OAuth + magic links; **passkeys are NOT native Supabase Auth** → honest fast-follow (Supabase roadmap or Edge-Function WebAuthn), not advertised in v1 UI.
+- **D7 — Auth: Supabase PKCE flow** (`flowType: 'pkce'` pinned) — the `?code=` query lands _before_ the `#/route` fragment, so HashRouter never sees it; a pre-router bootstrap exchanges the code and strips the query. Native Supabase Auth covers Google/Apple/Azure OAuth + magic links; **passkeys are NOT native Supabase Auth** → honest fast-follow (Supabase roadmap or Edge-Function WebAuthn), not advertised in v1 UI.
- **D8 — Roles: RLS enforces the enforceable** (membership, write access, PM-only management, `author_id = auth.uid()` on every update row → tamper-evident attribution). Content-level workflow rules (translator-must-suggest) are client-enforced with the authored append-only update log as audit trail; server-side validation is roadmap. Stated honestly in docs.
- **D9 — Extensions v1: in-process typed registry with real manifests/permissions**; sandboxed (iframe/worker RPC) hosting deferred but the manifest contract is final now, so extension authors never migrate.
@@ -48,62 +48,73 @@ The repo already contains real foundations to **extend, not replace**: per-proje
Sizing: **S** ≈ ≤8 files; **M** ≈ 10–20 files. Every phase ends green: `pnpm typecheck && pnpm test:unit && pnpm build` (+ Playwright where UI-facing), branch `claude/revamp-phase-N-N`, draft PR, [`STATUS.md`](./STATUS.md) updated in the same PR.
### Phase 0 — Bootstrap (this commit)
+
Roadmap, status checklist, prompt pack, and vision docs committed to the repo.
### Milestone 0 — Guardrails
+
- **0.1 CI hardening — S — deps: none.** Create `.github/workflows/ci.yml` (PR + non-main push: install → generate-icons → `tsc -b` → `vitest run` → build → Playwright chromium). Modify `playwright.config.ts` (CI retries/reporter), `package.json` (`test:unit`, `typecheck` scripts). Keep `deploy.yml` untouched; upload traces on failure. **DoD**: PRs show a green check running unit + e2e.
### Milestone 1 — Inline tracked changes + anchored comments (flagship; pure client, no backend)
+
- **1.1 Change model core + `ChangeMarkNode` + derivation semantics — M — deps: 0.1.** Create `src/core/changes/model.ts`, `src/core/changes/extract.ts`, `src/features/editor/rich/ChangeMarkNode.ts` (attrs per D3; `getTextContent()→''` for deletes; strikethrough/underline + author colour in `createDOM`). Modify `src/core/editor/richText.ts` (add `richStateToOriginal`), `src/styles/globals.css`. Tests: model/extract round-trips; plain derivation per D2. **DoD**: hand-built `targetRich` with marks renders styled; derivation semantics proven; existing tests pass.
- **1.2 Rich editing default-on + Playwright migration — M — deps: 1.1.** Flip `DEFAULT_EDITOR_SETTINGS.richEditing` to true (`src/storage/repositories/settingsRepo.ts`); migrate all 7 e2e specs off `textarea.fill()` to contenteditable helpers (`tests/e2e/helpers/richEditor.ts`); plain stays for code segments + opt-out. **DoD**: full e2e suite green with rich default; opt-out works.
- **1.3 Suggesting mode — M — deps: 1.2.** Create `src/core/changes/suggest.ts` ($-helpers: insert/delete-as-suggestion, mark split/merge), `src/features/editor/rich/TrackedChangesPlugin.tsx` (intercept `CONTROLLED_TEXT_INSERTION`/`PASTE`/`CUT`/`KEY_BACKSPACE`/`KEY_DELETE` when suggesting; IME: act only on final insertions post-composition — top correctness risk). Modify `useEditorModeStore` (`editMode: direct|suggesting`), `RichSegmentEditor`, toolbar/palette toggle, `settingsRepo` (stable `authorId` uuid in `profile.identity`). **DoD**: no keystroke destroys prior text in suggesting mode; `target` follows D2; direct mode byte-identical to today.
-- **1.4 Accept/reject + Changes panel rework — M — deps: 1.3.** Create `src/core/changes/resolve.ts` (live `$accept/$rejectChange` + headless `resolveChangeInRich` for unmounted segments), `src/features/editor/rich/ChangeHoverCard.tsx`. Rework `changes/ChangesPanel.tsx` to list *pending* suggestions live (old version-diff view becomes a "History" sub-tab); `segmentRepo.resolveChange` routes through `segmentRepo.update` so bridges/versioning see it. Accept-all/reject-all per segment & project. **DoD**: full suggest → review → accept/reject loop works locally.
+- **1.4 Accept/reject + Changes panel rework — M — deps: 1.3.** Create `src/core/changes/resolve.ts` (live `$accept/$rejectChange` + headless `resolveChangeInRich` for unmounted segments), `src/features/editor/rich/ChangeHoverCard.tsx`. Rework `changes/ChangesPanel.tsx` to list _pending_ suggestions live (old version-diff view becomes a "History" sub-tab); `segmentRepo.resolveChange` routes through `segmentRepo.update` so bridges/versioning see it. Accept-all/reject-all per segment & project. **DoD**: full suggest → review → accept/reject loop works locally.
- **1.5 Range-anchored threaded comments — M — deps: 1.2 (parallel to 1.3/1.4).** Create `CommentMarkNode` (thin `MarkNode` subclass), `comments/CommentsPanel.tsx` (project-wide, filter, click-to-jump). Extend `SegmentComment` with `parentId?/anchorId?/quote?` (additive), carry through `segmentCrdt.ts` comment mapping; "comment on selection" in toolbar; resolve dims/removes mark. Comments stay inline on segments (existing Dexie + Y.Array path — zero new sync work). **DoD**: select → comment → highlighted range + thread; replies/resolve; survives Yjs round-trip + restore.
-- **1.6 Review polish — S — deps: 1.4, 1.5.** Confirm blocked while pending changes exist (toast); next/prev-change keyboard nav + palette commands; revision stage pins Changes/Comments panels; show/hide marks toggle. **DoD**: pending segments can't reach TM; keyboard review works. *Milestone 1 = Google-Docs-style review, fully local.*
+- **1.6 Review polish — S — deps: 1.4, 1.5.** Confirm blocked while pending changes exist (toast); next/prev-change keyboard nav + palette commands; revision stage pins Changes/Comments panels; show/hide marks toggle. **DoD**: pending segments can't reach TM; keyboard review works. _Milestone 1 = Google-Docs-style review, fully local._
### Milestone 2 — Document model, DOCX fidelity, preview, DOCX export
+
- **2.1 Document/block schema (Dexie v6) + backfill — M — deps: 0.1.** Create `src/core/documents/model.ts` (`DocumentEntity`, `Block` with kind/depth/attrs/`InlineRun[]` source runs, tables/images), `documentRepo`/`blockRepo`, `fromSegments.ts` backfill. Modify `db.ts` (v6: `documents`, `blocks`, `assets`; upgrade backfills monolingual projects), `Segment.blockId?`, `segmentCrdt.ts` SCALAR_FIELDS, import flow stamps `blockId`. Blocks not mirrored to Yjs in v1 (immutable post-import). **DoD**: fresh + upgraded projects have consistent block trees; no UI change.
- **2.2 DOCX import fidelity — M — deps: 2.1.** Create `src/core/documents/docxImport.ts`: mammoth with explicit `styleMap` + `convertImage` → assets; walker captures strong/em/u/sub/sup/a runs, tables, images. `segmentation/docx.ts` delegates, keeping the `ParsedSegment` contract. Store original .docx blob in `assets` for future re-import. Fixture-based tests. **DoD**: styled DOCX yields blocks with runs/tables/images; segment output strictly richer than before.
- **2.3 Document preview pane — M — deps: 2.1.** `preview/DocumentPreview.tsx` + pure `core/documents/render.ts`: block tree with translated targets substituted (fallback source + untranslated tint), source/target/side-by-side, click-block→focus-segment. **DoD**: live assembled translated document while editing.
-- **2.4 Clean DOCX export — M — deps: 2.2, 2.3.** `core/documents/toDocx.ts` (dynamic `import('docx')`): blocks + `targetRich` formatting runs → headings/lists/links/tables/images. Pending changes export as accepted-preview (D2) with warning. Test by unzipping the buffer and asserting `document.xml`. **DoD**: import → translate → export opens correctly in Word. *Milestone 2 = document revamp, fully local.*
+- **2.4 Clean DOCX export — M — deps: 2.2, 2.3.** `core/documents/toDocx.ts` (dynamic `import('docx')`): blocks + `targetRich` formatting runs → headings/lists/links/tables/images. Pending changes export as accepted-preview (D2) with warning. Test by unzipping the buffer and asserting `document.xml`. **DoD**: import → translate → export opens correctly in Word. _Milestone 2 = document revamp, fully local._
### Milestone 3 — Accounts (Supabase) + synced preferences & personal resources
+
- **3.1 Supabase bootstrap + Google/magic-link auth + PKCE/HashRouter handling — M — deps: 0.1.** Create `src/storage/cloud/supabaseClient.ts` (lazy singleton, `flowType:'pkce'`, only when `VITE_SUPABASE_URL` set), `authBootstrap.ts` (pre-router `?code=` exchange + `history.replaceState`), `features/account/` (`useAuthStore`, `SignInDialog`, `AccountMenu`), `supabase/migrations/0001_profiles.sql` (profiles + RLS + signup trigger), `docs/cloud.md`. Modify `main.tsx` (await bootstrap), `TopBar`, CI (build once with dummy env). Signed-out behavior byte-identical; no supabase chunk in initial graph when unset. **DoD**: OAuth + magic link round-trip on the deployed hash-routed site.
- **3.2 Microsoft + Apple providers + account settings — S — deps: 3.1.** Provider buttons driven by `VITE_AUTH_PROVIDERS` (deploys without Apple's paid setup hide the button, not break). `AccountSettingsSection` (display name → profiles, linked identities, sign out). Document Azure app registration + Apple Developer Program requirement. **DoD**: configured providers work; unconfigured are hidden.
-- **3.3 Synced preferences/settings/layout — M — deps: 3.1.** `0002_user_settings.sql` (`user_settings(user_id,key,value jsonb,updated_at)` RLS owner-only); `cloud/settingsSync.ts` — pull-on-signin, push-on-change, per-key LWW; `SYNCED_SETTINGS_KEYS` allowlist (editor prefs, layout, lookup, spell; **MT API keys never sync**). Dexie stays the read path — sync is a background reconciler. **DoD**: sign in on machine B → prefs/layout arrive; local-only unaffected. *Shipped as `0003_user_settings.sql` (0002 was taken by profiles hardening). Layout deferred to 3.3.1 (see below).*
-- **3.3.1 Sidebar layout sync — S — deps: 3.3.** Fold the sidebar layout (`useSidebarPanelStore`, a zustand `persist` store in localStorage) into the 3.3 reconciler. Add an LWW `updatedAt` to its persisted state (stamped on each layout/collapsed mutation), register it as a `settingsSync` adapter (the engine is already generic over read/write/timestamp), and rehydrate the live store after a pull so machine B reflects the layout without a reload. **DoD**: reorder/collapse panels on machine A → arrives on machine B; local-only unaffected. *Shipped as a dedicated `features/editor/layoutSync.ts` reusing the `user_settings` table + LWW (the 3.3 `settingsSync` engine is bound to Dexie `settingsRepo` keys, not a source-agnostic adapter engine as this note optimistically implied) — the layout is a zustand/localStorage store, so it syncs as a sibling reconciler rather than a settingsSync adapter.*
-- **3.4 Personal term bank + TM sync — M — deps: 3.3.** `0003_personal_resources.sql` (owner-only rows + tombstones); generic `cloud/rowSync.ts` (cursor, LWW, tombstones); Dexie v7 `syncTombstones` table; glossary/tm repos stamp `updatedAt` + tombstone deletes when signed in; bundled corpora never sync. **DoD**: two devices converge; deletes don't resurrect. *Milestone 3 = accounts shipped.* *Shipped as `0004_personal_resources.sql` (0003 was taken by user_settings); soft-delete `deleted` column on the cloud rows carries tombstones server-side.*
+- **3.3 Synced preferences/settings/layout — M — deps: 3.1.** `0002_user_settings.sql` (`user_settings(user_id,key,value jsonb,updated_at)` RLS owner-only); `cloud/settingsSync.ts` — pull-on-signin, push-on-change, per-key LWW; `SYNCED_SETTINGS_KEYS` allowlist (editor prefs, layout, lookup, spell; **MT API keys never sync**). Dexie stays the read path — sync is a background reconciler. **DoD**: sign in on machine B → prefs/layout arrive; local-only unaffected. _Shipped as `0003_user_settings.sql` (0002 was taken by profiles hardening). Layout deferred to 3.3.1 (see below)._
+- **3.3.1 Sidebar layout sync — S — deps: 3.3.** Fold the sidebar layout (`useSidebarPanelStore`, a zustand `persist` store in localStorage) into the 3.3 reconciler. Add an LWW `updatedAt` to its persisted state (stamped on each layout/collapsed mutation), register it as a `settingsSync` adapter (the engine is already generic over read/write/timestamp), and rehydrate the live store after a pull so machine B reflects the layout without a reload. **DoD**: reorder/collapse panels on machine A → arrives on machine B; local-only unaffected. _Shipped as a dedicated `features/editor/layoutSync.ts` reusing the `user_settings` table + LWW (the 3.3 `settingsSync` engine is bound to Dexie `settingsRepo` keys, not a source-agnostic adapter engine as this note optimistically implied) — the layout is a zustand/localStorage store, so it syncs as a sibling reconciler rather than a settingsSync adapter._
+- **3.4 Personal term bank + TM sync — M — deps: 3.3.** `0003_personal_resources.sql` (owner-only rows + tombstones); generic `cloud/rowSync.ts` (cursor, LWW, tombstones); Dexie v7 `syncTombstones` table; glossary/tm repos stamp `updatedAt` + tombstone deletes when signed in; bundled corpora never sync. **DoD**: two devices converge; deletes don't resurrect. _Milestone 3 = accounts shipped._ _Shipped as `0004_personal_resources.sql` (0003 was taken by user_settings); soft-delete `deleted` column on the cloud rows carries tombstones server-side._
### Milestone 4 — Cloud projects + real-time collaboration
+
- **4.1 Cloud project schema + RLS + publish/join — M — deps: 3.1.** `0004_projects.sql`: `projects`, `project_members(role enum: project_manager|translator|revisor)`, `ydoc_state(state bytea, seq)`, `ydoc_updates(update bytea, author_id default auth.uid())`, member-scoped RLS + `is_project_member`/`has_role` helpers, `project-files` bucket. `cloud/projectCloud.ts`: `publishProject` (seeds cloud from local Yjs doc — pre-publish history survives), `openCloudProject` (hydrates local Dexie project). `Project.cloud?: {id, role}`. Publish is owner-only UX; joining is member-only (prevents duplicate cloud projects). **DoD**: publish; second account opens it; non-members RLS-denied.
- **4.2 `SupabaseRealtimeTransport` + chunking — M — deps: 4.1.** Implements the existing `SyncTransport` interface unchanged (`start/send/onMessage/destroy`, `hello/bye/state-request/state/update/presence`). Private broadcast channel `project:{cloudId}`; pure `chunking.ts` splits payloads >60KB (Realtime caps ~250KB; base64 +33%); outbound updates debounce-merged via `Y.mergeUpdates` (300ms). Initial state comes from Postgres (4.3), never broadcast for large docs. Transport picked in `createTransport` when project is cloud + signed in; BroadcastChannel/LAN paths untouched. **DoD**: two browsers exchange live edits; protocol unit-covered.
- **4.3 Postgres persistence loop — M — deps: 4.2.** `cloud/ydocPersistence.ts`: on open, fetch `ydoc_state` + newer updates, apply as `ORIGIN_REMOTE`; local updates appended debounced; client-side compaction >200 rows via optimistic `claim_compaction(project_id, expected_seq)` RPC (`0005_compaction.sql`). Realtime is latency, Postgres is truth; offline members converge by replay (Yjs idempotent). **DoD**: offline-A/online-B edits converge on reconnect; updates table bounded.
-- **4.4 Live collab UX: cursors, leases, attribution — M — deps: 4.3, 1.6.** Presence gains `editingSegmentId`/`userId`/`caret`; deterministic lease (lowest peerId) renders other peers' segment read-only with name chip + live preview; `RemoteCaretOverlay` reuses offset→DOM mapping from `src/core/spell/offsets.ts`. Remote suggesting-mode edits arrive pre-attributed via `ChangeMarkNode` — "everyone's changes tracked" falls out of M1 + sync. Guard in `reverseBridge.ts`: rebuild rich from plain on detected stale-LWW mismatch. **DoD**: live cursors; simultaneous entry yields one editor + one viewer; lease releases on blur. *Milestone 4 = real-time collaboration shipped.* *Shipped the lease/attribution/guard core (`sync/lease.ts`, presence `userId`+`caret`, `SegmentRow` read-only + "is editing" chip, reverseBridge stale-LWW guard); the **live remote-caret overlay is deferred to 4.4.1** — the presence model carries `caret` as the seam, but rendering it needs editor→session caret reporting + offset→DOM measurement, a separable slice.*
-- **4.4.1 Remote caret overlay — S — deps: 4.4.** Report the local caret (Lexical selection → plain-text offset) through `setActiveSegment(id, caret)`, and render remote peers' carets/selections in each segment with a `RemoteCaretOverlay` reusing the offset→DOM mapping in `src/core/spell/offsets.ts` (as `SpellUnderlinePlugin` does). **DoD**: live remote cursors move as peers type; overlay never mutates the editor tree. *Shipped: `mapOffsetToNode` (core) + `richOffsets.ts` (`selectionToCaretRange`/`buildLeafSpans`, reproducing the plain projection incl. delete-mark → '') + `RemoteCaretOverlay`; the focused editor reports its caret throttled to 100ms. **Milestone 4 complete.***
+- **4.4 Live collab UX: cursors, leases, attribution — M — deps: 4.3, 1.6.** Presence gains `editingSegmentId`/`userId`/`caret`; deterministic lease (lowest peerId) renders other peers' segment read-only with name chip + live preview; `RemoteCaretOverlay` reuses offset→DOM mapping from `src/core/spell/offsets.ts`. Remote suggesting-mode edits arrive pre-attributed via `ChangeMarkNode` — "everyone's changes tracked" falls out of M1 + sync. Guard in `reverseBridge.ts`: rebuild rich from plain on detected stale-LWW mismatch. **DoD**: live cursors; simultaneous entry yields one editor + one viewer; lease releases on blur. _Milestone 4 = real-time collaboration shipped._ _Shipped the lease/attribution/guard core (`sync/lease.ts`, presence `userId`+`caret`, `SegmentRow` read-only + "is editing" chip, reverseBridge stale-LWW guard); the **live remote-caret overlay is deferred to 4.4.1** — the presence model carries `caret` as the seam, but rendering it needs editor→session caret reporting + offset→DOM measurement, a separable slice._
+- **4.4.1 Remote caret overlay — S — deps: 4.4.** Report the local caret (Lexical selection → plain-text offset) through `setActiveSegment(id, caret)`, and render remote peers' carets/selections in each segment with a `RemoteCaretOverlay` reusing the offset→DOM mapping in `src/core/spell/offsets.ts` (as `SpellUnderlinePlugin` does). **DoD**: live remote cursors move as peers type; overlay never mutates the editor tree. _Shipped: `mapOffsetToNode` (core) + `richOffsets.ts` (`selectionToCaretRange`/`buildLeafSpans`, reproducing the plain projection incl. delete-mark → '') + `RemoteCaretOverlay`; the focused editor reports its caret throttled to 100ms. **Milestone 4 complete.**_
### Milestone 5 — Roles & workflow
-- **5.1 Members & roles management — M — deps: 4.1.** `cloud/members.ts` (invite by email via profiles lookup, change role, remove), `MembersPanel`, `0006_member_policies.sql` (PM-only member writes; trigger keeps ≥1 PM). **DoD**: PM invites translator + revisor; non-PM read-only; server rejects non-PM writes. *Shipped as `0008_member_policies.sql` (0006/0007 taken) + `cloud/members.ts` + a `ManageMembersDialog` on the project card (lazy-loaded) instead of a sidebar `MembersPanel`; invite resolves the email inside the SECURITY DEFINER `invite_member` RPC (PM-checked, no enumeration surface) rather than a client-side profiles lookup; `profiles.email` + a co-member SELECT policy added; a `enforce_min_one_pm` trigger guards the last manager.*
-- **5.2 Role-gated editing workflow — M — deps: 5.1, 1.6.** Pure `core/workflow/rules.ts`: `(role, stage, status) → {canEditDirect, mustSuggest, canResolveChanges, canReview, canManage}`; `projects.stage (translation|review|final)` + `deadline` (`0007_workflow.sql`, PM-only update). Translator in review stage is forced into suggesting (toggle disabled); accept/reject visible only to revisor/PM; local-only projects = PM-of-self (all permissive, zero regression). **DoD**: full role matrix works; local-only unchanged. *Shipped: pure `core/workflow/rules.ts` + `useWorkflowStore` gating (forced-suggest toggle, reviewer-only accept/reject in `ChangesPanel`/`ChangeHoverCard`), `0009_workflow.sql` (`project_stage` + `deadline`, PM-only via existing `projects_update`), `Project.cloud.stage` read on open + `changeProjectStage`, and a PM stage selector in `ManageMembersDialog`.*
-- **5.3 Approval + attribution in versioning — S — deps: 5.2.** `ProjectVersion.authorId?/approval?`; revisor/PM "Sign off" creates a labeled approval version; one stable author identity across marks, comments, versions, presence. **DoD**: sign-off flow; attribution unified. *Milestone 5 = roles shipped → **web v2 launch candidate**.* *Shipped: `ProjectVersion.authorId`/`approval`; `versionRepo` stamps `authorId` (the shared `ensureLocalAuthor` identity) on every capture + a `signOff()` that records a labeled approval version; `useCanReview` gates a **Sign off** button in `VersionHistoryPanel`, which badges approval versions. No migration (versions are local Dexie). **Milestone 5 complete.***
+
+- **5.1 Members & roles management — M — deps: 4.1.** `cloud/members.ts` (invite by email via profiles lookup, change role, remove), `MembersPanel`, `0006_member_policies.sql` (PM-only member writes; trigger keeps ≥1 PM). **DoD**: PM invites translator + revisor; non-PM read-only; server rejects non-PM writes. _Shipped as `0008_member_policies.sql` (0006/0007 taken) + `cloud/members.ts` + a `ManageMembersDialog` on the project card (lazy-loaded) instead of a sidebar `MembersPanel`; invite resolves the email inside the SECURITY DEFINER `invite_member` RPC (PM-checked, no enumeration surface) rather than a client-side profiles lookup; `profiles.email` + a co-member SELECT policy added; a `enforce_min_one_pm` trigger guards the last manager._
+- **5.2 Role-gated editing workflow — M — deps: 5.1, 1.6.** Pure `core/workflow/rules.ts`: `(role, stage, status) → {canEditDirect, mustSuggest, canResolveChanges, canReview, canManage}`; `projects.stage (translation|review|final)` + `deadline` (`0007_workflow.sql`, PM-only update). Translator in review stage is forced into suggesting (toggle disabled); accept/reject visible only to revisor/PM; local-only projects = PM-of-self (all permissive, zero regression). **DoD**: full role matrix works; local-only unchanged. _Shipped: pure `core/workflow/rules.ts` + `useWorkflowStore` gating (forced-suggest toggle, reviewer-only accept/reject in `ChangesPanel`/`ChangeHoverCard`), `0009_workflow.sql` (`project_stage` + `deadline`, PM-only via existing `projects_update`), `Project.cloud.stage` read on open + `changeProjectStage`, and a PM stage selector in `ManageMembersDialog`._
+- **5.3 Approval + attribution in versioning — S — deps: 5.2.** `ProjectVersion.authorId?/approval?`; revisor/PM "Sign off" creates a labeled approval version; one stable author identity across marks, comments, versions, presence. **DoD**: sign-off flow; attribution unified. _Milestone 5 = roles shipped → **web v2 launch candidate**._ _Shipped: `ProjectVersion.authorId`/`approval`; `versionRepo` stamps `authorId` (the shared `ensureLocalAuthor` identity) on every capture + a `signOff()` that records a labeled approval version; `useCanReview` gates a **Sign off** button in `VersionHistoryPanel`, which badges approval versions. No migration (versions are local Dexie). **Milestone 5 complete.**_
### Milestone 6 — Extensions & connectors
-- **6.1 Extension registry + MT providers as built-in addons — M — deps: 0.1.** `core/extensions/types.ts` (`ExtensionManifest {id, name, version, kinds, permissions, builtIn}`; kinds: `mt-provider|qa-rule|panel|import-format|export-format|storage-connector`), `registry.ts` (enablement persisted in settings), `builtins.ts`; `src/extensions/mt/*` manifest wrappers delegating to existing `src/core/mt/*` (no logic moves); `mt/index.ts` resolves via registry. Existing MT tests untouched prove behavior preservation. **DoD**: four MT providers run through registry; disabling removes from panel. *Shipped: `core/extensions/{types,registry,builtins}.ts` (dependency-free singleton, enablement as a disabled set, `subscribe`) + `src/extensions/mt` wrappers; `core/mt` `enabledProviders` + `useMTSettings` AND the user setting with the registry (reactive via `useSyncExternalStore`); disabled set persisted under the device-local `extensions.disabled` key, registered from `main.tsx`. No Add-ons page yet (6.2).*
-- **6.2 QA rules + formats as addons + Add-ons page — M — deps: 6.1.** Wrap `QA_CODES` rules and XLIFF/TMX/TBX/CSV/DOCX-export as contributions; `features/addons/AddonsPage.tsx` (catalogue, enable/disable, permission display, "suggested/built-in" UX); `/addons` route + nav. QA falls back to all-on when registry empty (keeps tests). **DoD**: Add-ons page lists everything pluggable; toggling a QA addon changes QA output. *Shipped: QA (`qa.`) + format (`format.*`) manifests in `core/extensions/builtins`; `core/qa/registryRules` (`effectiveQaRules` ANDs toggles with the registry, defaults on when unregistered) wired into `QAPanel` (reactive); `features/addons/AddonsPage` at `/addons` (+ nav) grouping addons by kind with permission chips + built-in badges — MT + QA toggle (enforced), formats shown Always-on (format-level gating deferred).*
+
+- **6.1 Extension registry + MT providers as built-in addons — M — deps: 0.1.** `core/extensions/types.ts` (`ExtensionManifest {id, name, version, kinds, permissions, builtIn}`; kinds: `mt-provider|qa-rule|panel|import-format|export-format|storage-connector`), `registry.ts` (enablement persisted in settings), `builtins.ts`; `src/extensions/mt/*` manifest wrappers delegating to existing `src/core/mt/*` (no logic moves); `mt/index.ts` resolves via registry. Existing MT tests untouched prove behavior preservation. **DoD**: four MT providers run through registry; disabling removes from panel. _Shipped: `core/extensions/{types,registry,builtins}.ts` (dependency-free singleton, enablement as a disabled set, `subscribe`) + `src/extensions/mt` wrappers; `core/mt` `enabledProviders` + `useMTSettings` AND the user setting with the registry (reactive via `useSyncExternalStore`); disabled set persisted under the device-local `extensions.disabled` key, registered from `main.tsx`. No Add-ons page yet (6.2)._
+- **6.2 QA rules + formats as addons + Add-ons page — M — deps: 6.1.** Wrap `QA_CODES` rules and XLIFF/TMX/TBX/CSV/DOCX-export as contributions; `features/addons/AddonsPage.tsx` (catalogue, enable/disable, permission display, "suggested/built-in" UX); `/addons` route + nav. QA falls back to all-on when registry empty (keeps tests). **DoD**: Add-ons page lists everything pluggable; toggling a QA addon changes QA output. _Shipped: QA (`qa.`) + format (`format.*`) manifests in `core/extensions/builtins`; `core/qa/registryRules` (`effectiveQaRules` ANDs toggles with the registry, defaults on when unregistered) wired into `QAPanel` (reactive); `features/addons/AddonsPage` at `/addons` (+ nav) grouping addons by kind with permission chips + built-in badges — MT + QA toggle (enforced), formats shown Always-on (format-level gating deferred)._
- **6.3 Google Drive connector — M — deps: 6.1, 2.4.** `src/extensions/connectors/gdrive/` (Google Identity Services token client, `drive.file` scope — avoids verification burden; REST list/download/upload; token in memory/session only), generic `ConnectorFilePicker`; "From cloud" in ImportDialog + "Save to cloud" on DOCX export. Pure client OAuth — works for local-only users too. **DoD**: import DOCX from Drive; export back to Drive.
-- **6.4 OneDrive connector — S — deps: 6.3.** `src/extensions/connectors/onedrive/` via dynamic `@azure/msal-browser` (`loginPopup` — avoids redirect/hash-router interplay), Graph `Files.ReadWrite`. **DoD**: same loop on OneDrive; both connectors appear as addons with permission labels. *Milestone 6 = extensions + connectors shipped.*
+- **6.4 OneDrive connector — S — deps: 6.3.** `src/extensions/connectors/onedrive/` via dynamic `@azure/msal-browser` (`loginPopup` — avoids redirect/hash-router interplay), Graph `Files.ReadWrite`. **DoD**: same loop on OneDrive; both connectors appear as addons with permission labels. _Milestone 6 = extensions + connectors shipped._
### Roadmap tail (not scheduled)
+
Tauri desktop (real mDNS LAN transport, native FS, iCloud via CloudKit), passkeys (native Supabase when available, else Edge-Function WebAuthn), marketplace + sandboxed third-party extensions (iframe/worker RPC host consuming the 6.1 manifest), project branches/merge atop `versions`, `@lexical/yjs` character-level same-segment co-editing (replacing lease+LWW), optional E2E encryption for cloud projects, Edge-Function compaction + server-side workflow validation, comments notifications.
## 5. Build automation: prompts + Routines (Pro-calibrated)
### 5.1 Mechanism
+
- **The repo is the coordination point.** [`STATUS.md`](./STATUS.md) tracks one line per phase (`pending / in-progress / in-review / done (PR #n)`); [`prompts/phase-N-N.md`](./prompts/) holds the per-phase prompt. Every autonomous session reads STATUS first — sessions are stateless and order-safe.
- **One daily Routine, not 20+ triggers.** A single Routine (fresh session per fire) runs [`prompts/phase-runner.md`](./prompts/phase-runner.md): read STATUS → if the previous phase's PR is open, check CI/review comments, fix if needed, stop → else pick the next `pending` phase whose deps are `done`, load its prompt file, implement exactly that scope, run tests, push `claude/revamp-phase-N-N`, open draft PR, update STATUS, subscribe to PR activity. Robust to slipping: nothing double-fires; a skipped day costs nothing.
- **The owner is the merge gate.** Each phase = one draft PR reviewed/merged by the owner; the next session only proceeds past a merged (or explicitly skipped) predecessor.
- **Pro-sizing rules baked into every prompt**: one coherent slice; no drive-by refactors; on scope overflow, ship the core slice green and append the remainder to STATUS as phase N.N.1. Never leave the branch red.
### 5.2 Schedule
+
- Routine: daily at **10:00 UTC (07:00 América/São_Paulo — adjustable)**, fresh session per fire, push + email completion notifications on.
- Expected calendar: ~4–5 weeks for Milestones 0–5 (web v2 launch candidate), +1–2 weeks for Milestone 6, at ~1 merged phase/day with occasional review-fix days. Phases 1.5, 2.x and 6.1 can interleave when a cloud phase is blocked on review.
- Manual override: paste any `prompts/phase-N-N.md` into a fresh session whenever spare quota exists; STATUS keeps everything consistent.
diff --git a/docs/revamp/STATUS.md b/docs/revamp/STATUS.md
index 3ca9f98..8321765 100644
--- a/docs/revamp/STATUS.md
+++ b/docs/revamp/STATUS.md
@@ -4,42 +4,43 @@ Single source of truth for build progress. **Every revamp PR must update this fi
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) |
+| 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
@@ -63,7 +64,7 @@ Rules for autonomous sessions:
- 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.ts` — `exportProjectDocx(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.replaceState`s 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 — 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.replaceState`s 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).
@@ -71,14 +72,14 @@ Rules for autonomous sessions:
- **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.sql` — `projects`, `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 `WireChunk`s (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.ts` — `SupabaseRealtimeTransport 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 `update`s 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_compaction`s (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.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 `WireChunk`s (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.ts` — `SupabaseRealtimeTransport 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 `update`s 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_compaction`s (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 `ChangeMarkNode`s. `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.ts` — `selectionToCaretRange(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 `EditorPage`→`useProjectSync`→`setActiveSegment(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 `
- Verbalis started with a stubborn problem: I needed to translate
- sensitive, security-critical material and there was no tool I could
- fully trust. The mainstream options wanted my documents on their
- servers. The privacy-respecting ones couldn't speak the formats real
- translation work runs on — XLIFF, TMX, TBX — so they never fit a
+ Verbalis started with a stubborn problem: I needed to translate sensitive,
+ security-critical material and there was no tool I could fully trust. The mainstream
+ options wanted my documents on their servers. The privacy-respecting ones couldn't speak
+ the formats real translation work runs on — XLIFF, TMX, TBX — so they never fit a
professional CAT workflow.
- So I built the tool I wanted: local-first by default{' '}
- — your files, translation memory and glossaries never leave your
- browser — yet fluent in the industry's interchange
- formats, so it slots into the same pipelines as memoQ,
- Trados and OmegaT.
+ So I built the tool I wanted: local-first by default — your files,
+ translation memory and glossaries never leave your browser — yet{' '}
+ fluent in the industry's interchange formats, so it slots into the same
+ pipelines as memoQ, Trados and OmegaT.
- Verbalis is a CAT tool for people who handle confidential text.
- Privacy here isn't a setting you toggle — it's the architecture.
+ Verbalis is a CAT tool for people who handle confidential text. Privacy here isn't a
+ setting you toggle — it's the architecture.
- Verbalis is source-available{' '}
- — the code is public to read and learn from, under a two-tier license:
+ Verbalis is source-available —
+ the code is public to read and learn from, under a two-tier license:
Read the full license
-
- Contact for commercial use
-
+ Contact for commercial use
- Sign in to sync your preferences, term bank, and cloud projects across
- devices. Verbalis stays 100% local until you sign in.
+ Sign in to sync your preferences, term bank, and cloud projects across devices. Verbalis
+ stays 100% local until you sign in.