Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
44 changes: 23 additions & 21 deletions components/manuscript/ManuscriptEditor.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -53,14 +54,6 @@ const TYPOS_DE: Record<string, string> = {
haken: 'Haken',
};

// QNBS-v3: concrete editor font stacks — single source mirrored from components/ui/Textarea fontMap.
const EDITOR_FONT_STACKS: Record<string, string> = {
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,
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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<HTMLDivElement>(null);

if (!activeSection) {
return (
<div className="flex h-full w-full items-center justify-center text-center text-[var(--sc-text-muted)] p-4">
Expand All @@ -335,6 +325,13 @@ export const ManuscriptEditor: FC<{ isFocusMode: boolean }> = React.memo(({ isFo
handleContentChange(activeSection.id, e.currentTarget.value);
};

const handleTextareaScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
if (highlightRef.current) {
highlightRef.current.scrollTop = e.currentTarget.scrollTop;
highlightRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
};

return (
<div className="relative h-full flex flex-col">
<div
Expand All @@ -351,13 +348,16 @@ export const ManuscriptEditor: FC<{ isFocusMode: boolean }> = React.memo(({ isFo
{/* QNBS-v3: Phase 2 — show insight badge when there are findings for this chapter */}
<InlineAnnotationLayer sectionTitle={activeSection.title} />
<Textarea
variant="overlay"
data-testid="manuscript-editor-textarea"
ref={editorRef}
value={activeSection.content}
onChange={(e) => handleContentChange(activeSection.id, e.target.value)}
onSelect={handleSelectionEvents}
onKeyUp={handleSelectionEvents}
onClick={handleSelectionEvents}
onKeyDown={handleKeyDown}
onScroll={handleTextareaScroll}
className={`h-full w-full leading-relaxed resize-none p-4 sm:p-6 md:p-12 pt-2 bg-transparent border-0 focus:ring-0 flex-grow caret-[var(--sc-text-primary)] text-transparent max-w-3xl mx-auto selection:bg-[var(--sc-accent)]/30 transition-all duration-500 ${isFocusMode ? 'max-w-4xl pt-12' : ''}`}
placeholder={
activeSection.prompt ||
Expand All @@ -372,6 +372,8 @@ export const ManuscriptEditor: FC<{ isFocusMode: boolean }> = React.memo(({ isFo
spellCheck={false}
/>
<div
ref={highlightRef}
data-testid="manuscript-editor-mirror"
className={`absolute inset-0 p-4 sm:p-6 md:p-12 pt-2 leading-relaxed pointer-events-none overflow-auto max-w-3xl mx-auto transition-all duration-500 ${isFocusMode ? 'max-w-4xl pt-12' : ''} ${isHighlightPending ? 'opacity-70' : 'opacity-100'}`}
style={editorStyles}
aria-hidden="true"
Expand Down
4 changes: 2 additions & 2 deletions components/ui/DebouncedTextarea.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { Textarea } from './Textarea';
import { Textarea, type TextareaProps } from './Textarea';

interface DebouncedTextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
interface DebouncedTextareaProps extends TextareaProps {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
value: string;
onDebouncedChange: (value: string) => void;
debounceTimeout?: number;
Expand Down
102 changes: 62 additions & 40 deletions components/ui/Textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,58 @@ import React, { useEffect, useRef } from 'react';
import { useAppSelector } from '../../app/hooks';
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { useTranslation } from '../../hooks/useTranslation';
import { resolveEditorFontFamily } from '../../services/editorTypography';
import { Icon } from './Icon';

export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
/**
* 'overlay': a real, focusable, input-only textarea meant to sit invisibly over a separate,
* visible text-mirror layer (e.g. ContextPanel.tsx, ManuscriptEditor.tsx) — renders with no
* glass background/blur/shadow/hover background/reserved bottom padding, and skips the
* dictation mic button (its reserved padding would otherwise collide with the mirror text).
* QNBS-v3 (#341): the unconditional `backdrop-blur-md` on the 'default' variant was sitting
* directly over such mirror layers, making the visible text underneath unreadable.
*/
variant?: 'default' | 'overlay';
}

const DEFAULT_CLASSES = `
flex min-h-[120px] w-full rounded-sc-lg
border border-[var(--sc-border-subtle)]
bg-[var(--glass-bg)] backdrop-blur-md
px-4 py-3 pb-12 text-sm
text-[var(--sc-text-primary)] placeholder:text-[var(--sc-text-muted)]
shadow-sm transition-all duration-sc-fast
focus-visible:outline-none focus-visible:border-[var(--border-interactive)] focus-visible:ring-4 focus-visible:ring-[var(--sc-ring-focus)] focus-visible:bg-[var(--sc-surface-raised)]/50
hover:border-[var(--sc-border-strong)] hover:bg-[var(--glass-bg-hover)]
disabled:opacity-50 disabled:cursor-not-allowed
scrollbar-thin scrollbar-thumb-rounded-md
`;

// QNBS-v3 (#341): no glass/blur/shadow/hover-bg/reserved padding — the caller's own className
// fully controls appearance (typically text-transparent, sitting over a visible mirror layer).
// Keeps border/rounded/focus-visible ring so keyboard focus stays visible per WCAG.
const OVERLAY_CLASSES = `
flex min-h-[120px] w-full rounded-sc-lg
border border-[var(--sc-border-subtle)]
text-sm placeholder:text-[var(--sc-text-muted)]
transition-all duration-sc-fast
focus-visible:outline-none focus-visible:border-[var(--border-interactive)] focus-visible:ring-4 focus-visible:ring-[var(--sc-ring-focus)]
disabled:opacity-50 disabled:cursor-not-allowed
scrollbar-thin scrollbar-thumb-rounded-md
`;
Comment thread
qnbs marked this conversation as resolved.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

export const Textarea = React.memo(
React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, style, ...props }, ref) => {
React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, style, variant = 'default', ...props }, ref) => {
const settings = useAppSelector((state) => state.settings);
const innerRef = useRef<HTMLTextAreaElement>(null);
const inputRef = (ref as React.RefObject<HTMLTextAreaElement>) || innerRef;
const { isListening, transcript, toggleListening, setTranscript } = useSpeechRecognition();
const { t } = useTranslation();

const fontMap = {
serif: 'Merriweather, serif',
'sans-serif': 'Inter, sans-serif',
monospace: 'JetBrains Mono, monospace',
custom: 'JetBrains Mono, monospace',
};

const editorStyles: React.CSSProperties = {
fontFamily: fontMap[settings.editorFont],
fontFamily: resolveEditorFontFamily(settings.editorFont),
fontSize: `${settings.fontSize}px`,
lineHeight: settings.lineSpacing,
};
Comment thread
qnbs marked this conversation as resolved.
Expand Down Expand Up @@ -49,40 +81,30 @@ export const Textarea = React.memo(
return (
<div className="relative w-full h-full group">
<textarea
className={`
flex min-h-[120px] w-full rounded-sc-lg
border border-[var(--sc-border-subtle)]
bg-[var(--glass-bg)] backdrop-blur-md
px-4 py-3 pb-12 text-sm
text-[var(--sc-text-primary)] placeholder:text-[var(--sc-text-muted)]
shadow-sm transition-all duration-sc-fast
focus-visible:outline-none focus-visible:border-[var(--border-interactive)] focus-visible:ring-4 focus-visible:ring-[var(--sc-ring-focus)] focus-visible:bg-[var(--sc-surface-raised)]/50
hover:border-[var(--sc-border-strong)] hover:bg-[var(--glass-bg-hover)]
disabled:opacity-50 disabled:cursor-not-allowed
scrollbar-thin scrollbar-thumb-rounded-md
${className}
`}
className={`${variant === 'overlay' ? OVERLAY_CLASSES : DEFAULT_CLASSES} ${className}`}
ref={inputRef}
style={{ ...editorStyles, ...style }}
{...props}
/>
<button
type="button"
onClick={toggleListening}
className={`absolute right-3 bottom-3 p-2 rounded-full transition-all duration-sc-normal focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-[var(--sc-ring-focus)] z-10 ${
isListening
? 'text-[var(--sc-danger-fg)] bg-[var(--sc-danger-bg)] animate-pulse shadow-[0_0_0_4px_var(--sc-danger-fg)] scale-110'
: 'text-[var(--sc-text-muted)] bg-[var(--sc-surface-raised)]/80 hover:text-[var(--sc-text-primary)] hover:bg-[var(--glass-bg-hover)] shadow-sm border border-[var(--sc-border-subtle)]'
}`}
title={t('common.dictation.title')}
aria-label={isListening ? t('common.dictation.stop') : t('common.dictation.start')}
>
{isListening ? (
<Icon name="microphone-solid" size="md" aria-hidden />
) : (
<Icon name="microphone" size="md" aria-hidden />
)}
</button>
{variant === 'default' && (
<button
type="button"
onClick={toggleListening}
className={`absolute right-3 bottom-3 p-2 rounded-full transition-all duration-sc-normal focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-[var(--sc-ring-focus)] z-10 ${
isListening
? 'text-[var(--sc-danger-fg)] bg-[var(--sc-danger-bg)] animate-pulse shadow-[0_0_0_4px_var(--sc-danger-fg)] scale-110'
: 'text-[var(--sc-text-muted)] bg-[var(--sc-surface-raised)]/80 hover:text-[var(--sc-text-primary)] hover:bg-[var(--glass-bg-hover)] shadow-sm border border-[var(--sc-border-subtle)]'
}`}
title={t('common.dictation.title')}
aria-label={isListening ? t('common.dictation.stop') : t('common.dictation.start')}
>
{isListening ? (
<Icon name="microphone-solid" size="md" aria-hidden />
) : (
<Icon name="microphone" size="md" aria-hidden />
)}
</button>
)}
</div>
);
},
Expand Down
31 changes: 21 additions & 10 deletions components/writing/ContextPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// QNBS-v3: Extracted from WriterView.tsx to keep each file ≤350 lines per architecture rules
import type { FC } from 'react';
import React from 'react';
import React, { useRef } from 'react';
import { useAppSelector } from '../../app/hooks';
import { useWriterViewContext } from '../../contexts/WriterViewContext';
import { writerActions } from '../../features/writer/writerSlice';
import { useTranslation } from '../../hooks/useTranslation';
import { resolveEditorFontFamily } from '../../services/editorTypography';
import { Card, CardContent, CardHeader } from '../ui/Card';
import { DebouncedTextarea } from '../ui/DebouncedTextarea';
import { Select } from '../ui/Select';
Expand All @@ -17,17 +18,22 @@ const ContextPanel: FC = React.memo(() => {
const selectedSection = project.manuscript.find((s) => s.id === selectedSectionId);
const selectedSectionIndex = project.manuscript.findIndex((s) => s.id === selectedSectionId);
const settings = useAppSelector((state) => state.settings);
const fontMap = {
serif: 'serif',
'sans-serif': 'sans-serif',
monospace: 'monospace',
custom: 'monospace',
};
// QNBS-v3 (#341): must resolve to the exact same stack as the real textarea (DebouncedTextarea →
// Textarea) — a mismatch here was the direct cause of selection/visible-text position drift.
const editorStyles: React.CSSProperties = {
fontFamily: fontMap[settings.editorFont],
fontFamily: resolveEditorFontFamily(settings.editorFont),
fontSize: `${settings.fontSize}px`,
lineHeight: settings.lineSpacing,
};
Comment thread
qnbs marked this conversation as resolved.
// QNBS-v3 (#341): the real textarea and the visible mirror div below can scroll independently
// (mirror is pointer-events-none, so this is one-directional: real textarea → mirror only).
const mirrorRef = useRef<HTMLDivElement>(null);
const handleTextareaScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
if (mirrorRef.current) {
mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
mirrorRef.current.scrollLeft = e.currentTarget.scrollLeft;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}
};
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
const handleSelectionEvents = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const target = e.currentTarget;
const { selectionStart, selectionEnd, value } = target;
Expand Down Expand Up @@ -71,6 +77,7 @@ const ContextPanel: FC = React.memo(() => {
<div className="relative flex-grow border rounded-md border-[var(--sc-border-subtle)] bg-[var(--sc-surface-base)] overflow-hidden min-h-[300px]">
{/* QNBS-v3: data-testid disambiguates Writer vs Manuscript textareas in Playwright (shared aria labelling). */}
<DebouncedTextarea
variant="overlay"
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
data-testid="writer-studio-editor"
value={selectedSection?.content || ''}
onDebouncedChange={(content) =>
Expand All @@ -79,7 +86,8 @@ const ContextPanel: FC = React.memo(() => {
onSelect={handleSelectionEvents}
onMouseUp={handleSelectionEvents}
onKeyUp={handleSelectionEvents}
className="h-full absolute inset-0 resize-none text-transparent bg-transparent caret-[var(--sc-text-primary)] z-10 p-4"
onScroll={handleTextareaScroll}
className="h-full absolute inset-0 resize-none text-transparent caret-[var(--sc-text-primary)] z-10 p-4"
placeholder={t('writer.studio.context.contentPlaceholder')}
aria-label={
selectedSection
Expand All @@ -90,7 +98,10 @@ const ContextPanel: FC = React.memo(() => {
role="textbox"
/>
<div
className="absolute inset-0 p-4 leading-relaxed pointer-events-none overflow-auto whitespace-pre-wrap"
ref={mirrorRef}
data-testid="writer-studio-mirror"
aria-hidden="true"
className="absolute inset-0 p-4 leading-relaxed pointer-events-none overflow-auto whitespace-pre-wrap text-[var(--sc-text-primary)]"
style={editorStyles}
>
{selectedSection?.content && (
Expand Down
33 changes: 33 additions & 0 deletions services/editorTypography.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Single source of truth for the manuscript/writer editor font stacks.
* QNBS-v3 (#341): components/ui/Textarea.tsx, components/writing/ContextPanel.tsx, and
* components/manuscript/ManuscriptEditor.tsx each maintained their own copy of this mapping —
* ContextPanel.tsx's copy had drifted to generic CSS keywords instead of concrete font stacks,
* producing different glyph metrics between its real (invisible) textarea and its visible text
* mirror, which is what made selection position drift from the visible caret/highlight.
*/

import type { EditorFont } from '../types';

export const EDITOR_FONT_STACKS: Record<EditorFont, string> = {
serif: 'Merriweather, serif',
'sans-serif': 'Inter, sans-serif',
monospace: 'JetBrains Mono, monospace',
custom: 'JetBrains Mono, monospace',
Comment thread
qnbs marked this conversation as resolved.
};

/**
* Resolves the concrete font-family stack for an editor surface. RTL prose needs Noto glyph
* coverage (generic serif/sans/mono stacks lack reliable Arabic/Hebrew rendering) — this mirrors
* the RTL branch ManuscriptEditor.tsx already had before this module existed.
*/
export function resolveEditorFontFamily(
editorFont: EditorFont,
dir: 'ltr' | 'rtl' = 'ltr',
): string {
const ltrStack = EDITOR_FONT_STACKS[editorFont] ?? EDITOR_FONT_STACKS['sans-serif'];
if (dir !== 'rtl') return ltrStack;
return editorFont === 'sans-serif' || editorFont === 'monospace'
? `"Noto Sans Arabic", "Noto Sans Hebrew", ${ltrStack}`
: `"Noto Naskh Arabic", "Noto Sans Hebrew", ${ltrStack}`;
}
Loading
Loading