From 930f8018a8d8ebccc895fe222c883a8caf6305b5 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:26:50 +0200 Subject: [PATCH 1/4] =?UTF-8?q?fix(writer):=20unreadable=20AI=20Writing=20?= =?UTF-8?q?Studio=20text=20=E2=80=94=20blur=20occlusion,=20font=20mismatch?= =?UTF-8?q?,=20no=20scroll=20sync=20(#341)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContextPanel.tsx (AI Writing Studio) and ManuscriptEditor.tsx (primary writing surface) both render manuscript text via a real, invisible input textarea stacked over a separate visible text-mirror div. Three confirmed defects made the visible layer unreadable and its selection/ caret position drift from what's shown: - The shared Textarea primitive's unconditional `backdrop-blur-md` sat directly over the mirror text, blurring it. Fixed with a new `variant="overlay"` (no glass background/blur/shadow/ reserved padding/mic button) — kept as opt-in since 13 other call sites rely on the default glass look. - ContextPanel's own local font-stack map resolved generic CSS keywords ('serif') while the real textarea resolved concrete stacks ('Merriweather, serif') for the same setting — different glyph metrics between the two layers directly explained the reported position drift. Consolidated into one shared services/editorTypography.ts, used by Textarea.tsx, ContextPanel.tsx, and ManuscriptEditor.tsx (which already had its own correct-but-duplicated copy). - Neither real textarea synced its scroll position with its mirror; on any document taller than the panel the two layers could show completely different content. Added one-directional scroll sync (real textarea → mirror) to both components. Also fixes a previously-unflagged padding-class leak (Textarea's base `px-4 py-3 pb-12`, reserved for its mic button, surviving unmerged alongside each overlay consumer's own padding). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 11 ++ components/manuscript/ManuscriptEditor.tsx | 44 ++++---- components/ui/DebouncedTextarea.tsx | 4 +- components/ui/Textarea.tsx | 102 +++++++++++------- components/writing/ContextPanel.tsx | 31 ++++-- services/editorTypography.ts | 33 ++++++ stories/Textarea.stories.tsx | 7 ++ tests/e2e/manuscript-rendering.spec.ts | 72 +++++++++++++ tests/e2e/writer.spec.ts | 47 ++++++++ tests/unit/Textarea.test.tsx | 40 +++++++ .../unit/manuscript/ManuscriptEditor.test.tsx | 56 ++++++++-- tests/unit/writing/ContextPanel.test.tsx | 57 ++++++++-- 12 files changed, 413 insertions(+), 91 deletions(-) create mode 100644 services/editorTypography.ts create mode 100644 tests/e2e/manuscript-rendering.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d3e99d2..99dbc047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,17 @@ 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) ### Docs diff --git a/components/manuscript/ManuscriptEditor.tsx b/components/manuscript/ManuscriptEditor.tsx index ee1984d7..925555bd 100644 --- a/components/manuscript/ManuscriptEditor.tsx +++ b/components/manuscript/ManuscriptEditor.tsx @@ -1,10 +1,11 @@ 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'; @@ -53,14 +54,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 +97,10 @@ 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); const editorStyles: React.CSSProperties = { fontFamily: editorFontFamily, fontSize: `${settings.fontSize}px`, @@ -310,6 +295,11 @@ 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); + if (!activeSection) { return (
@@ -335,6 +325,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 */}