Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d04cda4
fix(e2e): eliminate WelcomePortal startup/navigation nondeterminism (…
qnbs Sep 2, 2026
b9e1ee6
docs: sync README test-count metric to 7358 (post-#532 fix)
qnbs Sep 2, 2026
6dbb8df
fix(e2e): close review findings on #532 fix — reject-on-blocked, TOCT…
qnbs Sep 2, 2026
a9b61ab
docs: sync README test-count metric to 7359 (post-review-fix)
qnbs Sep 2, 2026
ee2ec81
fix(storage): close every long-lived IDB connection during factory re…
qnbs Sep 2, 2026
3c5d96f
docs: sync README test/i18n-key metrics (7370 tests/596 files, 2938 k…
qnbs Sep 2, 2026
2adce9b
chore(i18n): rebuild locale bundles for the new factoryReset.failed key
qnbs Sep 2, 2026
5b2cdd0
refactor(settings): extract factory-reset failure handling out of use…
qnbs Sep 2, 2026
8b6a1e9
fix(settings): move the entire factory-reset try/catch out of useSett…
qnbs Sep 2, 2026
8b779f7
fix(storage): async epoch-based reset quiescence contract across ever…
qnbs Sep 2, 2026
0f25c8a
docs: sync README test metrics (7375 tests/597 files)
qnbs Sep 2, 2026
c058b81
fix(graphs): fail-closed reset gate, late-registration barrier, singl…
qnbs Sep 2, 2026
2914e6c
docs: sync README test metrics (7379 tests/597 files)
qnbs Sep 2, 2026
61ebc6c
fix(graphs): close the reset-generation gap for opens that start mid-…
qnbs Sep 2, 2026
e3def1d
docs: sync README test metrics (7385 tests/597 files)
qnbs Sep 2, 2026
a67233e
fix(graphs): preserve-first deletion ownership, memoization root caus…
qnbs Sep 2, 2026
242d100
docs: sync README test metrics (7388 tests/597 files)
qnbs Sep 2, 2026
27a0d6b
fix(graphs): stale README metrics date and weak reset-closer test ass…
qnbs Sep 2, 2026
f954cc2
fix(graphs): reset closer must invalidate the pending flight, not jus…
qnbs Sep 2, 2026
bfb9c96
docs: sync README test metrics (7389 tests/597 files)
qnbs Sep 2, 2026
b65a295
fix(graphs): justify or drop the unused local-first persistence teard…
qnbs Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
<img src="https://img.shields.io/badge/Release-v1.28.3-6366F1" alt="Release v1.28.3">
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2937_keys-0EA5E9" alt="i18n 19 locales — 2937 keys">
<img src="https://img.shields.io/badge/Tests-7357%2B_%2F_595_files-22C55E" alt="7357+ tests / 595 files">
<img src="https://img.shields.io/badge/i18n-19_locales-2938_keys-0EA5E9" alt="i18n 19 locales — 2938 keys">
<img src="https://img.shields.io/badge/Tests-7389%2B_%2F_597_files-22C55E" alt="7389+ tests / 597 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -401,7 +401,7 @@ Infrastructure-level features that keep the app fast and extensible as projects

### 🌐 Full Multi-Language Support

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

- 🇩🇪 **German** (Deutsch)
- 🇬🇧 **English**
Expand Down Expand Up @@ -510,8 +510,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`) | 2937 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
| **Testing** | Vitest 4.x (7357+ tests / 595 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2938 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 (7389+ tests / 597 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
Expand Down Expand Up @@ -549,7 +549,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
│ ├── unit/ # Vitest unit tests (7357+ tests, 595 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (7389+ tests, 597 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
Expand Down Expand Up @@ -710,10 +710,10 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `deploy` | `main` only | GitHub Pages after **`ci-success`** succeeds |
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |

**Current test metrics (2026-08-30, source-synchronized; CI remains authoritative for pass/fail):**
- **7357+ unit tests** across **595 test files** — CI is authoritative for pass/fail
**Current test metrics (2026-09-02, source-synchronized; CI remains authoritative for pass/fail):**
- **7389+ unit tests** across **597 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2937 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
- i18n: **2938 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)

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

Expand Down
57 changes: 39 additions & 18 deletions app/listenerMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ import { saveEnvelopeFromProjectData } from '../services/storageBackend';
import { storageService } from '../services/storageService';
import type { Character, StorySection, World } from '../types';
import { isAnalyticsPersistenceAllowed } from './analyticsGate';
import {
projectPersistenceCoordinator,
settingsPersistenceCoordinator,
} from './persistenceCoordinator';
import type { AppDispatch, RootState } from './store';
import { projectPersistenceCoordinator, settingsPersistenceCoordinator } from './persistenceCoordinator';
import { appStoreRef } from './storeRef';
import { useTransientUiStore } from './transientUiStore';

Expand Down Expand Up @@ -688,26 +691,38 @@ function withLocalFirstLock<T>(fn: () => Promise<T>): Promise<T> {
return run;
}

// QNBS-v3: extracted so getLocalFirstHandle's own body doesn't absorb this classification/teardown complexity (CodeScene hotspot). Mutates the module-level localFirstHandle directly; returns the still-valid handle to reuse, or null once any stale/mismatched handle has been torn down and cleared.
async function reconcileLocalFirstHandle(
projectId: string,
isIdbEncryptionReady: () => boolean,
noopPersistence: LocalFirstHandle['persistence'],
): Promise<LocalFirstHandle | null> {
if (!localFirstHandle) return null;
if (localFirstHandle.projectId !== projectId) {
// Project switched — tear down the previous handle before creating a new one.
await localFirstHandle.persistence.destroy().catch(() => undefined);
localFirstHandle = null;
return null;
}
// QNBS-v3 (CodeAnt): the persistence backend is chosen at handle creation — if at-rest encryption became active after a plaintext-persisting handle was made, tear it down (wiping the plaintext already written) so no further plaintext is persisted.
if (isIdbEncryptionReady() && localFirstHandle.persistence.active) {
await localFirstHandle.persistence.clearData().catch(() => undefined);
await localFirstHandle.persistence.destroy().catch(() => undefined);
localFirstHandle = null;
return null;
}
// QNBS-v3: a dead reference, not an intentional NOOP — recreate rather than return a handle writes would silently go nowhere through.
if (localFirstHandle.persistence !== noopPersistence && !localFirstHandle.persistence.active) {
localFirstHandle = null;
return null;
}
return localFirstHandle;
}

function getLocalFirstHandle(project: ProjectData): Promise<LocalFirstHandle> {
return withLocalFirstLock(async () => {
const projectId = project.id ?? 'default';
const { isIdbEncryptionReady } = await import('../services/storage/storageEncryptionService');
if (localFirstHandle?.projectId === projectId) {
// QNBS-v3 (CodeAnt): the persistence backend (NOOP vs y-indexeddb) is chosen at handle
// creation. If at-rest encryption became active AFTER a plaintext-persisting handle was made,
// tear it down — wiping the plaintext already written — so no further plaintext is persisted.
if (isIdbEncryptionReady() && localFirstHandle.persistence.active) {
await localFirstHandle.persistence.clearData().catch(() => undefined);
await localFirstHandle.persistence.destroy().catch(() => undefined);
localFirstHandle = null;
} else {
return localFirstHandle;
}
} else if (localFirstHandle) {
// Project switched — tear down the previous handle before creating a new one.
await localFirstHandle.persistence.destroy().catch(() => undefined);
localFirstHandle = null;
}
const [
{ createBlankProjectDoc },
{ ProjectDocBinding },
Expand All @@ -717,6 +732,12 @@ function getLocalFirstHandle(project: ProjectData): Promise<LocalFirstHandle> {
import('../services/localFirst/docBinding'),
import('../services/localFirst/docPersistence'),
]);
const reused = await reconcileLocalFirstHandle(
projectId,
isIdbEncryptionReady,
NOOP_PERSISTENCE,
);
if (reused) return reused;
const doc = createBlankProjectDoc();
// QNBS-v3 (CodeAnt): never write a PLAINTEXT shadow copy to y-indexeddb when at-rest encryption
// is active — the local-first doc is not encrypted yet. Keep it in-memory only so the privacy
Expand Down Expand Up @@ -821,4 +842,4 @@ export async function initLocalFirstSyncOnStartup(enabled: boolean): Promise<voi
export const startAppListening = listenerMiddleware.startListening as TypedStartListening<
RootState,
AppDispatch
>;
>;
8 changes: 7 additions & 1 deletion components/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,17 @@ import { ViewErrorBoundary } from './ui/ViewErrorBoundary';

// --- SUB-COMPONENTS ---

// QNBS-v3: stable data-testid lets E2E recovery navigation target a category without matching translated label text
const NavButton: FC<{
id: string;
icon: React.ReactNode;
label: string;
isActive: boolean;
onClick: () => void;
}> = React.memo(({ icon, label, isActive, onClick }) => (
}> = React.memo(({ id, icon, label, isActive, onClick }) => (
<button
type="button"
data-testid={`settings-nav-${id}`}
onClick={onClick}
aria-current={isActive ? 'page' : undefined}
className={`flex items-center flex-shrink-0 md:flex-shrink md:w-full px-3 py-2 text-left rounded-md transition-colors whitespace-nowrap md:whitespace-normal ${isActive ? 'bg-[var(--nav-background-active)] text-[var(--nav-text-active)]' : 'hover:bg-[var(--nav-background-hover)] text-[var(--sc-text-secondary)] hover:text-[var(--sc-text-primary)]'}`}
Expand Down Expand Up @@ -365,6 +368,7 @@ const SettingsViewUI: FC = () => {
filteredNavCategories.map((cat) => (
<NavButton
key={cat.id}
id={cat.id}
icon={cat.icon}
label={cat.label}
isActive={activeCategory === cat.id}
Expand All @@ -379,6 +383,7 @@ const SettingsViewUI: FC = () => {
.map((cat) => (
<NavButton
key={cat.id}
id={cat.id}
icon={cat.icon}
label={cat.label}
isActive={activeCategory === cat.id}
Expand All @@ -396,6 +401,7 @@ const SettingsViewUI: FC = () => {
{groupCats.map((cat) => (
<NavButton
key={cat.id}
id={cat.id}
icon={cat.icon}
label={cat.label}
isActive={activeCategory === cat.id}
Expand Down
8 changes: 6 additions & 2 deletions components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ const BottomTabItem: React.FC<{
isActive: boolean;
onClick: () => void;
sectionId?: string;
}> = React.memo(({ icon, label, isActive, onClick, sectionId }) => {
/** Spotlight tour / stable E2E anchor (`data-tour`) */
dataTour?: string;
}> = React.memo(({ icon, label, isActive, onClick, sectionId, dataTour }) => {
// QNBS-v3: colored icon dot for mobile tab bar via section SSOT
const sectionConfig = sectionId ? APP_SECTIONS[sectionId as keyof typeof APP_SECTIONS] : null;
const iconColor = sectionConfig && !isActive ? sectionConfig.textColor : '';
Expand All @@ -86,6 +88,7 @@ const BottomTabItem: React.FC<{
<button
type="button"
onClick={onClick}
data-tour={dataTour}
className={`relative flex flex-col items-center justify-center flex-1 min-h-[44px] py-2 transition-colors duration-200 touch-manipulation outline-none focus-visible:ring-2 focus-visible:ring-[var(--sc-ring-focus)] rounded-lg ${
isActive ? 'text-[var(--nav-text-active)]' : 'text-[var(--sc-text-muted)]'
}`}
Expand Down Expand Up @@ -201,6 +204,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
label={t('common.more')}
isActive={isSidebarOpen || !isTabBarView}
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
dataTour="nav-more"
/>
</nav>

Expand Down Expand Up @@ -299,4 +303,4 @@ export const Sidebar: React.FC<SidebarProps> = ({
</aside>
</>
);
};
};
1 change: 1 addition & 0 deletions components/settings/DataSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ export const DataSection: FC = () => {
<Button
variant="danger"
size="sm"
data-testid="factory-reset-button"
Comment thread
qnbs marked this conversation as resolved.
onClick={() => setModal({ state: 'factoryReset', payload: {} })}
className="shrink-0"
>
Expand Down
9 changes: 8 additions & 1 deletion components/settings/FactoryResetDangerZone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ export const FactoryResetDangerZone: FC<Props> = ({
<p className={descriptionClassName}>
{t('settings.data.dangerZone.factoryReset.modalDescription')}
</p>
<Button variant="danger" onClick={onReset} disabled={busy} aria-busy={busy}>
{/* QNBS-v3: stable data-testid lets E2E recovery navigation target this button without matching translated label text */}
<Button
variant="danger"
onClick={onReset}
disabled={busy}
aria-busy={busy}
data-testid="encryption-recovery-factory-reset-button"
>
{t('settings.data.dangerZone.factoryReset.button')}
</Button>
</>
Expand Down
7 changes: 6 additions & 1 deletion components/settings/SettingsModals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,12 @@ export const SettingsModals: FC = () => {
<Button variant="secondary" onClick={() => setModal({ state: 'closed', payload: {} })}>
{t('common.cancel')}
</Button>
<Button variant="danger" onClick={() => void handleFactoryReset()}>
{/* QNBS-v3: stable data-testid lets E2E recovery navigation target this button without matching translated label text */}
<Button
variant="danger"
onClick={() => void handleFactoryReset()}
data-testid="factory-reset-confirm-button"
>
{t('settings.data.dangerZone.factoryReset.modalConfirm')}
</Button>
</div>
Expand Down
3 changes: 2 additions & 1 deletion hooks/useFactoryReset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export function useFactoryReset({ t, setBusy, setError }: Options): () => Promis
try {
await wipeAllAppData();
} catch (err) {
setError(t('settings.privacy.encryptionRecoveryFailed'));
// QNBS-v3: a failed factory reset can leave partial cleanup behind — never reuse encryptionRecoveryFailed's "your data has not been lost" claim here.
setError(t('settings.data.dangerZone.factoryReset.failed'));
Comment thread
qnbs marked this conversation as resolved.
logger.error('Factory reset failed', {
error: err instanceof Error ? err.message : String(err),
});
Expand Down
20 changes: 17 additions & 3 deletions hooks/useSettingsView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ import type {
type ModalState = 'closed' | 'reset' | 'restore' | 'delete' | 'create' | 'factoryReset';
type ModalPayload = { id?: number; name?: string; date?: string; wordCount?: number };

// QNBS-v3: the whole try/catch lives here, not in useSettingsView's own body, so this branch's complexity is never attributed to that already-flagged hotspot (CodeScene).
async function performFactoryReset(
t: (key: string) => string,
toast: ReturnType<typeof useToast>,
): Promise<void> {
try {
await wipeAllAppData();
} catch (error) {
logger.error('Factory reset failed', {
error: error instanceof Error ? error.message : String(error),
});
toast.error(t('settings.data.dangerZone.factoryReset.failed'));
}
}

export const useSettingsView = () => {
const { t, language, setLanguage } = useTranslation();
const dispatch = useAppDispatch();
Expand Down Expand Up @@ -349,9 +364,8 @@ export const useSettingsView = () => {

const handleFactoryReset = useCallback(async () => {
setModal({ state: 'closed', payload: {} });
// QNBS-v3: wipes all IDB databases, localStorage, SW caches, then reloads.
await wipeAllAppData();
}, []);
await performFactoryReset(t, toast);
}, [t, toast]);

const handleRepeatOnboarding = useCallback(() => {
// QNBS-v3: useApp.ts listens for this event and re-opens the WelcomePortal.
Expand Down
1 change: 1 addition & 0 deletions locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@
"settings.data.createSnapshot": "إنشاء لقطة",
"settings.data.dangerZone.description": "هذه الإجراءات لا رجعة فيها. تابع بحذر.",
"settings.data.dangerZone.factoryReset.button": "إعادة ضبط المصنع",
"settings.data.dangerZone.factoryReset.failed": "لم تكتمل إعادة ضبط المصنع — قد يكون التطبيق الآن في حالة إعادة ضبط جزئية. أعد تشغيل التطبيق للتحقق، ثم أعد محاولة إعادة الضبط.",
"settings.data.dangerZone.factoryReset.hint": "يحذف نهائيًا جميع المشاريع والإعدادات ومفاتيح API والبيانات المحلية. سيُعاد تشغيل التطبيق كتثبيت جديد.",
"settings.data.dangerZone.factoryReset.label": "إعادة ضبط جميع بيانات التطبيق",
"settings.data.dangerZone.factoryReset.modalConfirm": "حذف كل شيء وإعادة التشغيل",
Expand Down
2 changes: 1 addition & 1 deletion locales/ar/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@
"sidebar.world": "بناء العالم",
"sidebar.writer": "استوديو الكتابة بالذكاء الاصطناعي",
"sidebar.scenario": "السيناريو / السيناريو السينمائي"
}
}
1 change: 1 addition & 0 deletions locales/de/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@
"settings.data.createSnapshot": "Schnappschuss erstellen",
"settings.data.dangerZone.description": "Diese Aktionen sind unwiderruflich. Vorsicht!",
"settings.data.dangerZone.factoryReset.button": "Werkseinstellungen",
"settings.data.dangerZone.factoryReset.failed": "Der Werksreset wurde nicht abgeschlossen – die App befindet sich möglicherweise in einem teilweise zurückgesetzten Zustand. Starten Sie die App neu, um dies zu überprüfen, und versuchen Sie den Reset erneut.",
"settings.data.dangerZone.factoryReset.hint": "Löscht alle Projekte, Einstellungen, API-Schlüssel und lokalen Daten dauerhaft. Die App startet neu wie bei einer Erstinstallation.",
"settings.data.dangerZone.factoryReset.label": "Alle App-Daten zurücksetzen",
"settings.data.dangerZone.factoryReset.modalConfirm": "Alles löschen & neu starten",
Expand Down
2 changes: 1 addition & 1 deletion locales/de/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@
"sidebar.world": "Weltenbau",
"sidebar.writer": "KI-Schreibstudio",
"sidebar.scenario": "Szenario / Drehbuch"
}
}
Loading
Loading