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
1 change: 1 addition & 0 deletions AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ the same root cause in one sprint is itself the finding: **the failure mode is n
## v1.24.0 Dependency Hygiene + Onboarding + Docs Truth-up (2026-06-21, PR F)

- **`joi` override (accepted risk, KEPT):** `pnpm-workspace.yaml` `overrides.joi: ^18.2.1` pins the patched `joi` pulled transitively via `wait-on` (Storybook/test-runner wait helper), mitigating **GHSA-q7cg-457f-vx79** (unpublished jsdom exposure in `@hapi/statehood`). Still required — `wait-on@9.x` still depends on `joi`. `pnpm audit --audit-level=high` clean with the override in place; rationale documented inline in `pnpm-workspace.yaml` and here.
- **`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) was published/GitHub-reviewed 2026-08-12, freshly flagging a transitive devDependency of `@puppeteer/browsers` (Playwright's browser-binary downloader). No fixed version exists (`extract-zip@2.0.1` is the final release — `pnpm.overrides` cannot remediate an unpatched advisory), so `pnpm.overrides` doesn't apply here; documented as an `IgnoredVulns` entry in `src-tauri/osv-scanner.toml` instead, matching the file's existing pattern for unfixable transitive findings. Not exploitable in this project: only ever extracts Playwright/Chromium's own CDN-hosted zip releases, never a user- or attacker-supplied archive, and ships in no production bundle.
- **SBOM — deferred (decision):** evaluated a `@cyclonedx/cyclonedx-npm` generate-on-tag step; **not adopted in 1.24.0** to keep the release scope tight. Socket Security (PR + project report) already runs every CI run and provides dependency-risk + an SBOM dashboard, so the marginal value is low. Revisit when a formal SBOM artifact is required by a downstream consumer.
- **README metric drift fixed:** `scripts/sync-readme-metrics.mjs` had its locale count **hard-coded to `11`**, so its regexes stopped matching after the 11→17 expansion and silently froze the key count at a stale value. Locale count is now **dynamic** (counts `locales/` dirs) and the regexes match any digit count; re-run → README reads **2786 keys × 17 locales** with the drift guard green.
- **Docs truth-up:** corrected the stale `public/sw.js` "must hand-sync `APP_VERSION`" note in `CLAUDE.md` (it is auto-synced by `scripts/sync-sw-version.mjs` + `sync-tauri-version.mjs` via `predev`/`prebuild`); refreshed the stale 5-locale / `2 594 keys × 11 locales` strings in `CONTRIBUTING.md` + `.github/copilot-instructions.md` to 17 locales.
Expand Down
49 changes: 35 additions & 14 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { FC } from 'react';
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useStore } from 'react-redux';
import { useAppDispatch, useAppSelector } from './app/hooks';
import { flushPersistedState } from './app/persistedStateFlush';
import type { RootState } from './app/store';
import { useTransientUiStore } from './app/transientUiStore';
import { AnalyticsBootstrap } from './components/AnalyticsBootstrap';
Expand Down Expand Up @@ -66,6 +67,7 @@ import { getEffectiveTheme } from './services/commands/effectiveTheme';
import { approximateManuscriptWordCount } from './services/commands/wordCountApprox';
import { installDesktopMenu } from './services/desktop/desktopMenu';
import { installCloseToTray, installDesktopTray } from './services/desktop/desktopTray';
import { logger } from './services/logger';
import { pluginRegistry } from './services/pluginRegistry';
import { repairProjectI18nFields } from './services/projectI18nRepair';
import { hasCompletedSpotlightTour, startSpotlightTour } from './services/spotlightTour';
Expand Down Expand Up @@ -289,6 +291,14 @@ const App: FC<AppProps> = ({ isNewUser }) => {
);
}, [settings.accessibility.reducedMotion]);

// QNBS-v3 (#332/D4): manual relief valve for backdrop-blur GPU cost, mirroring reducedMotion above — covers OS/DE setups (some Linux/Wayland) that don't expose prefers-reduced-transparency.
useEffect(() => {
document.body.classList.toggle(
'worldscript-reduced-transparency',
settings.accessibility.reducedTransparency,
);
}, [settings.accessibility.reducedTransparency]);

// QNBS-v3: Barrierefreiheits-Toggles → dokumentweite Klassen (Tokens in index.css).
useEffect(() => {
document.documentElement.classList.toggle(
Expand Down Expand Up @@ -579,25 +589,33 @@ const App: FC<AppProps> = ({ isNewUser }) => {
//
// executeCommand is held in a ref so the menu only rebuilds when the language (t) changes — not on
// every executeCommand identity change (it depends on characters/worlds/settings/… and recreates often).
// QNBS-v3 (#332/D3): shared by the tray/menu Quit items — PredefinedMenuItem's native Quit bypasses onCloseRequested's flush entirely, so these call this instead. Never resolves if the flush failed, so the app stays running for the user to retry.
const quitApp = useCallback(async () => {
try {
await flushPersistedState(store.getState() as RootState);
} catch (error) {
logger.warn('Pre-quit flush failed — aborting quit so autosave can retry', {
error: error instanceof Error ? error.message : String(error),
});
return;
}
const { exit } = await import('@tauri-apps/plugin-process');
await exit(0);
}, [store]);

// QNBS-v3: executeCommandRef synced in its own effect (never assigned during render) so the menu
// effect below can depend on [t, quitApp] only and skip rebuilding on every executeCommand identity change.
const executeCommandRef = useRef(executeCommand);
executeCommandRef.current = executeCommand;
useEffect(() => {
void installDesktopMenu(
(key) => t(key),
(id) => executeCommandRef.current(id),
);
}, [t]);

// QNBS-v3 (T1): install the localized JS application menu (overrides the minimal Rust fallback).
// Rebuilds when the language changes so labels follow the active locale. No-op on the web.
executeCommandRef.current = executeCommand;
}, [executeCommand]);
useEffect(() => {
void installDesktopMenu(
(key) => t(key),
(id) => {
executeCommand(id);
},
(id) => executeCommandRef.current(id),
quitApp,
);
}, [t, executeCommand]);
}, [t, quitApp]);
Comment thread
qnbs marked this conversation as resolved.

// QNBS-v3 (T2): system tray (created once; guard makes re-calls a no-op). No-op on the web.
// QNBS-v3 (#190): executeCommand in a ref so the tray rebuilds on language (t) change only, not on
Expand All @@ -608,8 +626,9 @@ const App: FC<AppProps> = ({ isNewUser }) => {
void installDesktopTray(
(key) => t(key),
(id) => trayCommandRef.current(id),
quitApp,
);
}, [t]);
}, [t, quitApp]);

// QNBS-v3 (T2): close-to-tray — hide instead of quit when the setting is on (read live from store).
useEffect(() => {
Expand All @@ -622,6 +641,8 @@ const App: FC<AppProps> = ({ isNewUser }) => {
// Defensive read — imported/malformed persisted settings can leave `desktop` null/undefined,
// which would crash the close handler; default to false (don't trap the window).
() => (store.getState() as RootState).settings.desktop?.minimizeToTray ?? false,
// QNBS-v3 (#332/D3): flush pending project/settings state before a real quit proceeds.
() => flushPersistedState(store.getState() as RootState),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
).then((fn) => {
if (cancelled) {
fn?.();
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the race where an ordinary protected write could commit after a migration had already claimed
ownership of the same store, which could otherwise land ciphertext under a superseded
key/generation. (#339)
- **Manual "Reduce transparency effects" accessibility toggle** (Settings › Accessibility,
`accessibility.reducedTransparency`, default off) — strips `backdrop-blur-*` GPU compositing
everywhere, for desktop/Linux users whose window manager doesn't expose the OS-level
`prefers-reduced-transparency` preference that the existing automatic mitigation relies on.
(#332)

### Changed

Expand Down Expand Up @@ -60,6 +65,22 @@ 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)
- **Desktop (Tauri) build never read persisted state back at cold boot.** The app's boot-time
hydration called the raw IndexedDB-only `dbService.loadState()` unconditionally, with zero Tauri
branching, while every save path already routed through the Tauri-aware `storageService` — every
desktop launch loaded as a brand-new user regardless of what was actually saved to disk (a strict
superset of the reported "appearance preference doesn't persist" symptom). Boot-time hydration
(`services/appBootstrap.ts`) now mirrors the save path, branching on `isTauriRuntime()`. The
first-ever-launch settings default (`appearancePreset`) is also reconciled between
`idbProjectStore.ts`'s normalizer and `settingsSlice.ts`'s deliberate `'sepia'` initial state, and
quitting the desktop window now awaits any pending 1s-debounced project/settings autosave
(`services/desktop/desktopTray.ts`) before the process actually exits, instead of allowing a quit
to land mid-debounce and silently drop the last edit. (#332)
- **`SettingsView` re-rendered its entire tree on every unrelated Redux state change** —
`useSettingsView`'s return value was a fresh object every render (no memoization), so any
background write anywhere in the app (autosave, AI copilot, progress tracker) forced every
Settings component to re-render even while a completely different view was in the foreground. The
context value is now memoized against its actual dependencies. (#332)
- **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
Expand Down
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
<img src="https://img.shields.io/badge/Version-v1.26.0-6366F1" alt="v1.26.0">
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2914_keys-0EA5E9" alt="i18n 19 locales — 2914 keys">
<img src="https://img.shields.io/badge/Tests-6477%2B_%2F_545_files-22C55E" alt="6477+ tests / 545 files">
<img src="https://img.shields.io/badge/i18n-19_locales-2915_keys-0EA5E9" alt="i18n 19 locales — 2915 keys">
<img src="https://img.shields.io/badge/Tests-6477%2B_%2F_548_files-22C55E" alt="6477+ tests / 548 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -397,7 +397,7 @@ Infrastructure-level features that keep the app fast and extensible as projects

### 🌐 Full Multi-Language Support

Shipped UI locales with **2914 i18n keys** across all 19 languages — zero hardcoded user-facing strings:
Shipped UI locales with **2915 i18n keys** across all 19 languages — zero hardcoded user-facing strings:

- 🇩🇪 **German** (Deutsch)
- 🇬🇧 **English**
Expand Down Expand Up @@ -506,8 +506,8 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **PDF Export** | jsPDF | Client-side, configurable PDF document generation |
| **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`) | 2914 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 (6477+ tests / 545 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2915 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 (6477+ tests / 548 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` |
Expand Down Expand Up @@ -545,7 +545,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
│ ├── unit/ # Vitest unit tests (6477+ tests, 545 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (6477+ tests, 548 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
Expand Down Expand Up @@ -706,9 +706,9 @@ 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-07-30, CI-reported):**
- **6477+ unit tests** across **545 test files** — all passing
- **6477+ unit tests** across **548 test files** — all passing
- Coverage thresholds: lines ≥ 74 · branches ≥ 60 · functions ≥ 67 · statements ≥ 72 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2914 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)
- i18n: **2915 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)

**CI-cloud-first workflow (recommended):** On constrained hardware run **`pnpm run lint && pnpm run i18n:check && pnpm run typecheck`** locally, then push and let CI handle coverage, E2E, Lighthouse, and Stryker. Authoritative numbers come from CI artifacts (Codecov, JUnit). After CI goes green, update the README badges and `AUDIT.md` quality-gate line from the reported metrics. See **[`docs/CI.md`](docs/CI.md) § Cloud CI-first vs local development** for the full post-merge doc-update checklist.

Expand Down
28 changes: 28 additions & 0 deletions app/persistedStateFlush.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { ProjectData } from '../features/project/projectSlice';
import { saveEnvelopeFromProjectData, storageService } from '../services/storageService';
import type { RootState } from './store';

/**
* QNBS-v3 (#332/D3): shared, awaitable flush of pending project+settings state. Used by both the
* best-effort `visibilitychange` handler (index.tsx) and the desktop close-to-tray quit flush
* (App.tsx via desktopTray.ts), so an edit made just before a tab hide or window close isn't
* silently dropped by the 1s debounced autosave in `app/listenerMiddleware.ts`. Settings save
* independently of project data, and any save failure rejects (fail closed) instead of being
* swallowed by Promise.allSettled — callers decide their own failure policy.
*/
export async function flushPersistedState(state: RootState): Promise<void> {
const presentData = state.project.present?.data;
const saves: Promise<unknown>[] = [storageService.saveSettings(state.settings)];
if (presentData) {
const enriched: ProjectData = {
...presentData,
persistedVersionControl: {
branches: state.versionControl.branches,
snapshots: state.versionControl.snapshots,
currentBranchId: state.versionControl.currentBranchId,
},
};
saves.push(storageService.saveProject(saveEnvelopeFromProjectData(enriched)));
}
await Promise.all(saves);
}
6 changes: 6 additions & 0 deletions components/settings/AccessibilitySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ export const AccessibilitySection: FC = () => {
checked={accessibility.reducedMotion}
onChange={(v) => patchA11y({ reducedMotion: v })}
/>
{/* QNBS-v3 (#332/D4): manual relief valve for the backdrop-blur GPU cost — see index.css's body.is-desktop.worldscript-reduced-transparency rule; a no-op on web/PWA by design. */}
<ToggleSwitch
label={t('settings.accessibility.reducedTransparency')}
checked={accessibility.reducedTransparency}
onChange={(v) => patchA11y({ reducedTransparency: v })}
/>
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
<ToggleSwitch
label={t('settings.accessibility.largeText')}
checked={accessibility.largeText}
Expand Down
Loading
Loading