diff --git a/AUDIT.md b/AUDIT.md index 2116756e..153bc597 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -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. diff --git a/App.tsx b/App.tsx index c9a958a4..c5b8b992 100644 --- a/App.tsx +++ b/App.tsx @@ -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'; @@ -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'; @@ -289,6 +291,14 @@ const App: FC = ({ 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( @@ -579,25 +589,33 @@ const App: FC = ({ 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]); // 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 @@ -608,8 +626,9 @@ const App: FC = ({ 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(() => { @@ -622,6 +641,8 @@ const App: FC = ({ 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), ).then((fn) => { if (cancelled) { fn?.(); diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ec7865..ee8c8307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 7eec03c0..bda42105 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ v1.26.0 IndexedDB v8 PWA v3.0 - i18n 19 locales — 2914 keys - 6477+ tests / 545 files + i18n 19 locales — 2915 keys + 6477+ tests / 548 files Codecov Coverage License MIT CI Status @@ -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** @@ -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` | @@ -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 @@ -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. diff --git a/app/persistedStateFlush.ts b/app/persistedStateFlush.ts new file mode 100644 index 00000000..d486cd32 --- /dev/null +++ b/app/persistedStateFlush.ts @@ -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 { + const presentData = state.project.present?.data; + const saves: Promise[] = [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); +} diff --git a/components/settings/AccessibilitySection.tsx b/components/settings/AccessibilitySection.tsx index ede202cf..cbc5ceec 100644 --- a/components/settings/AccessibilitySection.tsx +++ b/components/settings/AccessibilitySection.tsx @@ -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. */} + patchA11y({ reducedTransparency: v })} + /> { throw new Error('useToast must be used within a ToastProvider'); } - return { - success: ( - title: string, - description?: string, - options?: { actionLabel?: string; commandId?: string }, - ) => context.addToast('success', title, description, options), - error: ( - title: string, - description?: string, - options?: { actionLabel?: string; commandId?: string }, - ) => context.addToast('error', title, description, options), - info: ( - title: string, - description?: string, - options?: { actionLabel?: string; commandId?: string }, - ) => context.addToast('info', title, description, options), - }; + // QNBS-v3 (#332/D5): memoized on context.addToast's identity — an unmemoized object here previously invalidated every consumer's own useMemo (e.g. Settings context) on each unrelated render. + return useMemo( + () => ({ + success: ( + title: string, + description?: string, + options?: { actionLabel?: string; commandId?: string }, + ) => context.addToast('success', title, description, options), + error: ( + title: string, + description?: string, + options?: { actionLabel?: string; commandId?: string }, + ) => context.addToast('error', title, description, options), + info: ( + title: string, + description?: string, + options?: { actionLabel?: string; commandId?: string }, + ) => context.addToast('info', title, description, options), + }), + [context], + ); }; const ToastItem: FC<{ @@ -134,29 +138,35 @@ export const ToastProvider: FC<{ children: React.ReactNode }> = ({ children }) = const dispatch = useAppDispatch(); const notifications = useAppSelector((state) => state.status.notifications); - const addToast = ( - type: NotificationType, - title: string, - description?: string, - options?: { actionLabel?: string; commandId?: string }, - ) => { - dispatch( - statusActions.addNotification({ - type, - title, - ...(description !== undefined ? { description } : {}), - ...(options?.actionLabel ? { actionLabel: options.actionLabel } : {}), - ...(options?.commandId ? { commandId: options.commandId } : {}), - }), - ); - }; + // QNBS-v3 (#332/D5): stable identity so the memoized context value below and useToast()'s own memoization actually hold across re-renders. + const addToast = useCallback( + ( + type: NotificationType, + title: string, + description?: string, + options?: { actionLabel?: string; commandId?: string }, + ) => { + dispatch( + statusActions.addNotification({ + type, + title, + ...(description !== undefined ? { description } : {}), + ...(options?.actionLabel ? { actionLabel: options.actionLabel } : {}), + ...(options?.commandId ? { commandId: options.commandId } : {}), + }), + ); + }, + [dispatch], + ); const removeToast = (id: string) => { dispatch(statusActions.removeNotification(id)); }; + const contextValue = useMemo(() => ({ addToast }), [addToast]); + return ( - + {children}
{ [passphraseModal, dispatch, toast, t], ); - return { - t, - language, - settings, - featureFlags, - project, - activeCategory, - setActiveCategory, - modal, - setModal, - importFileRef, - snapshots, - snapshotName, - setSnapshotName, - handleLanguageChange, - handleSettingChange, - handleExport, - handleImport, - handleResetProject, - handleFactoryReset, - handleRepeatOnboarding, - handleCreateSnapshot, - handleRestoreSnapshot, - handleDeleteSnapshot, - projectSize, - currentWordCount, - passphraseModal, - setPassphraseModal, - encryptionReady, - migrationProgress, - handlePassphraseConfirm, - handleLockSession: useCallback(() => { - clearIdbEncryptionKey(); - setEncryptionReady(false); - toast.info(t('settings.privacy.encryptionLockedStatus')); - // QNBS-v3: without this, a subsequent autosave silently fails closed (no route back to the - // unlock UI existed) until the user manually reopens Settings and unlocks — surface the same - // global unlock modal App.tsx shows on a locked cold start. - setIdbUnlockOpen(true); - }, [toast, t, setIdbUnlockOpen]), - }; + const handleLockSession = useCallback(() => { + clearIdbEncryptionKey(); + setEncryptionReady(false); + toast.info(t('settings.privacy.encryptionLockedStatus')); + // QNBS-v3: without this, a subsequent autosave silently fails closed with no route back to the unlock UI — surface the same global unlock modal App.tsx shows on a locked cold start. + setIdbUnlockOpen(true); + }, [toast, t, setIdbUnlockOpen]); + + // QNBS-v3 (#332/D5): stabilizes the context value's identity so unrelated global-state renders (e.g. a background autosave write) don't force every Settings-tree consumer to re-render. + return useMemo( + () => ({ + t, + language, + settings, + featureFlags, + project, + activeCategory, + setActiveCategory, + modal, + setModal, + importFileRef, + snapshots, + snapshotName, + setSnapshotName, + handleLanguageChange, + handleSettingChange, + handleExport, + handleImport, + handleResetProject, + handleFactoryReset, + handleRepeatOnboarding, + handleCreateSnapshot, + handleRestoreSnapshot, + handleDeleteSnapshot, + projectSize, + currentWordCount, + passphraseModal, + setPassphraseModal, + encryptionReady, + migrationProgress, + handlePassphraseConfirm, + handleLockSession, + }), + [ + t, + language, + settings, + featureFlags, + project, + activeCategory, + modal, + snapshots, + snapshotName, + handleLanguageChange, + handleSettingChange, + handleExport, + handleImport, + handleResetProject, + handleFactoryReset, + handleRepeatOnboarding, + handleCreateSnapshot, + handleRestoreSnapshot, + handleDeleteSnapshot, + projectSize, + currentWordCount, + passphraseModal, + encryptionReady, + migrationProgress, + handlePassphraseConfirm, + handleLockSession, + ], + ); }; export type UseSettingsViewReturnType = ReturnType; diff --git a/index.css b/index.css index d51b7721..3edc3a14 100644 --- a/index.css +++ b/index.css @@ -1009,17 +1009,26 @@ body.is-desktop scrollbar-gutter: stable; } -/* QNBS-v3 (D4 / C-6): honor the OS reduced-transparency accessibility preference - on desktop by making the token-driven glass layer opaque (drops the GPU cost of - compositing translucent surfaces). NOTE: components that apply `backdrop-blur-*` - Tailwind utilities directly are not covered here — consolidating those onto the - `--glass-*` token layer is the remaining C-6 work (see docs/DESKTOP-UI-AUDIT.md). */ +/* QNBS-v3 (D4/C-6/#332): honors OS reduced-transparency by making the token-driven glass layer opaque and neutralizing direct backdrop-blur-* utilities (24 files) the token swap alone doesn't reach; unlayered CSS already outranks Tailwind's utility layer, so no !important is needed. */ @media (prefers-reduced-transparency: reduce) { body.is-desktop { --glass-bg: var(--sc-surface-raised); --glass-bg-hover: var(--sc-surface-overlay); --glass-border: var(--sc-border-subtle); } + body.is-desktop [class*="backdrop-blur-"] { + backdrop-filter: none; + } +} + +/* QNBS-v3 (#332/D4): manual "Reduce transparency effects" relief valve for desktop/Linux users whose OS/DE doesn't expose prefers-reduced-transparency; scoped to body.is-desktop like the OS-preference block above since the GPU cost this addresses is WebKitGTK-specific. */ +body.is-desktop.worldscript-reduced-transparency { + --glass-bg: var(--sc-surface-raised); + --glass-bg-hover: var(--sc-surface-overlay); + --glass-border: var(--sc-border-subtle); +} +body.is-desktop.worldscript-reduced-transparency [class*="backdrop-blur-"] { + backdrop-filter: none; } /* QNBS-v3 (PR2): scoped typography for the Export view's "Rendered" preview. DOMPurify strips diff --git a/index.tsx b/index.tsx index 6ce1e56e..abdd5d72 100644 --- a/index.tsx +++ b/index.tsx @@ -2,17 +2,15 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { Provider } from 'react-redux'; import App from './App'; +import { flushPersistedState } from './app/persistedStateFlush'; import { type AppDispatch, appStoreRef, type RootState, setupStore } from './app/store'; import { IdbUnlockModal } from './components/settings/IdbUnlockModal'; import { I18nProvider } from './contexts/I18nContext'; -import type { ProjectData } from './features/project/projectSlice'; import { versionControlActions } from './features/versionControl/versionControlSlice'; +import { loadPersistedRootState } from './services/appBootstrap'; import { initializeStorage, resetAllDatabases } from './services/dbInitialization'; -import { dbService } from './services/dbService'; import { logger } from './services/logger'; import { IdbStorageLockedError } from './services/storage/storageEncryptionService'; -import { saveEnvelopeFromProjectData, storageService } from './services/storageService'; -import type { PersistedRootState } from './types'; /* ── Self-hosted fonts (@fontsource) ── */ import '@fontsource/inter/300.css'; import '@fontsource/inter/400.css'; @@ -201,10 +199,7 @@ async function bootApp(): Promise { } try { - const loadedState = await dbService.loadState(); - const preloadedState: PersistedRootState | undefined = loadedState as - | PersistedRootState - | undefined; + const preloadedState = await loadPersistedRootState(); const isNewUser = !preloadedState; @@ -259,21 +254,11 @@ async function bootApp(): Promise { // QNBS-v3: visibilitychange-Flush reduziert Datenverlust, wenn Tabs abrupt in den Hintergrund wechseln. const flushOnHidden = () => { if (document.visibilityState !== 'hidden') return; - const state = store.getState() as RootState; - const presentData = state.project.present?.data; - if (!presentData) return; - const enriched: ProjectData = { - ...presentData, - persistedVersionControl: { - branches: state.versionControl.branches, - snapshots: state.versionControl.snapshots, - currentBranchId: state.versionControl.currentBranchId, - }, - }; - void Promise.allSettled([ - storageService.saveProject(saveEnvelopeFromProjectData(enriched)), - storageService.saveSettings(state.settings), - ]); + flushPersistedState(store.getState() as RootState).catch((error) => { + logger.warn('Best-effort visibilitychange flush failed', { + error: error instanceof Error ? error.message : String(error), + }); + }); }; document.addEventListener('visibilitychange', flushOnHidden); diff --git a/locales/ar/settings.json b/locales/ar/settings.json index 04157abc..ed1039b1 100644 --- a/locales/ar/settings.json +++ b/locales/ar/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "الراحة الحركية", "settings.accessibility.preset.screenReader": "قارئ الشاشة", "settings.accessibility.reducedMotion": "حركة مُخفَّضة", + "settings.accessibility.reducedTransparency": "تقليل تأثيرات الشفافية", "settings.accessibility.screenReader": "دعم قارئ الشاشة", "settings.accessibility.title": "إعدادات إمكانية الوصول", "settings.advancedAi.localRagBuild": "إعادة بناء فهرس البحث المحلي", diff --git a/locales/de/settings.json b/locales/de/settings.json index d6e3fd91..e7eeb616 100644 --- a/locales/de/settings.json +++ b/locales/de/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "Motorik & Bedienung", "settings.accessibility.preset.screenReader": "Screenreader", "settings.accessibility.reducedMotion": "Reduzierte Bewegung", + "settings.accessibility.reducedTransparency": "Transparenzeffekte reduzieren", "settings.accessibility.screenReader": "Bildschirmleser-Unterstützung", "settings.accessibility.title": "Barrierefreiheitseinstellungen", "settings.advancedAi.localRagBuild": "Suchindex neu aufbauen", diff --git a/locales/el/settings.json b/locales/el/settings.json index 2c0dbb7e..30027559 100644 --- a/locales/el/settings.json +++ b/locales/el/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "Άνεση κινητήρα", "settings.accessibility.preset.screenReader": "Αναγνώστης οθόνης", "settings.accessibility.reducedMotion": "Μειωμένη κίνηση", + "settings.accessibility.reducedTransparency": "Μείωση εφέ διαφάνειας", "settings.accessibility.screenReader": "Υποστήριξη Screen Reader", "settings.accessibility.title": "Accessibility Ρυθμίσεις", "settings.advancedAi.localRagBuild": "Ανοικοδόμηση ευρετηρίου τοπικής αναζήτησης", @@ -699,7 +700,7 @@ "settings.privacy.encryptionSetAction": "Ορίστε τη φράση πρόσβασης", "settings.privacy.encryptionSetButton": "Ενεργοποίηση & Κρυπτογράφηση", "settings.privacy.encryptionSetup": "Ορίστε μια φράση πρόσβασης για να κρυπτογραφήσετε όλα τα χειρόγραφα, τα στιγμιότυπα και τις ρυθμίσεις που είναι αποθηκευμένα σε αυτήν τη συσκευή.", - "settings.privacy.encryptionSetupFailed": "Could not save the encryption setup — check that this browser allows local storage, then try again", + "settings.privacy.encryptionSetupFailed": "Δεν ήταν δυνατή η αποθήκευση της ρύθμισης κρυπτογράφησης — ελέγξτε ότι αυτό το πρόγραμμα περιήγησης επιτρέπει την τοπική αποθήκευση και δοκιμάστε ξανά", "settings.privacy.encryptionTooManyAttempts": "Πάρα πολλές αποτυχημένες προσπάθειες. Περιμένετε {{seconds}} δευτερόλεπτα.", "settings.privacy.encryptionTooShort": "Η φράση πρόσβασης πρέπει να αποτελείται από τουλάχιστον 8 χαρακτήρες", "settings.privacy.encryptionUnlockAction": "Ξεκλείδωμα αποθήκευσης", diff --git a/locales/en/settings.json b/locales/en/settings.json index f2fba3dc..23ab8c59 100644 --- a/locales/en/settings.json +++ b/locales/en/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "Motor comfort", "settings.accessibility.preset.screenReader": "Screen reader", "settings.accessibility.reducedMotion": "Reduced Motion", + "settings.accessibility.reducedTransparency": "Reduce transparency effects", "settings.accessibility.screenReader": "Screen Reader Support", "settings.accessibility.title": "Accessibility Settings", "settings.advancedAi.localRagBuild": "Rebuild local search index", diff --git a/locales/es/settings.json b/locales/es/settings.json index 5e68a588..0e24550c 100644 --- a/locales/es/settings.json +++ b/locales/es/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "Comodidad motora", "settings.accessibility.preset.screenReader": "Lector de pantalla", "settings.accessibility.reducedMotion": "Movimiento reducido", + "settings.accessibility.reducedTransparency": "Reducir efectos de transparencia", "settings.accessibility.screenReader": "Soporte para lector de pantalla", "settings.accessibility.title": "Configuración de accesibilidad", "settings.advancedAi.localRagBuild": "Reconstruir índice de búsqueda local", diff --git a/locales/eu/settings.json b/locales/eu/settings.json index 3d75cb4d..60c65e16 100644 --- a/locales/eu/settings.json +++ b/locales/eu/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "Motor erosotasuna", "settings.accessibility.preset.screenReader": "Pantaila irakurgailua", "settings.accessibility.reducedMotion": "Mugimendu murriztua", + "settings.accessibility.reducedTransparency": "Murriztu gardentasun-efektuak", "settings.accessibility.screenReader": "Pantaila irakurgailuaren euskarria", "settings.accessibility.title": "Irisgarritasun ezarpenak", "settings.advancedAi.localRagBuild": "Berreraiki tokiko bilaketa-indizea", diff --git a/locales/fa/settings.json b/locales/fa/settings.json index fa8db67e..836ec05f 100644 --- a/locales/fa/settings.json +++ b/locales/fa/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "راحتی موتور", "settings.accessibility.preset.screenReader": "صفحه خوان", "settings.accessibility.reducedMotion": "حرکت کاهش یافته", + "settings.accessibility.reducedTransparency": "کاهش جلوه‌های شفافیت", "settings.accessibility.screenReader": "پشتیبانی از صفحه خوان", "settings.accessibility.title": "تنظیمات دسترسی", "settings.advancedAi.localRagBuild": "فهرست جستجوی محلی را بازسازی کنید", diff --git a/locales/fi/settings.json b/locales/fi/settings.json index 906d2c3d..34821849 100644 --- a/locales/fi/settings.json +++ b/locales/fi/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "Moottorin mukavuus", "settings.accessibility.preset.screenReader": "Näytönlukija", "settings.accessibility.reducedMotion": "Alennettu liike", + "settings.accessibility.reducedTransparency": "Vähennä läpinäkyvyystehosteita", "settings.accessibility.screenReader": "Näytönlukijan tuki", "settings.accessibility.title": "Esteettömyysasetukset", "settings.advancedAi.localRagBuild": "Rakenna paikallinen hakuhakemisto uudelleen", @@ -699,7 +700,7 @@ "settings.privacy.encryptionSetAction": "Aseta tunnuslause", "settings.privacy.encryptionSetButton": "Ota käyttöön ja salaa", "settings.privacy.encryptionSetup": "Aseta tunnuslause, joka salaa kaikki tähän laitteeseen tallennetut käsikirjoitukset, tilannevedokset ja asetukset.", - "settings.privacy.encryptionSetupFailed": "Could not save the encryption setup — check that this browser allows local storage, then try again", + "settings.privacy.encryptionSetupFailed": "Salauksen määritystä ei voitu tallentaa — tarkista, että tämä selain sallii paikallisen tallennustilan, ja yritä uudelleen", "settings.privacy.encryptionTooManyAttempts": "Liian monta epäonnistunutta yritystä. Odota {{seconds}} sekuntia.", "settings.privacy.encryptionTooShort": "Tunnuslauseessa on oltava vähintään 8 merkkiä", "settings.privacy.encryptionUnlockAction": "Avaa tallennustila", diff --git a/locales/fr/settings.json b/locales/fr/settings.json index b23db802..d79f8910 100644 --- a/locales/fr/settings.json +++ b/locales/fr/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "Confort moteur", "settings.accessibility.preset.screenReader": "Lecteur d'écran", "settings.accessibility.reducedMotion": "Mouvement réduit", + "settings.accessibility.reducedTransparency": "Réduire les effets de transparence", "settings.accessibility.screenReader": "Prise en charge du lecteur d'écran", "settings.accessibility.title": "Paramètres d'accessibilité", "settings.advancedAi.localRagBuild": "Reconstruire l'index de recherche local", diff --git a/locales/he/settings.json b/locales/he/settings.json index ec6e5895..005ec657 100644 --- a/locales/he/settings.json +++ b/locales/he/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "נוחות מוטורית", "settings.accessibility.preset.screenReader": "קורא מסך", "settings.accessibility.reducedMotion": "תנועה מופחתת", + "settings.accessibility.reducedTransparency": "הפחתת אפקטי שקיפות", "settings.accessibility.screenReader": "תמיכת קורא מסך", "settings.accessibility.title": "הגדרות נגישות", "settings.advancedAi.localRagBuild": "בנייה מחדש של אינדקס החיפוש המקומי", diff --git a/locales/hu/settings.json b/locales/hu/settings.json index 83597a4d..94aeae91 100644 --- a/locales/hu/settings.json +++ b/locales/hu/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "Motoros kényelem", "settings.accessibility.preset.screenReader": "Képernyőolvasó", "settings.accessibility.reducedMotion": "Csökkentett mozgás", + "settings.accessibility.reducedTransparency": "Áttetszőségi effektek csökkentése", "settings.accessibility.screenReader": "Képernyőolvasó támogatás", "settings.accessibility.title": "Kisegítő lehetőségek beállításai", "settings.advancedAi.localRagBuild": "Helyi keresési index újraépítése", @@ -699,7 +700,7 @@ "settings.privacy.encryptionSetAction": "Állítsa be a jelszót", "settings.privacy.encryptionSetButton": "Engedélyezés és titkosítás", "settings.privacy.encryptionSetup": "Állítson be egy jelszót az eszközön tárolt összes kézirat, pillanatkép és beállítás titkosításához.", - "settings.privacy.encryptionSetupFailed": "Could not save the encryption setup — check that this browser allows local storage, then try again", + "settings.privacy.encryptionSetupFailed": "Nem sikerült menteni a titkosítási beállítást — ellenőrizze, hogy a böngésző engedélyezi-e a helyi tárolást, majd próbálja újra", "settings.privacy.encryptionTooManyAttempts": "Túl sok sikertelen próbálkozás. Kérjük, várjon {{seconds}} másodpercet.", "settings.privacy.encryptionTooShort": "A jelszónak legalább 8 karakterből kell állnia", "settings.privacy.encryptionUnlockAction": "Tárolás feloldása", diff --git a/locales/is/settings.json b/locales/is/settings.json index 3a9e70a1..494cf443 100644 --- a/locales/is/settings.json +++ b/locales/is/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "Mótor þægindi", "settings.accessibility.preset.screenReader": "Skjálesari", "settings.accessibility.reducedMotion": "Minni hreyfing", + "settings.accessibility.reducedTransparency": "Draga úr gagnsæisáhrifum", "settings.accessibility.screenReader": "Stuðningur við skjálesara", "settings.accessibility.title": "Aðgengisstillingar", "settings.advancedAi.localRagBuild": "Endurbyggja staðbundna leitarvísitölu", @@ -699,7 +700,7 @@ "settings.privacy.encryptionSetAction": "Stilltu lykilorð", "settings.privacy.encryptionSetButton": "Virkja og dulkóða", "settings.privacy.encryptionSetup": "Stilltu lykilorð til að dulkóða öll handrit, skyndimyndir og stillingar sem vistaðar eru á þessu tæki.", - "settings.privacy.encryptionSetupFailed": "Could not save the encryption setup — check that this browser allows local storage, then try again", + "settings.privacy.encryptionSetupFailed": "Ekki tókst að vista dulkóðunarstillinguna — athugaðu hvort þessi vafri leyfir staðbundna geymslu og reyndu aftur", "settings.privacy.encryptionTooManyAttempts": "Of margar misheppnaðar tilraunir. Vinsamlegast bíddu í {{seconds}} sekúndur.", "settings.privacy.encryptionTooShort": "Aðgangsorð verður að vera að minnsta kosti 8 stafir", "settings.privacy.encryptionUnlockAction": "Opnaðu geymslu", diff --git a/locales/it/settings.json b/locales/it/settings.json index a1408bca..802c28df 100644 --- a/locales/it/settings.json +++ b/locales/it/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "Comfort motorio", "settings.accessibility.preset.screenReader": "Screen reader", "settings.accessibility.reducedMotion": "Movimento ridotto", + "settings.accessibility.reducedTransparency": "Riduci gli effetti di trasparenza", "settings.accessibility.screenReader": "Supporto per screen reader", "settings.accessibility.title": "Impostazioni di accessibilità", "settings.advancedAi.localRagBuild": "Ricostruisci l'indice di ricerca locale", diff --git a/locales/ja/settings.json b/locales/ja/settings.json index 147fa1c8..b9b1078a 100644 --- a/locales/ja/settings.json +++ b/locales/ja/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "モーターの快適性", "settings.accessibility.preset.screenReader": "スクリーンリーダー", "settings.accessibility.reducedMotion": "モーションの軽減", + "settings.accessibility.reducedTransparency": "透明効果を減らす", "settings.accessibility.screenReader": "スクリーン リーダーのサポート", "settings.accessibility.title": "Accessibility 設定", "settings.advancedAi.localRagBuild": "ローカル検索インデックスを再構築する", diff --git a/locales/ko/settings.json b/locales/ko/settings.json index 92e74cc1..9db3c890 100644 --- a/locales/ko/settings.json +++ b/locales/ko/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "모터의 편안함", "settings.accessibility.preset.screenReader": "스크린 리더", "settings.accessibility.reducedMotion": "모션 감소", + "settings.accessibility.reducedTransparency": "투명 효과 줄이기", "settings.accessibility.screenReader": "스크린 리더 지원", "settings.accessibility.title": "접근성 설정", "settings.advancedAi.localRagBuild": "지역 검색 색인 재구축", diff --git a/locales/pt/settings.json b/locales/pt/settings.json index c9a6bdfe..8661db65 100644 --- a/locales/pt/settings.json +++ b/locales/pt/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "Conforto motor", "settings.accessibility.preset.screenReader": "Leitor de tela", "settings.accessibility.reducedMotion": "Movimento Reduzido", + "settings.accessibility.reducedTransparency": "Reduzir efeitos de transparência", "settings.accessibility.screenReader": "Suporte para leitor de tela", "settings.accessibility.title": "Accessibility Configurações", "settings.advancedAi.localRagBuild": "Reconstruir índice de pesquisa local", diff --git a/locales/ru/settings.json b/locales/ru/settings.json index 5af132d3..82c4f29e 100644 --- a/locales/ru/settings.json +++ b/locales/ru/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "Двигательный комфорт", "settings.accessibility.preset.screenReader": "Программа чтения с экрана", "settings.accessibility.reducedMotion": "Уменьшенное движение", + "settings.accessibility.reducedTransparency": "Уменьшить эффекты прозрачности", "settings.accessibility.screenReader": "Поддержка программы чтения с экрана", "settings.accessibility.title": "Настройки специальных возможностей", "settings.advancedAi.localRagBuild": "Восстановить индекс локального поиска", diff --git a/locales/sv/settings.json b/locales/sv/settings.json index 2fada8eb..f106ca6c 100644 --- a/locales/sv/settings.json +++ b/locales/sv/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "Motorkomfort", "settings.accessibility.preset.screenReader": "Skärmläsare", "settings.accessibility.reducedMotion": "Minskad rörelse", + "settings.accessibility.reducedTransparency": "Minska transparenseffekter", "settings.accessibility.screenReader": "Support för skärmläsare", "settings.accessibility.title": "Tillgänglighetsinställningar", "settings.advancedAi.localRagBuild": "Bygg om lokalt sökindex", @@ -699,7 +700,7 @@ "settings.privacy.encryptionSetAction": "Ställ in lösenordsfras", "settings.privacy.encryptionSetButton": "Aktivera och kryptera", "settings.privacy.encryptionSetup": "Ställ in en lösenordsfras för att kryptera alla manuskript, ögonblicksbilder och inställningar som lagras på den här enheten.", - "settings.privacy.encryptionSetupFailed": "Could not save the encryption setup — check that this browser allows local storage, then try again", + "settings.privacy.encryptionSetupFailed": "Det gick inte att spara krypteringsinställningen — kontrollera att den här webbläsaren tillåter lokal lagring och försök igen", "settings.privacy.encryptionTooManyAttempts": "För många misslyckade försök. Vänta {{seconds}} sekunder.", "settings.privacy.encryptionTooShort": "Lösenfrasen måste bestå av minst 8 tecken", "settings.privacy.encryptionUnlockAction": "Lås upp lagring", diff --git a/locales/zh/settings.json b/locales/zh/settings.json index b419a991..3ed7f9a2 100644 --- a/locales/zh/settings.json +++ b/locales/zh/settings.json @@ -42,6 +42,7 @@ "settings.accessibility.preset.motor": "电机舒适度", "settings.accessibility.preset.screenReader": "屏幕阅读器", "settings.accessibility.reducedMotion": "减少运动", + "settings.accessibility.reducedTransparency": "减少透明效果", "settings.accessibility.screenReader": "屏幕阅读器支持", "settings.accessibility.title": "Accessibility 设置", "settings.advancedAi.localRagBuild": "重建本地搜索索引", diff --git a/public/locales/ar/bundle.json b/public/locales/ar/bundle.json index b9504e7e..972ba674 100644 --- a/public/locales/ar/bundle.json +++ b/public/locales/ar/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "الراحة الحركية", "settings.accessibility.preset.screenReader": "قارئ الشاشة", "settings.accessibility.reducedMotion": "حركة مُخفَّضة", + "settings.accessibility.reducedTransparency": "تقليل تأثيرات الشفافية", "settings.accessibility.screenReader": "دعم قارئ الشاشة", "settings.accessibility.title": "إعدادات إمكانية الوصول", "settings.advancedAi.localRagBuild": "إعادة بناء فهرس البحث المحلي", diff --git a/public/locales/de/bundle.json b/public/locales/de/bundle.json index 345acaaf..35a4dd0d 100644 --- a/public/locales/de/bundle.json +++ b/public/locales/de/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "Motorik & Bedienung", "settings.accessibility.preset.screenReader": "Screenreader", "settings.accessibility.reducedMotion": "Reduzierte Bewegung", + "settings.accessibility.reducedTransparency": "Transparenzeffekte reduzieren", "settings.accessibility.screenReader": "Bildschirmleser-Unterstützung", "settings.accessibility.title": "Barrierefreiheitseinstellungen", "settings.advancedAi.localRagBuild": "Suchindex neu aufbauen", diff --git a/public/locales/el/bundle.json b/public/locales/el/bundle.json index c169331d..59e358d2 100644 --- a/public/locales/el/bundle.json +++ b/public/locales/el/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "Άνεση κινητήρα", "settings.accessibility.preset.screenReader": "Αναγνώστης οθόνης", "settings.accessibility.reducedMotion": "Μειωμένη κίνηση", + "settings.accessibility.reducedTransparency": "Μείωση εφέ διαφάνειας", "settings.accessibility.screenReader": "Υποστήριξη Screen Reader", "settings.accessibility.title": "Accessibility Ρυθμίσεις", "settings.advancedAi.localRagBuild": "Ανοικοδόμηση ευρετηρίου τοπικής αναζήτησης", @@ -2345,7 +2346,7 @@ "settings.privacy.encryptionSetAction": "Ορίστε τη φράση πρόσβασης", "settings.privacy.encryptionSetButton": "Ενεργοποίηση & Κρυπτογράφηση", "settings.privacy.encryptionSetup": "Ορίστε μια φράση πρόσβασης για να κρυπτογραφήσετε όλα τα χειρόγραφα, τα στιγμιότυπα και τις ρυθμίσεις που είναι αποθηκευμένα σε αυτήν τη συσκευή.", - "settings.privacy.encryptionSetupFailed": "Could not save the encryption setup — check that this browser allows local storage, then try again", + "settings.privacy.encryptionSetupFailed": "Δεν ήταν δυνατή η αποθήκευση της ρύθμισης κρυπτογράφησης — ελέγξτε ότι αυτό το πρόγραμμα περιήγησης επιτρέπει την τοπική αποθήκευση και δοκιμάστε ξανά", "settings.privacy.encryptionTooManyAttempts": "Πάρα πολλές αποτυχημένες προσπάθειες. Περιμένετε {{seconds}} δευτερόλεπτα.", "settings.privacy.encryptionTooShort": "Η φράση πρόσβασης πρέπει να αποτελείται από τουλάχιστον 8 χαρακτήρες", "settings.privacy.encryptionUnlockAction": "Ξεκλείδωμα αποθήκευσης", diff --git a/public/locales/en/bundle.json b/public/locales/en/bundle.json index f827a63b..9f37fc14 100644 --- a/public/locales/en/bundle.json +++ b/public/locales/en/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "Motor comfort", "settings.accessibility.preset.screenReader": "Screen reader", "settings.accessibility.reducedMotion": "Reduced Motion", + "settings.accessibility.reducedTransparency": "Reduce transparency effects", "settings.accessibility.screenReader": "Screen Reader Support", "settings.accessibility.title": "Accessibility Settings", "settings.advancedAi.localRagBuild": "Rebuild local search index", diff --git a/public/locales/es/bundle.json b/public/locales/es/bundle.json index 9079e46d..3d67f3a0 100644 --- a/public/locales/es/bundle.json +++ b/public/locales/es/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "Comodidad motora", "settings.accessibility.preset.screenReader": "Lector de pantalla", "settings.accessibility.reducedMotion": "Movimiento reducido", + "settings.accessibility.reducedTransparency": "Reducir efectos de transparencia", "settings.accessibility.screenReader": "Soporte para lector de pantalla", "settings.accessibility.title": "Configuración de accesibilidad", "settings.advancedAi.localRagBuild": "Reconstruir índice de búsqueda local", diff --git a/public/locales/eu/bundle.json b/public/locales/eu/bundle.json index d1d37f4d..5a470883 100644 --- a/public/locales/eu/bundle.json +++ b/public/locales/eu/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "Motor erosotasuna", "settings.accessibility.preset.screenReader": "Pantaila irakurgailua", "settings.accessibility.reducedMotion": "Mugimendu murriztua", + "settings.accessibility.reducedTransparency": "Murriztu gardentasun-efektuak", "settings.accessibility.screenReader": "Pantaila irakurgailuaren euskarria", "settings.accessibility.title": "Irisgarritasun ezarpenak", "settings.advancedAi.localRagBuild": "Berreraiki tokiko bilaketa-indizea", diff --git a/public/locales/fa/bundle.json b/public/locales/fa/bundle.json index 81ed5f17..04816b6a 100644 --- a/public/locales/fa/bundle.json +++ b/public/locales/fa/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "راحتی موتور", "settings.accessibility.preset.screenReader": "صفحه خوان", "settings.accessibility.reducedMotion": "حرکت کاهش یافته", + "settings.accessibility.reducedTransparency": "کاهش جلوه‌های شفافیت", "settings.accessibility.screenReader": "پشتیبانی از صفحه خوان", "settings.accessibility.title": "تنظیمات دسترسی", "settings.advancedAi.localRagBuild": "فهرست جستجوی محلی را بازسازی کنید", diff --git a/public/locales/fi/bundle.json b/public/locales/fi/bundle.json index 54380747..cb0ee215 100644 --- a/public/locales/fi/bundle.json +++ b/public/locales/fi/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "Moottorin mukavuus", "settings.accessibility.preset.screenReader": "Näytönlukija", "settings.accessibility.reducedMotion": "Alennettu liike", + "settings.accessibility.reducedTransparency": "Vähennä läpinäkyvyystehosteita", "settings.accessibility.screenReader": "Näytönlukijan tuki", "settings.accessibility.title": "Esteettömyysasetukset", "settings.advancedAi.localRagBuild": "Rakenna paikallinen hakuhakemisto uudelleen", @@ -2345,7 +2346,7 @@ "settings.privacy.encryptionSetAction": "Aseta tunnuslause", "settings.privacy.encryptionSetButton": "Ota käyttöön ja salaa", "settings.privacy.encryptionSetup": "Aseta tunnuslause, joka salaa kaikki tähän laitteeseen tallennetut käsikirjoitukset, tilannevedokset ja asetukset.", - "settings.privacy.encryptionSetupFailed": "Could not save the encryption setup — check that this browser allows local storage, then try again", + "settings.privacy.encryptionSetupFailed": "Salauksen määritystä ei voitu tallentaa — tarkista, että tämä selain sallii paikallisen tallennustilan, ja yritä uudelleen", "settings.privacy.encryptionTooManyAttempts": "Liian monta epäonnistunutta yritystä. Odota {{seconds}} sekuntia.", "settings.privacy.encryptionTooShort": "Tunnuslauseessa on oltava vähintään 8 merkkiä", "settings.privacy.encryptionUnlockAction": "Avaa tallennustila", diff --git a/public/locales/fr/bundle.json b/public/locales/fr/bundle.json index 3af88abe..dbd74a90 100644 --- a/public/locales/fr/bundle.json +++ b/public/locales/fr/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "Confort moteur", "settings.accessibility.preset.screenReader": "Lecteur d'écran", "settings.accessibility.reducedMotion": "Mouvement réduit", + "settings.accessibility.reducedTransparency": "Réduire les effets de transparence", "settings.accessibility.screenReader": "Prise en charge du lecteur d'écran", "settings.accessibility.title": "Paramètres d'accessibilité", "settings.advancedAi.localRagBuild": "Reconstruire l'index de recherche local", diff --git a/public/locales/he/bundle.json b/public/locales/he/bundle.json index 74c658b4..e6ecb5ab 100644 --- a/public/locales/he/bundle.json +++ b/public/locales/he/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "נוחות מוטורית", "settings.accessibility.preset.screenReader": "קורא מסך", "settings.accessibility.reducedMotion": "תנועה מופחתת", + "settings.accessibility.reducedTransparency": "הפחתת אפקטי שקיפות", "settings.accessibility.screenReader": "תמיכת קורא מסך", "settings.accessibility.title": "הגדרות נגישות", "settings.advancedAi.localRagBuild": "בנייה מחדש של אינדקס החיפוש המקומי", diff --git a/public/locales/hu/bundle.json b/public/locales/hu/bundle.json index f7a658e6..8881476c 100644 --- a/public/locales/hu/bundle.json +++ b/public/locales/hu/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "Motoros kényelem", "settings.accessibility.preset.screenReader": "Képernyőolvasó", "settings.accessibility.reducedMotion": "Csökkentett mozgás", + "settings.accessibility.reducedTransparency": "Áttetszőségi effektek csökkentése", "settings.accessibility.screenReader": "Képernyőolvasó támogatás", "settings.accessibility.title": "Kisegítő lehetőségek beállításai", "settings.advancedAi.localRagBuild": "Helyi keresési index újraépítése", @@ -2345,7 +2346,7 @@ "settings.privacy.encryptionSetAction": "Állítsa be a jelszót", "settings.privacy.encryptionSetButton": "Engedélyezés és titkosítás", "settings.privacy.encryptionSetup": "Állítson be egy jelszót az eszközön tárolt összes kézirat, pillanatkép és beállítás titkosításához.", - "settings.privacy.encryptionSetupFailed": "Could not save the encryption setup — check that this browser allows local storage, then try again", + "settings.privacy.encryptionSetupFailed": "Nem sikerült menteni a titkosítási beállítást — ellenőrizze, hogy a böngésző engedélyezi-e a helyi tárolást, majd próbálja újra", "settings.privacy.encryptionTooManyAttempts": "Túl sok sikertelen próbálkozás. Kérjük, várjon {{seconds}} másodpercet.", "settings.privacy.encryptionTooShort": "A jelszónak legalább 8 karakterből kell állnia", "settings.privacy.encryptionUnlockAction": "Tárolás feloldása", diff --git a/public/locales/is/bundle.json b/public/locales/is/bundle.json index 8a65585e..fcf612bc 100644 --- a/public/locales/is/bundle.json +++ b/public/locales/is/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "Mótor þægindi", "settings.accessibility.preset.screenReader": "Skjálesari", "settings.accessibility.reducedMotion": "Minni hreyfing", + "settings.accessibility.reducedTransparency": "Draga úr gagnsæisáhrifum", "settings.accessibility.screenReader": "Stuðningur við skjálesara", "settings.accessibility.title": "Aðgengisstillingar", "settings.advancedAi.localRagBuild": "Endurbyggja staðbundna leitarvísitölu", @@ -2345,7 +2346,7 @@ "settings.privacy.encryptionSetAction": "Stilltu lykilorð", "settings.privacy.encryptionSetButton": "Virkja og dulkóða", "settings.privacy.encryptionSetup": "Stilltu lykilorð til að dulkóða öll handrit, skyndimyndir og stillingar sem vistaðar eru á þessu tæki.", - "settings.privacy.encryptionSetupFailed": "Could not save the encryption setup — check that this browser allows local storage, then try again", + "settings.privacy.encryptionSetupFailed": "Ekki tókst að vista dulkóðunarstillinguna — athugaðu hvort þessi vafri leyfir staðbundna geymslu og reyndu aftur", "settings.privacy.encryptionTooManyAttempts": "Of margar misheppnaðar tilraunir. Vinsamlegast bíddu í {{seconds}} sekúndur.", "settings.privacy.encryptionTooShort": "Aðgangsorð verður að vera að minnsta kosti 8 stafir", "settings.privacy.encryptionUnlockAction": "Opnaðu geymslu", diff --git a/public/locales/it/bundle.json b/public/locales/it/bundle.json index b380a703..244ebca3 100644 --- a/public/locales/it/bundle.json +++ b/public/locales/it/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "Comfort motorio", "settings.accessibility.preset.screenReader": "Screen reader", "settings.accessibility.reducedMotion": "Movimento ridotto", + "settings.accessibility.reducedTransparency": "Riduci gli effetti di trasparenza", "settings.accessibility.screenReader": "Supporto per screen reader", "settings.accessibility.title": "Impostazioni di accessibilità", "settings.advancedAi.localRagBuild": "Ricostruisci l'indice di ricerca locale", diff --git a/public/locales/ja/bundle.json b/public/locales/ja/bundle.json index 05b1c9d3..cdf7adc2 100644 --- a/public/locales/ja/bundle.json +++ b/public/locales/ja/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "モーターの快適性", "settings.accessibility.preset.screenReader": "スクリーンリーダー", "settings.accessibility.reducedMotion": "モーションの軽減", + "settings.accessibility.reducedTransparency": "透明効果を減らす", "settings.accessibility.screenReader": "スクリーン リーダーのサポート", "settings.accessibility.title": "Accessibility 設定", "settings.advancedAi.localRagBuild": "ローカル検索インデックスを再構築する", diff --git a/public/locales/ko/bundle.json b/public/locales/ko/bundle.json index 271959ec..8947a9c4 100644 --- a/public/locales/ko/bundle.json +++ b/public/locales/ko/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "모터의 편안함", "settings.accessibility.preset.screenReader": "스크린 리더", "settings.accessibility.reducedMotion": "모션 감소", + "settings.accessibility.reducedTransparency": "투명 효과 줄이기", "settings.accessibility.screenReader": "스크린 리더 지원", "settings.accessibility.title": "접근성 설정", "settings.advancedAi.localRagBuild": "지역 검색 색인 재구축", diff --git a/public/locales/pt/bundle.json b/public/locales/pt/bundle.json index 095b08fd..1443a32c 100644 --- a/public/locales/pt/bundle.json +++ b/public/locales/pt/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "Conforto motor", "settings.accessibility.preset.screenReader": "Leitor de tela", "settings.accessibility.reducedMotion": "Movimento Reduzido", + "settings.accessibility.reducedTransparency": "Reduzir efeitos de transparência", "settings.accessibility.screenReader": "Suporte para leitor de tela", "settings.accessibility.title": "Accessibility Configurações", "settings.advancedAi.localRagBuild": "Reconstruir índice de pesquisa local", diff --git a/public/locales/ru/bundle.json b/public/locales/ru/bundle.json index 17b1448f..0900b2f2 100644 --- a/public/locales/ru/bundle.json +++ b/public/locales/ru/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "Двигательный комфорт", "settings.accessibility.preset.screenReader": "Программа чтения с экрана", "settings.accessibility.reducedMotion": "Уменьшенное движение", + "settings.accessibility.reducedTransparency": "Уменьшить эффекты прозрачности", "settings.accessibility.screenReader": "Поддержка программы чтения с экрана", "settings.accessibility.title": "Настройки специальных возможностей", "settings.advancedAi.localRagBuild": "Восстановить индекс локального поиска", diff --git a/public/locales/sv/bundle.json b/public/locales/sv/bundle.json index 983b3b6f..d50e7a7e 100644 --- a/public/locales/sv/bundle.json +++ b/public/locales/sv/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "Motorkomfort", "settings.accessibility.preset.screenReader": "Skärmläsare", "settings.accessibility.reducedMotion": "Minskad rörelse", + "settings.accessibility.reducedTransparency": "Minska transparenseffekter", "settings.accessibility.screenReader": "Support för skärmläsare", "settings.accessibility.title": "Tillgänglighetsinställningar", "settings.advancedAi.localRagBuild": "Bygg om lokalt sökindex", @@ -2345,7 +2346,7 @@ "settings.privacy.encryptionSetAction": "Ställ in lösenordsfras", "settings.privacy.encryptionSetButton": "Aktivera och kryptera", "settings.privacy.encryptionSetup": "Ställ in en lösenordsfras för att kryptera alla manuskript, ögonblicksbilder och inställningar som lagras på den här enheten.", - "settings.privacy.encryptionSetupFailed": "Could not save the encryption setup — check that this browser allows local storage, then try again", + "settings.privacy.encryptionSetupFailed": "Det gick inte att spara krypteringsinställningen — kontrollera att den här webbläsaren tillåter lokal lagring och försök igen", "settings.privacy.encryptionTooManyAttempts": "För många misslyckade försök. Vänta {{seconds}} sekunder.", "settings.privacy.encryptionTooShort": "Lösenfrasen måste bestå av minst 8 tecken", "settings.privacy.encryptionUnlockAction": "Lås upp lagring", diff --git a/public/locales/zh/bundle.json b/public/locales/zh/bundle.json index ac33292a..40ccb893 100644 --- a/public/locales/zh/bundle.json +++ b/public/locales/zh/bundle.json @@ -1688,6 +1688,7 @@ "settings.accessibility.preset.motor": "电机舒适度", "settings.accessibility.preset.screenReader": "屏幕阅读器", "settings.accessibility.reducedMotion": "减少运动", + "settings.accessibility.reducedTransparency": "减少透明效果", "settings.accessibility.screenReader": "屏幕阅读器支持", "settings.accessibility.title": "Accessibility 设置", "settings.advancedAi.localRagBuild": "重建本地搜索索引", diff --git a/services/appBootstrap.ts b/services/appBootstrap.ts new file mode 100644 index 00000000..bc4648b2 --- /dev/null +++ b/services/appBootstrap.ts @@ -0,0 +1,38 @@ +import type { ProjectData } from '../features/project/projectSlice'; +import type { PersistedRootState } from '../types'; +import { dbService } from './dbService'; +import { storageService } from './storageService'; +import { isTauriRuntime } from './tauriRuntime'; + +/** + * QNBS-v3 (#332): every persisted-state write on desktop already routes through `storageService` + * (Tauri-filesystem-aware) — `app/listenerMiddleware.ts`'s autosaves and `index.tsx`'s own + * `visibilitychange` flush (via `app/persistedStateFlush.ts`) all use it. `dbService.loadState()` is + * a raw IndexedDB-only read with zero Tauri branching; on the desktop build nothing was ever read + * back at cold boot, so every launch hydrated as a brand-new user regardless of what was actually + * saved to disk. This mirrors the save path instead of reading IndexedDB unconditionally. Extracted + * out of `index.tsx` (a side-effect-heavy entry module that boots the whole app on import) so this + * branch is directly unit-testable. + */ +export async function loadPersistedRootState(): Promise { + if (!isTauriRuntime()) { + const loadedState = await dbService.loadState(); + return loadedState as PersistedRootState | undefined; + } + const [settings, projectIds, activeProjectId] = await Promise.all([ + storageService.loadSettings(), + storageService.listProjects(), + storageService.getActiveProjectId(), + ]); + // QNBS-v3 (#332): prefer the last-saved project's marker over projectIds[0] — readDir() order isn't recency, so an arbitrary first entry could hydrate a stale project. Falls back to projectIds[0] for pre-marker installs or a since-deleted active project. + const projectId = + activeProjectId && projectIds.includes(activeProjectId) ? activeProjectId : projectIds[0]; + const project = projectId ? await storageService.loadProject(projectId) : null; + if (!settings && !project) return undefined; + const result: PersistedRootState = {}; + if (settings) result.settings = settings; + // QNBS-v3: flat shape — the existing hydration logic below reconstructs the redux-undo envelope + // from `project.data` regardless of which backend produced it. + if (project) result.project = { data: project as unknown as ProjectData }; + return result; +} diff --git a/services/desktop/desktopMenu.ts b/services/desktop/desktopMenu.ts index 8e99dbc4..c0cc49bd 100644 --- a/services/desktop/desktopMenu.ts +++ b/services/desktop/desktopMenu.ts @@ -20,6 +20,8 @@ const log = createLogger('desktop-menu'); export type MenuTranslate = (key: string) => string; /** Routes a native menu action to an app command (the App-level `executeCommand`). */ export type MenuCommandRunner = (commandId: string) => void; +/** Flushes pending state, then exits the process — must not resolve if it did not actually quit. */ +export type DesktopQuitFn = () => Promise; // QNBS-v3 (#189): monotonic request token. installDesktopMenu is async and re-runs on every language // change; without this, two overlapping calls race and whichever finishes setAsAppMenu() LAST wins — @@ -40,6 +42,7 @@ export function _resetMenuInstallTokenForTest(): void { export async function installDesktopMenu( t: MenuTranslate, runCommand: MenuCommandRunner, + quitApp: DesktopQuitFn, ): Promise { if (!isTauriRuntime()) return false; const myToken = ++menuInstallToken; @@ -60,7 +63,14 @@ export async function installDesktopMenu( action: () => runCommand(DESKTOP_COMMANDS.settings), }), await PredefinedMenuItem.new({ item: 'Separator' }), - await PredefinedMenuItem.new({ item: 'Quit' }), + // QNBS-v3 (#332/D3): custom item (not PredefinedMenuItem) so Quit routes through quitApp's flush — the predefined item calls the OS exit directly, bypassing any app-level flush. + await MenuItem.new({ + id: 'menu-quit', + text: t('desktop.tray.quit'), + action: () => { + void quitApp(); + }, + }), ], }); diff --git a/services/desktop/desktopTray.ts b/services/desktop/desktopTray.ts index 5aafb7e1..034167de 100644 --- a/services/desktop/desktopTray.ts +++ b/services/desktop/desktopTray.ts @@ -20,6 +20,8 @@ const TRAY_ID = 'worldscript-main-tray'; export type MenuTranslate = (key: string) => string; export type TrayCommandRunner = (commandId: string) => void; +/** Flushes pending state, then exits the process — must not resolve if it did not actually quit. */ +export type DesktopQuitFn = () => Promise; let trayInstalled = false; // QNBS-v3 (#190): in-flight guard. `trayInstalled` only flips true AFTER the async imports + tray @@ -45,6 +47,7 @@ export function _resetTrayInstalledForTest(): void { export async function installDesktopTray( t: MenuTranslate, runCommand: TrayCommandRunner, + quitApp: DesktopQuitFn, ): Promise { if (!isTauriRuntime() || trayInstalling) return false; trayInstalling = true; @@ -76,7 +79,14 @@ export async function installDesktopTray( action: () => runCommand(DESKTOP_COMMANDS.commandPalette), }), await PredefinedMenuItem.new({ item: 'Separator' }), - await PredefinedMenuItem.new({ item: 'Quit' }), + // QNBS-v3 (#332/D3): custom item (not PredefinedMenuItem) so Quit routes through quitApp's flush — the predefined item calls the OS exit directly, bypassing onCloseRequested entirely. + await MenuItem.new({ + id: 'tray-quit', + text: t('desktop.tray.quit'), + action: () => { + void quitApp(); + }, + }), ], }); @@ -117,18 +127,34 @@ export async function installDesktopTray( * Intercept the window close button: when `shouldMinimizeToTray()` is true, hide to the tray instead * of quitting. Returns an unlisten fn (or null on web / failure). The getter is read live so a * settings change takes effect without re-registering. + * + * QNBS-v3 (#332/D3): when the window is actually allowed to close (minimize-to-tray off, the + * default), `flushPendingState` is awaited first — Tauri's `onCloseRequested` supports and awaits + * async handlers before the window closes — so the 1s debounced project/settings autosave can't be + * silently dropped by a quit that lands mid-debounce. A rejected flush keeps the window open + * (fail closed) instead of letting a failed save through as if it were a successful pre-close save. */ export async function installCloseToTray( shouldMinimizeToTray: () => boolean, + flushPendingState: () => Promise, ): Promise<(() => void) | null> { if (!isTauriRuntime()) return null; try { const { getCurrentWindow } = await import('@tauri-apps/api/window'); const win = getCurrentWindow(); - return await win.onCloseRequested((event) => { + return await win.onCloseRequested(async (event) => { if (shouldMinimizeToTray()) { event.preventDefault(); void win.hide(); + return; + } + try { + await flushPendingState(); + } catch (err) { + event.preventDefault(); + log.warn('Pre-close flush failed — keeping the window open instead of quitting', { + error: String(err), + }); } }); } catch (err) { diff --git a/services/fs/projectFsStore.ts b/services/fs/projectFsStore.ts index 08004b8c..cfcb5a08 100644 --- a/services/fs/projectFsStore.ts +++ b/services/fs/projectFsStore.ts @@ -37,6 +37,43 @@ export class FsProjectStore extends FsAssetStore { const projectFile = await apis.join(projectPath, 'project.json'); await retryFs(() => apis.writeTextFile(projectFile, compressData(flat))); + // QNBS-v3 (#332): documented best-effort abort — the project data above already saved; a failed marker write only degrades the next cold-boot's project selection, not worth failing this save over. + await this.setActiveProjectId(projectId).catch((error) => { + logger.warn('Failed to persist active-project marker (project save itself succeeded)', { + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + /** QNBS-v3 (#332): marker file recording the last-saved project ID, read back at cold boot. */ + private async setActiveProjectId(projectId: string): Promise { + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + const configPath = await apis.join(appDataPath, 'config'); + if (!(await apis.exists(configPath))) { + await apis.mkdir(configPath, { recursive: true }); + } + const markerFile = await apis.join(configPath, 'active-project-id.txt'); + await retryFs(() => apis.writeTextFile(markerFile, projectId)); + } + + /** + * The last-saved project's ID, or null if no marker exists yet (fresh install, or one that + * predates this marker — callers should fall back to a deterministic choice among + * `listProjects()`'s results, not assume null means no projects exist). + */ + async getActiveProjectId(): Promise { + try { + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + const markerFile = await apis.join(appDataPath, 'config', 'active-project-id.txt'); + if (!(await apis.exists(markerFile))) return null; + const id = (await retryFs(() => apis.readTextFile(markerFile))).trim(); + return id || null; + } catch (error) { + logger.error('Failed to read active project marker:', error); + return null; + } } async loadProject(projectId: string): Promise { diff --git a/services/storage/idbProjectStore.ts b/services/storage/idbProjectStore.ts index 1797c00c..8bb24aef 100644 --- a/services/storage/idbProjectStore.ts +++ b/services/storage/idbProjectStore.ts @@ -43,7 +43,10 @@ import { export function normalizePersistedSettings(incoming: Record): Settings { const validSettings = { theme: 'dark', - appearancePreset: 'default', + // QNBS-v3 (#332): must match settingsSlice.ts's initialState.appearancePreset — a mismatch here + // only mattered for genuinely first-ever launches, but the two are the same product default and + // should never silently disagree about what "no persisted preference" means. + appearancePreset: 'sepia', writingSurfaceStyle: 'textured', // QNBS-v3: aiMode added in v1.22 — backfill for older persisted settings that lack the field. aiMode: 'hybrid', @@ -56,9 +59,10 @@ export function normalizePersistedSettings(incoming: Record): S ...incoming, } as Settings; - // QNBS-v3: fantasy/romance presets removed in v1.22 — migrate legacy stored values to 'default' + // QNBS-v3: fantasy/romance presets removed in v1.22 — migrate legacy stored values to the + // current default (#332: was 'default', now matches settingsSlice.ts's 'sepia'). if (!['default', 'sepia'].includes(validSettings.appearancePreset)) { - validSettings.appearancePreset = 'default'; + validSettings.appearancePreset = 'sepia'; } if (!['textured', 'plain'].includes(validSettings.writingSurfaceStyle)) { validSettings.writingSurfaceStyle = 'textured'; diff --git a/services/storageBackend.ts b/services/storageBackend.ts index d3c451dd..83eef5df 100644 --- a/services/storageBackend.ts +++ b/services/storageBackend.ts @@ -64,6 +64,8 @@ export interface StorageBackend { loadProject(projectId: string): Promise; listProjects(): Promise; deleteProject(projectId: string): Promise; + /** QNBS-v3 (#332): optional — only the multi-project Tauri filesystem backend implements this; IndexedDB's single-project contract has no "which one" ambiguity to resolve. */ + getActiveProjectId?(): Promise; saveImage(id: string, base64Data: string): Promise; getImage(id: string): Promise; diff --git a/services/storageService.ts b/services/storageService.ts index a6092bbf..3f16b297 100644 --- a/services/storageService.ts +++ b/services/storageService.ts @@ -82,6 +82,12 @@ class StorageManager { return backend.listProjects(); } + // QNBS-v3 (#332): optional on StorageBackend — IndexedDB has no multi-project ambiguity to resolve, so a missing implementation normalizes to null rather than throwing. + async getActiveProjectId(): Promise { + const backend = await this.getBackend(); + return (await backend.getActiveProjectId?.()) ?? null; + } + async deleteProject(projectId: string): Promise { const backend = await this.getBackend(); return backend.deleteProject(projectId); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5f4ed729..e42f32bd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4995,6 +4995,16 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.5" @@ -6683,6 +6693,7 @@ dependencies = [ "tauri-plugin-http", "tauri-plugin-log", "tauri-plugin-notification", + "tauri-plugin-process", "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-updater", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 01333836..506baa7c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,8 @@ tauri-plugin-deep-link = "2" tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } # QNBS-v3 (T3): native OS notifications (background-task completion), permission-gated in JS. tauri-plugin-notification = "2" +# QNBS-v3 (#332/D3): exposes exit() to JS so the tray/menu Quit items can flush state before actually terminating the process. +tauri-plugin-process = "2" tokio = { version = "1", features = ["sync", "time", "process", "rt-multi-thread"] } candle-core = { version = "0.11", optional = true } candle-nn = { version = "0.11", optional = true } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e417648b..0f7032d4 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -74,6 +74,7 @@ "shell:allow-open", "updater:default", "notification:default", + "process:allow-exit", "core:tray:default", "core:app:allow-default-window-icon", "core:window:allow-show", diff --git a/src-tauri/osv-scanner.toml b/src-tauri/osv-scanner.toml index a53cfdfb..5ed060f0 100644 --- a/src-tauri/osv-scanner.toml +++ b/src-tauri/osv-scanner.toml @@ -117,13 +117,13 @@ ignoreUntil = "2026-11-30T00:00:00Z" reason = "unic-ucd-ident 0.9.0: unmaintained Unicode data crate; final version; no exploitable attack surface" # ─── npm advisories (pnpm-lock.yaml) ───────────────────────────────────────── -# Reviewed 2026-06-16 / 2026-07-26. All currently-flagged npm advisories are -# remediated via pnpm-workspace.yaml overrides (tmp, ws, form-data, protobufjs, -# @babel/core, dompurify→3.4.12, js-yaml→4.x, body-parser→1.20.6, …) — no open -# npm ignores remain. (The former dompurify GHSA-x4vx-rjvf-j5p4 and js-yaml-3.x -# GHSA-h67p-54hq-rp68 ignores were dropped: both advisories no longer match the -# lockfile after the overrides.) One documented ignore remains below — no fixed -# version exists to override to. +# Reviewed 2026-06-16 / 2026-07-26 / 2026-08-12. Most currently-flagged npm +# advisories are remediated via pnpm-workspace.yaml overrides (tmp, ws, +# form-data, protobufjs, @babel/core, dompurify→3.4.12, js-yaml→4.x, +# body-parser→1.20.6, …). (The former dompurify GHSA-x4vx-rjvf-j5p4 and +# js-yaml-3.x GHSA-h67p-54hq-rp68 ignores were dropped: both advisories no +# longer match the lockfile after the overrides.) One documented ignore +# remains below — no fixed version exists to override to. # extract-zip@2.0.1 (CVE-2026-56876 / GHSA-jmr9-qjv8-65gv, CVSS 8.6): unvalidated # symlink path traversal when extracting an attacker-controlled zip archive. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9b82e59b..c4683af3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -141,6 +141,8 @@ pub fn run() { // QNBS-v3 (T3): native OS notifications — permission request/gating lives entirely in JS // (services/desktop/desktopNotifications.ts); the Rust side only registers the plugin. .plugin(tauri_plugin_notification::init()) + // QNBS-v3 (#332/D3): exposes exit() to JS so tray/menu Quit can flush state before terminating. + .plugin(tauri_plugin_process::init()) .plugin( tauri_plugin_window_state::Builder::new() .with_state_flags(tauri_plugin_window_state::StateFlags::all()) diff --git a/tests/e2e/settings-persistence.spec.ts b/tests/e2e/settings-persistence.spec.ts new file mode 100644 index 00000000..b273d837 --- /dev/null +++ b/tests/e2e/settings-persistence.spec.ts @@ -0,0 +1,36 @@ +/** + * QNBS-v3 (#332/D1): exercises the real Redux→listenerMiddleware→storageService→reload→boot-hydration round trip on the web-build CI runner; cannot verify the Tauri filesystem branch itself (open per docs/ISSUES-332-333-PERFORMANCE-LEDGER.md until a packaged .deb is measured). + */ +import { expect, test } from '@playwright/test'; +import { clickNavItem, ensureBlankProject, selectEnglish, waitForSpaReady } from './helpers'; + +test.describe('Settings persistence round-trip', () => { + test('a toggled accessibility setting survives a reload', async ({ page }) => { + await page.goto('/'); + await waitForSpaReady(page); + await selectEnglish(page); + await ensureBlankProject(page); + await clickNavItem(page, /Settings/i); + await page.getByRole('button', { name: /Accessibility|Barrierefreiheit/i }).click(); + + const toggle = page.getByRole('switch', { name: 'Reduce transparency effects' }); + await expect(toggle).toBeVisible({ timeout: 15000 }); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + // QNBS-v3: settings autosave is debounced (~1s, listenerMiddleware.ts) — wait it out before reloading, matching flushWriterDebounce's established fixed-wait pattern in helpers.ts. + await page.waitForTimeout(1500); + + await page.reload(); + await waitForSpaReady(page); + await clickNavItem(page, /Settings/i); + await page.getByRole('button', { name: /Accessibility|Barrierefreiheit/i }).click(); + + const toggleAfterReload = page.getByRole('switch', { name: 'Reduce transparency effects' }); + await expect(toggleAfterReload).toBeVisible({ timeout: 15000 }); + await expect(toggleAfterReload).toHaveAttribute('aria-checked', 'true'); + // QNBS-v3: the body-class effect (index.css's worldscript-reduced-transparency rule) confirms the setting reached Redux state, not just the toggle's own UI state. + await expect(page.locator('body')).toHaveClass(/worldscript-reduced-transparency/); + }); +}); diff --git a/tests/unit/Toast.test.tsx b/tests/unit/Toast.test.tsx index 3265cd4d..44000b06 100644 --- a/tests/unit/Toast.test.tsx +++ b/tests/unit/Toast.test.tsx @@ -204,4 +204,12 @@ describe('useToast', () => { }); expect(mockDispatch).toHaveBeenCalled(); }); + + // QNBS-v3 (#332/D5): consumers (e.g. useSettingsView's context memoization) depend on this staying stable — a fresh object every render defeated their useMemo. + it('keeps the same object reference across a re-render', () => { + const { result, rerender } = renderHook(() => useToast(), { wrapper }); + const first = result.current; + rerender(); + expect(result.current).toBe(first); + }); }); diff --git a/tests/unit/accessibilitySchema.test.ts b/tests/unit/accessibilitySchema.test.ts index 176dacdc..34c594e7 100644 --- a/tests/unit/accessibilitySchema.test.ts +++ b/tests/unit/accessibilitySchema.test.ts @@ -11,6 +11,13 @@ describe('accessibilitySchema', () => { expect(a.liveRegionVerbosity).toBe('normal'); expect(a.comfortableTargets).toBe(false); expect(a.focusIndicators).toBe(true); + // QNBS-v3 (#332/D4): manual reduced-transparency toggle defaults off — must not silently change anyone's visual design on upgrade. + expect(a.reducedTransparency).toBe(false); + }); + + it('preserves an explicitly persisted reducedTransparency value over the default', () => { + const a = normalizeAccessibilitySettings({ reducedTransparency: true }); + expect(a.reducedTransparency).toBe(true); }); it('motor preset enables comfortable targets and large text', () => { diff --git a/tests/unit/desktopMenu.test.ts b/tests/unit/desktopMenu.test.ts index 757e90cd..8d17adc1 100644 --- a/tests/unit/desktopMenu.test.ts +++ b/tests/unit/desktopMenu.test.ts @@ -65,13 +65,21 @@ describe('installDesktopMenu', () => { it('returns false on the web (no Tauri runtime)', async () => { h.isTauri.value = false; - const ok = await installDesktopMenu((k) => k, vi.fn()); + const ok = await installDesktopMenu( + (k) => k, + vi.fn(), + vi.fn(async () => {}), + ); expect(ok).toBe(false); expect(h.setAsAppMenu).not.toHaveBeenCalled(); }); it('builds five localized submenus and sets the app menu', async () => { - const ok = await installDesktopMenu((k) => `T:${k}`, vi.fn()); + const ok = await installDesktopMenu( + (k) => `T:${k}`, + vi.fn(), + vi.fn(async () => {}), + ); expect(ok).toBe(true); expect(h.setAsAppMenu).toHaveBeenCalledTimes(1); expect(h.submenuCalls.map((s) => s.text)).toEqual([ @@ -83,11 +91,16 @@ describe('installDesktopMenu', () => { ]); }); - it('creates exactly the four custom items (Edit/Window use predefined items)', async () => { - await installDesktopMenu((k) => k, vi.fn()); + it('creates exactly the five custom items, including Quit (Edit/Window use predefined items)', async () => { + await installDesktopMenu( + (k) => k, + vi.fn(), + vi.fn(async () => {}), + ); expect(h.itemCalls.map((c) => c.id)).toEqual([ 'menu-export', 'menu-settings', + 'menu-quit', 'menu-command-palette', 'menu-help', ]); @@ -95,11 +108,18 @@ describe('installDesktopMenu', () => { expect(h.itemCalls.find((c) => c.id === 'menu-command-palette')?.accelerator).toBe( 'CmdOrCtrl+K', ); + // QNBS-v3 (#332/D3): Quit is now a custom item (not PredefinedMenuItem) so it routes through quitApp's flush. + expect(h.itemCalls.find((c) => c.id === 'menu-quit')?.text).toBe('desktop.tray.quit'); + expect(h.predefinedCalls.map((c) => c.item)).not.toContain('Quit'); }); it('routes custom item actions to executeCommand with the right command id', async () => { const run = vi.fn(); - await installDesktopMenu((k) => k, run); + await installDesktopMenu( + (k) => k, + run, + vi.fn(async () => {}), + ); const byId = Object.fromEntries(h.itemCalls.map((c) => [c.id, c])); byId['menu-export']?.action?.(); byId['menu-settings']?.action?.(); @@ -111,10 +131,26 @@ describe('installDesktopMenu', () => { expect(run).toHaveBeenCalledWith('nav-help'); }); + it('routes the Quit item action to quitApp', async () => { + const quitApp = vi.fn(async () => {}); + await installDesktopMenu((k) => k, vi.fn(), quitApp); + const byId = Object.fromEntries(h.itemCalls.map((c) => [c.id, c])); + byId['menu-quit']?.action?.(); + expect(quitApp).toHaveBeenCalledTimes(1); + }); + it('discards a stale concurrent install — only the latest applies setAsAppMenu', async () => { const results = await Promise.all([ - installDesktopMenu((k) => k, vi.fn()), - installDesktopMenu((k) => k, vi.fn()), + installDesktopMenu( + (k) => k, + vi.fn(), + vi.fn(async () => {}), + ), + installDesktopMenu( + (k) => k, + vi.fn(), + vi.fn(async () => {}), + ), ]); // Core guarantee: a superseded (stale) call must NEVER apply its menu — at most one of the two // overlapping calls reaches setAsAppMenu, so a stale locale can't overwrite the newer menu. @@ -127,7 +163,11 @@ describe('installDesktopMenu', () => { it('returns false (and does not throw) when the menu API rejects', async () => { const mod = await import('@tauri-apps/api/menu'); vi.mocked(mod.Menu.new).mockRejectedValueOnce(new Error('boom')); - const ok = await installDesktopMenu((k) => k, vi.fn()); + const ok = await installDesktopMenu( + (k) => k, + vi.fn(), + vi.fn(async () => {}), + ); expect(ok).toBe(false); }); }); diff --git a/tests/unit/desktopTray.test.ts b/tests/unit/desktopTray.test.ts index 9b5e4e3d..d7418a11 100644 --- a/tests/unit/desktopTray.test.ts +++ b/tests/unit/desktopTray.test.ts @@ -19,7 +19,7 @@ const h = vi.hoisted(() => ({ action?: (e: { type: string; button?: string }) => void; } | null, setVisible: vi.fn(), - closeCb: null as ((e: { preventDefault: () => void }) => void) | null, + closeCb: null as ((e: { preventDefault: () => void }) => void | Promise) | null, hide: vi.fn(), setMenu: vi.fn(), setTooltip: vi.fn(), @@ -53,10 +53,12 @@ vi.mock('@tauri-apps/api/menu', () => ({ vi.mock('@tauri-apps/api/app', () => ({ defaultWindowIcon: vi.fn(async () => null) })); vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ - onCloseRequested: vi.fn(async (cb: (e: { preventDefault: () => void }) => void) => { - h.closeCb = cb; - return () => {}; - }), + onCloseRequested: vi.fn( + async (cb: (e: { preventDefault: () => void }) => void | Promise) => { + h.closeCb = cb; + return () => {}; + }, + ), hide: () => h.hide(), }), })); @@ -81,25 +83,57 @@ describe('installDesktopTray', () => { it('returns false on the web (no Tauri runtime)', async () => { h.isTauri.value = false; - expect(await installDesktopTray((k) => k, vi.fn())).toBe(false); + expect( + await installDesktopTray( + (k) => k, + vi.fn(), + vi.fn(async () => {}), + ), + ).toBe(false); }); - it('creates a tray with localized tooltip and three custom items', async () => { - const ok = await installDesktopTray((k) => `T:${k}`, vi.fn()); + // QNBS-v3 (#332): covers the tray's flush-aware Quit item and its custom (not predefined-OS) routing. + it('creates a tray with localized tooltip and four custom items, including Quit', async () => { + const ok = await installDesktopTray( + (k) => `T:${k}`, + vi.fn(), + vi.fn(async () => {}), + ); expect(ok).toBe(true); expect(h.trayOpts?.tooltip).toBe('T:desktop.tray.tooltip'); expect(h.itemCalls.map((c) => c.id)).toEqual([ 'tray-show', 'tray-settings', 'tray-command-palette', + 'tray-quit', ]); }); + it('routes the tray Quit item action to quitApp, not a predefined OS action', async () => { + const quitApp = vi.fn(async () => {}); + await installDesktopTray((k) => k, vi.fn(), quitApp); + const byId = Object.fromEntries(h.itemCalls.map((c) => [c.id, c])); + byId['tray-quit']?.action?.(); + expect(quitApp).toHaveBeenCalledTimes(1); + }); + it('relabels the existing tray on a re-call instead of recreating it', async () => { - expect(await installDesktopTray((k) => `A:${k}`, vi.fn())).toBe(true); + expect( + await installDesktopTray( + (k) => `A:${k}`, + vi.fn(), + vi.fn(async () => {}), + ), + ).toBe(true); expect(h.trayNew).toHaveBeenCalledTimes(1); // A second call (e.g. language change) relabels via setMenu/setTooltip — no second TrayIcon. - expect(await installDesktopTray((k) => `B:${k}`, vi.fn())).toBe(true); + expect( + await installDesktopTray( + (k) => `B:${k}`, + vi.fn(), + vi.fn(async () => {}), + ), + ).toBe(true); expect(h.trayNew).toHaveBeenCalledTimes(1); expect(h.setMenu).toHaveBeenCalledTimes(1); expect(h.setTooltip).toHaveBeenCalledWith('B:desktop.tray.tooltip'); @@ -107,18 +141,30 @@ describe('installDesktopTray', () => { it('rejects a concurrent second install (in-flight guard, no double tray)', async () => { const results = await Promise.all([ - installDesktopTray((k) => k, vi.fn()), - installDesktopTray((k) => k, vi.fn()), + installDesktopTray( + (k) => k, + vi.fn(), + vi.fn(async () => {}), + ), + installDesktopTray( + (k) => k, + vi.fn(), + vi.fn(async () => {}), + ), ]); // Exactly one call wins; the other is rejected by the in-flight guard before it creates anything. expect(results.filter(Boolean)).toHaveLength(1); - // Only one tray's worth of items was created (3, not 6) — no duplicate creation. - expect(h.itemCalls).toHaveLength(3); + // Only one tray's worth of items was created (4, not 8) — no duplicate creation. + expect(h.itemCalls).toHaveLength(4); }); it('left-click focuses the window; command items route to executeCommand', async () => { const run = vi.fn(); - await installDesktopTray((k) => k, run); + await installDesktopTray( + (k) => k, + run, + vi.fn(async () => {}), + ); h.trayOpts?.action?.({ type: 'Click', button: 'Left' }); expect(h.setVisible).toHaveBeenCalledWith(true); const byId = Object.fromEntries(h.itemCalls.map((c) => [c.id, c])); @@ -138,22 +184,59 @@ describe('installCloseToTray', () => { it('returns null on the web', async () => { h.isTauri.value = false; - expect(await installCloseToTray(() => true)).toBeNull(); + expect(await installCloseToTray(() => true, vi.fn())).toBeNull(); }); - it('hides + prevents close when minimizeToTray is on', async () => { - await installCloseToTray(() => true); + it('hides + prevents close when minimizeToTray is on, without flushing', async () => { + const flush = vi.fn(async () => {}); + await installCloseToTray(() => true, flush); const preventDefault = vi.fn(); - h.closeCb?.({ preventDefault }); + await h.closeCb?.({ preventDefault }); expect(preventDefault).toHaveBeenCalled(); expect(h.hide).toHaveBeenCalled(); + expect(flush).not.toHaveBeenCalled(); }); it('lets the window close when minimizeToTray is off', async () => { - await installCloseToTray(() => false); + const flush = vi.fn(async () => {}); + await installCloseToTray(() => false, flush); const preventDefault = vi.fn(); - h.closeCb?.({ preventDefault }); + await h.closeCb?.({ preventDefault }); expect(preventDefault).not.toHaveBeenCalled(); expect(h.hide).not.toHaveBeenCalled(); }); + + it('keeps the window open (fail closed) when the flush rejects, instead of quitting anyway', async () => { + const flush = vi.fn(async () => { + throw new Error('write failed'); + }); + await installCloseToTray(() => false, flush); + const preventDefault = vi.fn(); + await h.closeCb?.({ preventDefault }); + expect(preventDefault).toHaveBeenCalled(); + }); + + it('awaits the flush callback before letting the window close (QNBS-v3 #332/D3)', async () => { + let resolveFlush: (() => void) | undefined; + const flush = vi.fn( + () => + new Promise((resolve) => { + resolveFlush = resolve; + }), + ); + await installCloseToTray(() => false, flush); + const preventDefault = vi.fn(); + const closePromise = h.closeCb?.({ preventDefault }) as Promise | undefined; + expect(flush).toHaveBeenCalledTimes(1); + // The handler is still pending until the flush resolves. + let settled = false; + void closePromise?.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + resolveFlush?.(); + await closePromise; + expect(settled).toBe(true); + }); }); diff --git a/tests/unit/hooks/useSettingsView.test.ts b/tests/unit/hooks/useSettingsView.test.ts index 2c129c7f..deca826a 100644 --- a/tests/unit/hooks/useSettingsView.test.ts +++ b/tests/unit/hooks/useSettingsView.test.ts @@ -8,10 +8,17 @@ import type { ProjectSnapshot, StorySection } from '../../../types'; // --------------------------------------------------------------------------- // vi.hoisted — thunk match fns // --------------------------------------------------------------------------- -const { mockImportMatch, mockRestoreMatch } = vi.hoisted(() => ({ - mockImportMatch: vi.fn((_: unknown) => true), - mockRestoreMatch: vi.fn((_: unknown) => true), -})); +const { mockImportMatch, mockRestoreMatch, stableT, stableToast, stableEmptyArray } = vi.hoisted( + () => ({ + mockImportMatch: vi.fn((_: unknown) => true), + mockRestoreMatch: vi.fn((_: unknown) => true), + // QNBS-v3 (#332/D5): the real I18nContext memoizes `t` — a fresh arrow function here would defeat the useSettingsView useMemo identity test below. + stableT: (key: string) => key, + stableToast: { info: vi.fn(), success: vi.fn(), error: vi.fn() }, + // QNBS-v3 (#332/D5): real EntityAdapter selectAll() selectors return the same array reference when entities are unchanged — a fresh `[]` here would defeat the identity test for an unrelated reason. + stableEmptyArray: [] as unknown[], + }), +); // --------------------------------------------------------------------------- // Mocks @@ -23,8 +30,9 @@ const mockListSnapshots = vi.fn().mockResolvedValue([]); const mockSaveSnapshot = vi.fn().mockResolvedValue(undefined); const mockDeleteSnapshot = vi.fn().mockResolvedValue(undefined); const mockLoggerWarn = vi.fn(); -const mockToastInfo = vi.fn(); -const mockToastSuccess = vi.fn(); +// QNBS-v3 (#332/D5): aliased to stableToast's own methods (not fresh vi.fn()s) so the encryption tests below assert against the same stable mock useToast() actually returns. +const mockToastInfo = stableToast.info; +const mockToastSuccess = stableToast.success; const mockClearIdbEncryptionKey = vi.fn(); const mockIsIdbEncryptionReady = vi.fn(() => false); const mockSetupIdbEncryption = vi.fn().mockResolvedValue(undefined); @@ -68,15 +76,15 @@ vi.mock('../../../app/hooks', () => ({ vi.mock('../../../hooks/useTranslation', () => ({ useTranslation: () => ({ - t: (key: string) => key, + t: stableT, language: 'en', setLanguage: mockSetLanguage, }), })); vi.mock('../../../features/project/projectSelectors', () => ({ - selectAllCharacters: () => [], - selectAllWorlds: () => [], + selectAllCharacters: () => stableEmptyArray, + selectAllWorlds: () => stableEmptyArray, })); vi.mock('../../../features/project/projectSlice', () => ({ @@ -181,11 +189,8 @@ vi.mock('../../../features/status/statusSlice', () => ({ })); vi.mock('../../../components/ui/Toast', () => ({ - useToast: () => ({ - info: mockToastInfo, - success: mockToastSuccess, - error: vi.fn(), - }), + // QNBS-v3 (#332/D5): a stable reference here isolates the identity test below to useSettingsView's own dependency wiring — production useToast() is now separately memoized and covered by tests/unit/Toast.test.tsx's own reference-stability test. + useToast: () => stableToast, })); vi.mock('../../../services/logger', () => ({ @@ -749,3 +754,22 @@ describe('handleLockSession', () => { expect(mockToastInfo).toHaveBeenCalledWith('settings.privacy.encryptionLockedStatus'); }); }); + +// QNBS-v3 (#332/D5): the returned object's identity must stay stable across a re-render with unchanged inputs, or every Settings-tree consumer re-renders on any unrelated global-state change. +describe('memoized return value', () => { + it('keeps the same object reference across a re-render with unchanged inputs', () => { + const { result, rerender } = renderHook(() => useSettingsView()); + const first = result.current; + rerender(); + expect(result.current).toBe(first); + }); + + it('returns a new reference (with updated data) once a real dependency changes', () => { + const { result, rerender } = renderHook(() => useSettingsView()); + const first = result.current; + mockProject = { ...mockProject, title: 'Renamed Novel' }; + rerender(); + expect(result.current).not.toBe(first); + expect(result.current.project.title).toBe('Renamed Novel'); + }); +}); diff --git a/tests/unit/persistedStateFlush.test.ts b/tests/unit/persistedStateFlush.test.ts new file mode 100644 index 00000000..88158c8d --- /dev/null +++ b/tests/unit/persistedStateFlush.test.ts @@ -0,0 +1,84 @@ +/** + * Tests for app/persistedStateFlush.ts + * QNBS-v3 (#332/D3): shared flush helper used by both index.tsx's visibilitychange handler and the + * desktop close-to-tray quit flush — verifies it saves project+settings via storageService, always + * saves settings even with no project data yet (fresh/new-user state), and fails closed on any + * rejected save (Promise.all, not Promise.allSettled) so a failed write is never silently ignored. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RootState } from '../../app/store'; + +const h = vi.hoisted(() => ({ + saveProject: vi.fn(async (_envelope: { envelope: Record }) => {}), + saveSettings: vi.fn(async (_settings: unknown) => {}), +})); + +vi.mock('../../services/storageService', () => ({ + storageService: { saveProject: h.saveProject, saveSettings: h.saveSettings }, + saveEnvelopeFromProjectData: (data: unknown) => ({ envelope: data }), +})); + +import { flushPersistedState } from '../../app/persistedStateFlush'; + +function buildState(overrides: Partial = {}): RootState { + return { + project: { + present: { + data: { + id: 'proj-1', + title: 'My Project', + }, + }, + }, + versionControl: { + branches: [{ id: 'main' }], + snapshots: [], + currentBranchId: 'main', + }, + settings: { theme: 'dark' }, + ...overrides, + } as unknown as RootState; +} + +describe('flushPersistedState', () => { + beforeEach(() => { + h.saveProject.mockClear(); + h.saveSettings.mockClear(); + }); + + it('saves project (enriched with persistedVersionControl) and settings', async () => { + const state = buildState(); + await flushPersistedState(state); + + expect(h.saveProject).toHaveBeenCalledTimes(1); + const [savedArg] = h.saveProject.mock.calls[0] ?? []; + expect(savedArg?.envelope['id']).toBe('proj-1'); + expect(savedArg?.envelope['persistedVersionControl']).toEqual({ + branches: [{ id: 'main' }], + snapshots: [], + currentBranchId: 'main', + }); + + expect(h.saveSettings).toHaveBeenCalledWith(state.settings); + }); + + it('still saves settings when there is no project data yet (fresh/new-user state)', async () => { + const state = buildState({ + project: { present: { data: undefined } } as unknown as RootState['project'], + }); + await flushPersistedState(state); + expect(h.saveProject).not.toHaveBeenCalled(); + expect(h.saveSettings).toHaveBeenCalledWith(state.settings); + }); + + it('propagates a rejection when saveProject fails (fail-closed, not swallowed)', async () => { + h.saveProject.mockRejectedValueOnce(new Error('disk full')); + await expect(flushPersistedState(buildState())).rejects.toThrow('disk full'); + }); + + it('propagates a rejection when saveSettings fails (fail-closed, not swallowed)', async () => { + h.saveSettings.mockRejectedValueOnce(new Error('disk full')); + await expect(flushPersistedState(buildState())).rejects.toThrow('disk full'); + }); +}); diff --git a/tests/unit/services/appBootstrap.test.ts b/tests/unit/services/appBootstrap.test.ts new file mode 100644 index 00000000..539ab4bd --- /dev/null +++ b/tests/unit/services/appBootstrap.test.ts @@ -0,0 +1,124 @@ +/** + * Tests for services/appBootstrap.ts + * QNBS-v3 (#332/D1): the desktop build never read persisted state back at cold boot — + * `index.tsx`'s old inline hydration called the raw IndexedDB-only `dbService.loadState()` + * unconditionally, with zero Tauri branching, even though every save path already routed through + * the Tauri-aware `storageService`. Verifies the fixed branch: web uses `dbService.loadState()`, + * desktop uses `storageService.loadSettings()`/`listProjects()`/`loadProject()` and never touches + * `dbService`. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + isTauri: { value: false }, + dbLoadState: vi.fn(), + loadSettings: vi.fn(), + listProjects: vi.fn(), + loadProject: vi.fn(), + getActiveProjectId: vi.fn(), +})); + +vi.mock('../../../services/tauriRuntime', () => ({ + isTauriRuntime: () => h.isTauri.value, +})); + +vi.mock('../../../services/dbService', () => ({ + dbService: { loadState: h.dbLoadState }, +})); + +vi.mock('../../../services/storageService', () => ({ + storageService: { + loadSettings: h.loadSettings, + listProjects: h.listProjects, + loadProject: h.loadProject, + getActiveProjectId: h.getActiveProjectId, + }, +})); + +import { loadPersistedRootState } from '../../../services/appBootstrap'; + +describe('loadPersistedRootState', () => { + beforeEach(() => { + vi.clearAllMocks(); + h.isTauri.value = false; + h.dbLoadState.mockResolvedValue(undefined); + h.loadSettings.mockResolvedValue(null); + h.listProjects.mockResolvedValue([]); + h.loadProject.mockResolvedValue(null); + h.getActiveProjectId.mockResolvedValue(null); + }); + + it('on the web, reads via dbService.loadState() and never touches storageService', async () => { + h.dbLoadState.mockResolvedValue({ settings: { theme: 'dark' } }); + const result = await loadPersistedRootState(); + expect(result).toEqual({ settings: { theme: 'dark' } }); + expect(h.dbLoadState).toHaveBeenCalledTimes(1); + expect(h.loadSettings).not.toHaveBeenCalled(); + expect(h.listProjects).not.toHaveBeenCalled(); + expect(h.loadProject).not.toHaveBeenCalled(); + }); + + it('on desktop, reads via storageService and never touches dbService.loadState()', async () => { + h.isTauri.value = true; + h.loadSettings.mockResolvedValue({ theme: 'sepia' }); + h.listProjects.mockResolvedValue(['proj-1']); + h.loadProject.mockResolvedValue({ id: 'proj-1', title: 'My Novel' }); + + const result = await loadPersistedRootState(); + + expect(h.dbLoadState).not.toHaveBeenCalled(); + expect(h.loadSettings).toHaveBeenCalledTimes(1); + expect(h.listProjects).toHaveBeenCalledTimes(1); + expect(h.loadProject).toHaveBeenCalledWith('proj-1'); + expect(result?.settings).toEqual({ theme: 'sepia' }); + // Flat shape — index.tsx's existing hydration logic reconstructs the redux-undo envelope. + expect(result?.project).toEqual({ data: { id: 'proj-1', title: 'My Novel' } }); + }); + + it('on desktop with no persisted settings or projects, returns undefined (fresh user)', async () => { + h.isTauri.value = true; + const result = await loadPersistedRootState(); + expect(result).toBeUndefined(); + expect(h.loadProject).not.toHaveBeenCalled(); + }); + + it('on desktop with settings but no projects, returns settings only', async () => { + h.isTauri.value = true; + h.loadSettings.mockResolvedValue({ theme: 'light' }); + const result = await loadPersistedRootState(); + expect(result).toEqual({ settings: { theme: 'light' } }); + expect(result?.project).toBeUndefined(); + }); + + // QNBS-v3 (#332): covers desktop boot's active-project restoration — marker preferred, deleted-project marker and no-marker both fall back to the first listed project id. + it('on desktop with no active-project marker, falls back to the first listed project id', async () => { + h.isTauri.value = true; + h.listProjects.mockResolvedValue(['proj-1', 'proj-2']); + h.getActiveProjectId.mockResolvedValue(null); + h.loadProject.mockResolvedValue({ id: 'proj-1', title: 'First' }); + await loadPersistedRootState(); + expect(h.loadProject).toHaveBeenCalledTimes(1); + expect(h.loadProject).toHaveBeenCalledWith('proj-1'); + }); + + it('on desktop, prefers the active-project marker over the first listed project id', async () => { + h.isTauri.value = true; + h.listProjects.mockResolvedValue(['proj-1', 'proj-2']); + h.getActiveProjectId.mockResolvedValue('proj-2'); + h.loadProject.mockResolvedValue({ id: 'proj-2', title: 'Second' }); + await loadPersistedRootState(); + expect(h.loadProject).toHaveBeenCalledTimes(1); + expect(h.loadProject).toHaveBeenCalledWith('proj-2'); + }); + + it('on desktop, falls back to the first listed project id when the marker points to a deleted project', async () => { + h.isTauri.value = true; + h.listProjects.mockResolvedValue(['proj-1', 'proj-2']); + h.getActiveProjectId.mockResolvedValue('proj-deleted'); + h.loadProject.mockResolvedValue({ id: 'proj-1', title: 'First' }); + await loadPersistedRootState(); + expect(h.loadProject).toHaveBeenCalledTimes(1); + expect(h.loadProject).toHaveBeenCalledWith('proj-1'); + }); +}); diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index 11840ba7..596511f1 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -36,9 +36,14 @@ vi.mock('@tauri-apps/api/path', () => ({ appDataDir: () => fsHolder.current.appDataDir(), join: (...parts: string[]) => fsHolder.current.join(...parts), })); +vi.mock('../../../../services/logger', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() } }; +}); import { appStoreRef } from '../../../../app/storeRef'; import { FsProjectStore } from '../../../../services/fs/projectFsStore'; +import { logger } from '../../../../services/logger'; interface FakeFs { apis: TauriApis; @@ -137,6 +142,34 @@ describe('FsProjectStore — projects', () => { expect(await store.loadProject('nope')).toBeNull(); expect(await store.listProjects()).toEqual([]); }); + + // QNBS-v3 (#332): saveProject records the active-project marker so cold boot doesn't pick an arbitrary readDir() entry. + it('records the saved project as the active-project marker, updating it on each subsequent save', async () => { + expect(await store.getActiveProjectId()).toBeNull(); + + await store.saveProject(project as never); + expect(await store.getActiveProjectId()).toBe('p1'); + + const secondProject = { ...project, id: 'p2', title: 'Second Novel' }; + await store.saveProject(secondProject as never); + expect(await store.getActiveProjectId()).toBe('p2'); + }); + + // QNBS-v3 (#332): a rejected marker write is a documented best-effort abort — it must not fail the project save that already succeeded. + it('still resolves saveProject and logs a warning when the active-project marker write rejects', async () => { + const originalWriteTextFile = fake.apis.writeTextFile; + fake.apis.writeTextFile = (p: string, c: string) => { + if (p.endsWith('active-project-id.txt')) return Promise.reject(new Error('disk full')); + return originalWriteTextFile(p, c); + }; + + await expect(store.saveProject(project as never)).resolves.toBeUndefined(); + expect(await store.loadProject('p1')).not.toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to persist active-project marker (project save itself succeeded)', + expect.objectContaining({ error: 'disk full' }), + ); + }); }); describe('FsSettingsStore — settings + encrypted API keys', () => { diff --git a/tests/unit/services/storage/idbProjectStore.test.ts b/tests/unit/services/storage/idbProjectStore.test.ts index 22a63f90..2d575f27 100644 --- a/tests/unit/services/storage/idbProjectStore.test.ts +++ b/tests/unit/services/storage/idbProjectStore.test.ts @@ -11,12 +11,25 @@ describe('idbProjectStore', () => { it('applies defaults for missing top-level fields', () => { const result = normalizePersistedSettings({}); expect(result.theme).toBe('dark'); - expect(result.appearancePreset).toBe('default'); + // QNBS-v3 (#332): must match settingsSlice.ts's initialState.appearancePreset ('sepia' since + // v1.21) — a prior mismatch here made a genuinely first-ever launch disagree with every + // other launch about what "no persisted preference" means. + expect(result.appearancePreset).toBe('sepia'); expect(result.writingSurfaceStyle).toBe('textured'); expect(result.editorFont).toBe('serif'); expect(result.fontSize).toBe(16); }); + it('migrates a legacy/invalid appearancePreset value to the current default', () => { + const result = normalizePersistedSettings({ appearancePreset: 'fantasy' }); + expect(result.appearancePreset).toBe('sepia'); + }); + + it('preserves an explicitly persisted appearancePreset over the default', () => { + const result = normalizePersistedSettings({ appearancePreset: 'default' }); + expect(result.appearancePreset).toBe('default'); + }); + it('preserves provided values over defaults', () => { const result = normalizePersistedSettings({ theme: 'light', diff --git a/tests/unit/settings/AccessibilitySection.test.tsx b/tests/unit/settings/AccessibilitySection.test.tsx index 8a5136de..f3233532 100644 --- a/tests/unit/settings/AccessibilitySection.test.tsx +++ b/tests/unit/settings/AccessibilitySection.test.tsx @@ -22,6 +22,7 @@ const makeSettings = (overrides = {}) => ({ presetId: 'custom' as const, highContrast: false, reducedMotion: false, + reducedTransparency: false, largeText: false, screenReader: false, liveRegionVerbosity: 'normal' as const, @@ -61,6 +62,7 @@ vi.mock('../../../features/settings/accessibilitySchema', () => ({ presetId: id, highContrast: id === 'lowVision', reducedMotion: id === 'motor', + reducedTransparency: false, largeText: id === 'lowVision', screenReader: id === 'screenReader', liveRegionVerbosity: 'normal' as const, @@ -71,6 +73,7 @@ vi.mock('../../../features/settings/accessibilitySchema', () => ({ presetId: 'custom' as const, highContrast: false, reducedMotion: false, + reducedTransparency: false, largeText: false, screenReader: false, focusIndicators: true, @@ -177,6 +180,24 @@ describe('AccessibilitySection', () => { expect(screen.getByText('settings.accessibility.reducedMotion')).toBeInTheDocument(); }); + // QNBS-v3 (#332/D4) + it('renders reduced transparency toggle', () => { + render(); + expect(screen.getByText('settings.accessibility.reducedTransparency')).toBeInTheDocument(); + }); + + it('calls handleSettingChange when reduced transparency is toggled', async () => { + const user = userEvent.setup(); + render(); + await user.click( + screen.getByRole('switch', { name: 'settings.accessibility.reducedTransparency' }), + ); + expect(mockHandleSettingChange).toHaveBeenCalledWith( + 'accessibility', + expect.objectContaining({ reducedTransparency: true }), + ); + }); + it('renders the preview section', () => { render(); expect(screen.getByText('settings.accessibility.hub.preview.sampleButton')).toBeInTheDocument(); diff --git a/tests/unit/storageService.test.ts b/tests/unit/storageService.test.ts index 54fa7116..5300b49c 100644 --- a/tests/unit/storageService.test.ts +++ b/tests/unit/storageService.test.ts @@ -84,6 +84,11 @@ describe('storageService (IndexedDB backend in browser)', () => { expect(mockDb.deleteProject).toHaveBeenCalledWith('p1'); }); + // QNBS-v3 (#332): getActiveProjectId is optional on StorageBackend — dbService/IndexedDB has no multi-project ambiguity to resolve, so this must not throw when the backend doesn't implement it. + it('returns null for getActiveProjectId when the backend does not implement it', async () => { + expect(await storageService.getActiveProjectId()).toBeNull(); + }); + it('delegates saveSettings / loadSettings to dbService', async () => { await storageService.saveSettings({} as never); expect(mockDb.saveSettings).toHaveBeenCalled(); diff --git a/types.ts b/types.ts index e76e2359..68232121 100644 --- a/types.ts +++ b/types.ts @@ -512,6 +512,8 @@ export type LiveRegionVerbosity = 'minimal' | 'normal' | 'verbose'; export interface AccessibilitySettings { highContrast: boolean; reducedMotion: boolean; + /** Manual opt-in to strip backdrop-blur/glass translucency (GPU cost relief valve, #332/D4). */ + reducedTransparency: boolean; largeText: boolean; screenReader: boolean; focusIndicators: boolean; diff --git a/types/tauri-plugins.d.ts b/types/tauri-plugins.d.ts index f1073156..fc887780 100644 --- a/types/tauri-plugins.d.ts +++ b/types/tauri-plugins.d.ts @@ -10,6 +10,8 @@ declare module '@tauri-apps/plugin-updater' { declare module '@tauri-apps/plugin-process' { export function relaunch(): Promise; + // QNBS-v3 (#332): typed exit() so App.tsx's quitApp can flush persisted state before a coordinated desktop shutdown. + export function exit(code?: number): Promise; } declare module '@tauri-apps/plugin-shell' {