diff --git a/README.md b/README.md index ecd7090c..7b84b203 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2937 keys - 7356+ tests / 595 files + 7357+ tests / 595 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2937 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7356+ tests / 595 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7357+ tests / 595 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7356+ tests, 595 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7357+ tests, 595 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-08-30, source-synchronized; CI remains authoritative for pass/fail):** -- **7356+ unit tests** across **595 test files** — CI is authoritative for pass/fail +- **7357+ unit tests** across **595 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2937 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/components/manuscript/ManuscriptEditor.tsx b/components/manuscript/ManuscriptEditor.tsx index a9deb6a2..7183fb3c 100644 --- a/components/manuscript/ManuscriptEditor.tsx +++ b/components/manuscript/ManuscriptEditor.tsx @@ -97,6 +97,7 @@ export const ManuscriptEditor: FC<{ isFocusMode: boolean }> = React.memo(({ isFo // QNBS-v3: Defer highlight computation so keystroke → textarea updates stay synchronous even for long scenes. const deferredContent = useDeferredValue(activeSection?.content ?? ''); const isHighlightPending = deferredContent !== (activeSection?.content ?? ''); + // QNBS-v3: Pending mirror opacity preserves WCAG contrast while retaining the deferred-state cue. // 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 @@ -398,7 +399,7 @@ export const ManuscriptEditor: FC<{ isFocusMode: boolean }> = React.memo(({ isFo 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'}`} + 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-75' : 'opacity-100'}`} style={editorStyles} aria-hidden="true" > @@ -526,4 +527,4 @@ export const ManuscriptEditor: FC<{ isFocusMode: boolean }> = React.memo(({ isFo ); }); -ManuscriptEditor.displayName = 'ManuscriptEditor'; \ No newline at end of file +ManuscriptEditor.displayName = 'ManuscriptEditor'; diff --git a/tests/unit/manuscript/ManuscriptEditor.test.tsx b/tests/unit/manuscript/ManuscriptEditor.test.tsx index 85fe08f6..1931ec80 100644 --- a/tests/unit/manuscript/ManuscriptEditor.test.tsx +++ b/tests/unit/manuscript/ManuscriptEditor.test.tsx @@ -8,6 +8,24 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +// QNBS-v3: Retain the previous deferred value so pending-state coverage models React's stale render contract. +const reactDeferredMock = vi.hoisted(() => ({ + forceStaleValue: false, + previousValue: undefined as unknown, +})); + +vi.mock('react', async () => { + const actual = await vi.importActual('react'); + const useDeferredValue = (value: T): T => { + if (reactDeferredMock.forceStaleValue && reactDeferredMock.previousValue !== undefined) { + return reactDeferredMock.previousValue as T; + } + reactDeferredMock.previousValue = value; + return value; + }; + return { ...actual, useDeferredValue }; +}); + // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- @@ -180,6 +198,9 @@ describe('ManuscriptEditor', () => { ltMock.available = false; ltMock.matches = []; ltMock.applySuggestion.mockReset(); + // QNBS-v3: Reset deferred mock state so each test observes an isolated render timeline. + reactDeferredMock.forceStaleValue = false; + reactDeferredMock.previousValue = undefined; }); it('shows empty state when no section is selected', () => { @@ -321,5 +342,20 @@ describe('ManuscriptEditor', () => { } as unknown as React.UIEvent); expect(mirror.scrollTop).toBe(360); }); + + // QNBS-v3: A stale mirror must retain content and the WCAG-safe pending opacity during edits. + it('applies opacity-75 while retaining stale mirror content during deferred rendering', () => { + const { rerender } = render(); + mockActiveSection = { + id: 'sec-1', + title: 'Chapter One', + content: 'Updated content while the edit settles', + }; + reactDeferredMock.forceStaleValue = true; + rerender(); + const mirror = screen.getByTestId('manuscript-editor-mirror'); + expect(mirror).toHaveClass('opacity-75'); + expect(mirror).toHaveTextContent('Hello world teh quick brown fox'); + }); }); -}); \ No newline at end of file +});