Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
73 changes: 50 additions & 23 deletions components/manuscript/ManuscriptEditor.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -53,14 +55,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 +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`,
Expand Down Expand Up @@ -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<HTMLDivElement>(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 (
<div className="flex h-full w-full items-center justify-center text-center text-[var(--sc-text-muted)] p-4">
Expand All @@ -335,6 +350,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,33 +373,38 @@ 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 ||
t('manuscript.contentPlaceholder', { title: activeSection.title })
}
style={{
fontSize: `${settings.fontSize}px`,
// QNBS-v3: must match the highlight overlay's editorFontFamily so glyphs align in RTL.
fontFamily: editorFontFamily,
lineHeight: settings.lineSpacing,
}}
spellCheck={false}
/>
<div
ref={highlightRef}
data-testid="manuscript-editor-mirror"
dir={dir}
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"
>
{renderedContent}
</div>
<DictationButton targetRef={editorRef} />
</div>
<div className="absolute bottom-20 left-6 md:bottom-4 text-xs text-[var(--sc-text-muted)] bg-[var(--sc-surface-raised)]/90 border border-[var(--sc-border-subtle)] px-3 py-1 rounded-full pointer-events-none backdrop-blur-sm shadow-sm transition-opacity duration-300">
{activeSectionStats.wordCount} {t('common.words')}
Expand Down
66 changes: 32 additions & 34 deletions components/ui/DebouncedTextarea.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,45 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { Textarea } from './Textarea';
import { forwardRef, useEffect, useState } from 'react';
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;
}

export const DebouncedTextarea: React.FC<DebouncedTextareaProps> = ({
value: propValue,
onDebouncedChange,
debounceTimeout = 750,
...props
}) => {
const [internalValue, setInternalValue] = useState(propValue);
// QNBS-v3 (#344): forwards ref to the real textarea DOM node — a sibling DictationButton needs it to inject a dictated transcript, matching ManuscriptEditor's existing editorRef.
export const DebouncedTextarea = forwardRef<HTMLTextAreaElement, DebouncedTextareaProps>(
({ value: propValue, onDebouncedChange, debounceTimeout = 750, ...props }, ref) => {
const [internalValue, setInternalValue] = useState(propValue);

// Sync with prop changes from external sources (e.g., undo/redo)
useEffect(() => {
setInternalValue(propValue);
}, [propValue]);
// Sync with prop changes from external sources (e.g., undo/redo)
useEffect(() => {
setInternalValue(propValue);
}, [propValue]);

// Debounce and notify parent of changes
useEffect(() => {
const handler = setTimeout(() => {
// If the user has typed something different from the last known prop value,
// notify the parent component.
if (propValue !== internalValue) {
onDebouncedChange(internalValue);
}
}, debounceTimeout);
// Debounce and notify parent of changes
useEffect(() => {
const handler = setTimeout(() => {
// If the user has typed something different from the last known prop value,
// notify the parent component.
if (propValue !== internalValue) {
onDebouncedChange(internalValue);
}
}, debounceTimeout);

// Cleanup: clear the timeout if the user types again.
return () => {
clearTimeout(handler);
};
// This effect runs whenever the user's input changes.
}, [internalValue, onDebouncedChange, debounceTimeout, propValue]);
// Cleanup: clear the timeout if the user types again.
return () => {
clearTimeout(handler);
};
// This effect runs whenever the user's input changes.
}, [internalValue, onDebouncedChange, debounceTimeout, propValue]);

const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInternalValue(e.target.value);
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInternalValue(e.target.value);
};

return <Textarea {...props} value={internalValue} onChange={handleChange} />;
};
return <Textarea ref={ref} {...props} value={internalValue} onChange={handleChange} />;
},
);
DebouncedTextarea.displayName = 'DebouncedTextarea';
56 changes: 56 additions & 0 deletions components/ui/DictationButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { FC, RefObject } from 'react';
import { useEffect } from 'react';
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { useTranslation } from '../../hooks/useTranslation';
import { Icon } from './Icon';

interface DictationButtonProps {
targetRef: RefObject<HTMLTextAreaElement | null>;
}

// QNBS-v3 (#341/#344): extracted from Textarea.tsx's default-variant mic button so overlay consumers (ContextPanel, ManuscriptEditor) can render it as a sibling instead of losing dictation entirely.
export const DictationButton: FC<DictationButtonProps> = ({ targetRef }) => {
const { isListening, transcript, toggleListening, setTranscript } = useSpeechRecognition();
const { t } = useTranslation();

useEffect(() => {
if (transcript && targetRef.current) {
const input = targetRef.current;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value',
)?.set;

if (nativeInputValueSetter) {
const currentValue = input.value;
const separator = currentValue.length > 0 && !currentValue.endsWith('\n') ? ' ' : '';
const newValue = currentValue ? `${currentValue}${separator}${transcript}` : transcript;
nativeInputValueSetter.call(input, newValue);
const event = new Event('input', { bubbles: true });
input.dispatchEvent(event);
}
setTranscript('');
}
}, [transcript, setTranscript, targetRef]);

return (
<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-20 ${
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>
);
};
DictationButton.displayName = 'DictationButton';
Loading
Loading