diff --git a/AUDIT.md b/AUDIT.md index da15f93f..2116756e 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -6,6 +6,8 @@ **Quality gate (2026-08-12 — encryption lifecycle + desktop reliability + recovery journal):** lint ✅ · typecheck ✅ (tsgo) · i18n:check ✅ (**2904 keys × 19 locales**) · targeted unit tests ✅ (271 across the full affected storage suite post-merge: `protectedStoreMigration`, `encryptionMigrationJournal`, `secondaryPayloadStoreAdapter(s)`, `protectedWriteAdmission`, `idbStoreEncryption`, `dbService*`, `sceneRevisionService`, `aiInferenceCacheService`) · CI Quality Gate (Node 22 + 24) green on #337/#339 · codecov/patch ✅ (73.46% → target after adding secondary-adapter payload-shape coverage). #335 (fail-closed lifecycle), #336 (desktop AI/Python hardening for #332/#333), #337 (durable migration journal + secondary-store adapters), #339 (cross-tab write-admission fixing the migration TOCTOU race) all merged into `main`. PR #310 closed as superseded (`docs/PR-310-RECONCILIATION.md`). Production disable/passphrase-rotation wiring remains open Phase-4 work — [issue #338](https://github.com/qnbs/WorldScript-Studio/issues/338). +- **`extract-zip@2.0.1` OSV ignore (accepted risk, 2026-08-12):** **GHSA-jmr9-qjv8-65gv** / CVE-2026-56876 (CVSS 8.6, unvalidated symlink path traversal when extracting an attacker-controlled zip) flags a transitive devDependency of `@puppeteer/browsers` (Playwright's browser-binary downloader). No fixed version exists (`extract-zip@2.0.1` is the final release), so `pnpm.overrides` cannot remediate it; documented as an `IgnoredVulns` entry in `src-tauri/osv-scanner.toml`, matching the file's existing pattern for unfixable transitive findings. Not exploitable here: only ever extracts Playwright/Chromium's own CDN-hosted zip releases, never a user- or attacker-supplied archive, and ships in no production bundle. + **Quality gate (2026-06-17 — language expansion +6 locales):** lint ✅ · typecheck ✅ · i18n:check ✅ (**2716 keys × 17 locales** — fi/sv/hu/is/eu + fa RTL) · placeholder guard ✅ (17 bundles) · targeted unit tests ✅ (LanguageSelector 9 · I18nContext 59 · i18nPlaceholders 33). `LanguageSelector` exonym labels localized via `portal.language.names.*` (native endonym stays hardcoded by design). **Bulk translation completed** for all 10 Beta locales (glossary v2.0, ~44 anchor terms/locale; placeholder-masked, checkpointed): post-run coverage fi 91 % · sv 90 % · hu 91 % · is 92 % · eu 92 % · fa 93 % · ja 99 % · zh 100 % · pt 98 % · el 97 % (Beta MT; human native review tracked). Two bulk-script bugs fixed: (1) `glossaryTranslate` partial-match left ~1,300 strings partially English → now exact-match only; (2) `--all` mangled `help.json` rich HTML → `help.json` excluded from `--all` (`ALL_SKIP`) and kept English fallback for the 6 new langs (tag-dense markup isn't MT-safe; human-review task). New `docs/TRANSLATION-GUIDE.md` + `I18N-GLOSSARY.md` v2.0. **Quality gate (2026-06-16 — v1.23.0):** lint ✅ · typecheck ✅ · i18n:check ✅ (**2709 keys × 11 locales**) · placeholder guard ✅ · unit tests ✅ (5807+ / 485 files) · coverage thresholds L74/B60/F67/S72 ✅. Toolchain: Node 22/24, **pnpm 11**, Vite 8, TypeScript 7 (tsgo). diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d3e99d2..63ec7865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Migration verification no longer re-scans already-verified stores on resume**, and a batch that reports progress without advancing its durable cursor is now rejected instead of being able to replay the same records indefinitely. (#337) +- **AI Writing Studio manuscript text was unreadable, with selection/caret position drifting from + the visible text.** `ContextPanel.tsx`'s real (input-handling) textarea sat invisibly over a + separate visible text-mirror layer; the shared `Textarea` primitive's unconditional + `backdrop-blur-md` blurred the mirror text underneath, the two layers resolved different concrete + font stacks for the same font setting (different glyph metrics → position drift), and neither + layer synced its scroll position with the other. `components/manuscript/ManuscriptEditor.tsx` + (the primary writing surface) used the same fragile pattern and carried the same blur/scroll-sync + defect. Fixed via a new `Textarea` `variant="overlay"` (no glass background/blur/reserved + padding/mic button) and a single shared `services/editorTypography.ts` font-stack resolver used + by both the real textarea and its mirror in both components, plus one-directional scroll sync + from each real textarea to its mirror. (#341) +- **Overlay-variant textareas dropped RTL font resolution, custom-font selection, and dictation.** + `resolveEditorFontFamily` now also takes the active text direction and `settings.customFont?.name` + (previously every `editorFont: 'custom'` silently rendered as JetBrains Mono, and RTL sessions used + LTR font stacks); the new `DictationButton` component restores the microphone entry point that + `variant="overlay"` had unconditionally removed from both Writer Studio and the manuscript editor, + rendered as a sibling above the mirror instead of inside `Textarea` itself. Also fixes two related + scroll-sync gaps: the mirror now resets to the top on a section switch instead of showing a stale + offset, and re-syncs after debounced/deferred content growth instead of staying clamped to a + since-invalid scroll range. (#341, #344) ### Docs diff --git a/components/manuscript/ManuscriptEditor.tsx b/components/manuscript/ManuscriptEditor.tsx index ee1984d7..031c2a27 100644 --- a/components/manuscript/ManuscriptEditor.tsx +++ b/components/manuscript/ManuscriptEditor.tsx @@ -1,13 +1,15 @@ import type { FC, ReactNode } from 'react'; -import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import { useAppSelector } from '../../app/hooks'; import { useManuscriptViewContext } from '../../contexts/ManuscriptViewContext'; import { useLanguageToolCheck } from '../../hooks/useLanguageToolCheck'; import { useTranslation } from '../../hooks/useTranslation'; import { useVoiceDictation } from '../../hooks/useVoiceDictation'; +import { resolveEditorFontFamily } from '../../services/editorTypography'; import type { LanguageToolMatch } from '../../services/languageToolService'; import { InlineAnnotationLayer } from '../copilot/InlineAnnotationLayer'; import { DebouncedInput } from '../ui/DebouncedInput'; +import { DictationButton } from '../ui/DictationButton'; import { Icon } from '../ui/Icon'; import { Textarea } from '../ui/Textarea'; @@ -53,14 +55,6 @@ const TYPOS_DE: Record = { haken: 'Haken', }; -// QNBS-v3: concrete editor font stacks — single source mirrored from components/ui/Textarea fontMap. -const EDITOR_FONT_STACKS: Record = { - serif: 'Merriweather, serif', - 'sans-serif': 'Inter, sans-serif', - monospace: 'JetBrains Mono, monospace', - custom: 'JetBrains Mono, monospace', -}; - export const ManuscriptEditor: FC<{ isFocusMode: boolean }> = React.memo(({ isFocusMode }) => { const { t, @@ -104,18 +98,14 @@ export const ManuscriptEditor: FC<{ isFocusMode: boolean }> = React.memo(({ isFo const deferredContent = useDeferredValue(activeSection?.content ?? ''); const isHighlightPending = deferredContent !== (activeSection?.content ?? ''); - // QNBS-v3: map the editorFont enum to a concrete CSS stack (mirrors components/ui/Textarea - // fontMap) — the raw enum value (e.g. 'custom') is not a valid font-family, and the highlight - // overlay must render the exact same stack as the textarea so glyphs stay aligned. - const ltrEditorStack = EDITOR_FONT_STACKS[settings.editorFont] ?? 'Inter, sans-serif'; - // QNBS-v3: RTL prose needs Noto glyphs — generic serif/sans/mono lack reliable Arabic/Hebrew - // coverage; prefer Naskh (book face) for serif/custom, Noto Sans otherwise, Latin stack as tail. - const editorFontFamily = - dir === 'rtl' - ? settings.editorFont === 'sans-serif' || settings.editorFont === 'monospace' - ? `"Noto Sans Arabic", "Noto Sans Hebrew", ${ltrEditorStack}` - : `"Noto Naskh Arabic", "Noto Sans Hebrew", ${ltrEditorStack}` - : ltrEditorStack; + // QNBS-v3 (#341): shared with components/ui/Textarea.tsx and ContextPanel.tsx — the raw enum + // value (e.g. 'custom') is not a valid font-family, and the highlight overlay must render the + // exact same stack as the textarea so glyphs stay aligned. + const editorFontFamily = resolveEditorFontFamily( + settings.editorFont, + dir, + settings.customFont?.name, + ); const editorStyles: React.CSSProperties = { fontFamily: editorFontFamily, fontSize: `${settings.fontSize}px`, @@ -310,6 +300,31 @@ export const ManuscriptEditor: FC<{ isFocusMode: boolean }> = React.memo(({ isFo ltAvailable, ]); + // QNBS-v3 (#341): the real textarea and the visible highlight-overlay div below can scroll + // independently (overlay is pointer-events-none, so this is one-directional: textarea → overlay + // only). Declared before the early return below — hooks must run unconditionally. + const highlightRef = useRef(null); + const prevSectionIdRef = useRef(activeSection?.id); + + // QNBS-v3 (#344): resets both layers to the top on a section switch (stale offset from the previous section); otherwise re-applies the textarea's current scrollTop once deferredContent catches up, since a scroll during the useDeferredValue lag window can clamp the overlay's scrollTop against its then-shorter content. + useEffect(() => { + const sectionChanged = prevSectionIdRef.current !== activeSection?.id; + prevSectionIdRef.current = activeSection?.id; + if (sectionChanged) { + if (editorRef.current) { + editorRef.current.scrollTop = 0; + editorRef.current.scrollLeft = 0; + } + if (highlightRef.current) { + highlightRef.current.scrollTop = 0; + highlightRef.current.scrollLeft = 0; + } + } else if (deferredContent && editorRef.current && highlightRef.current) { + highlightRef.current.scrollTop = editorRef.current.scrollTop; + highlightRef.current.scrollLeft = editorRef.current.scrollLeft; + } + }, [activeSection?.id, deferredContent, editorRef]); + if (!activeSection) { return (
@@ -335,6 +350,13 @@ export const ManuscriptEditor: FC<{ isFocusMode: boolean }> = React.memo(({ isFo handleContentChange(activeSection.id, e.currentTarget.value); }; + const handleTextareaScroll = (e: React.UIEvent) => { + if (highlightRef.current) { + highlightRef.current.scrollTop = e.currentTarget.scrollTop; + highlightRef.current.scrollLeft = e.currentTarget.scrollLeft; + } + }; + return (
= React.memo(({ isFo {/* QNBS-v3: Phase 2 — show insight badge when there are findings for this chapter */}