Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ interface AppProps {

const App: FC<AppProps> = ({ isNewUser }) => {
const appState = useApp({ isNewUser });
const { currentView, handleNavigate, isPortalActive, isInitialLoad } = appState;
const { currentView, handleNavigate, isPortalActive, isInitialLoad, allowInitialMetadataSeed } =
appState;
const settings = useAppSelector((state) => state.settings);
const project = useAppSelector(selectProjectData);
const featureFlags = useAppSelector(selectFeatureFlags);
Expand Down Expand Up @@ -445,7 +446,14 @@ const App: FC<AppProps> = ({ isNewUser }) => {
}, [currentView, announce, t, isInitialLoad, isPortalActive]);

// QNBS-v3: gates on isInitialLoad too (not just isPortalActive) so a same-commit stale read can't auto-seed a project before the welcome portal shows.
useProjectBootstrapEffect({ project, isInitialLoad, isPortalActive, isI18nReady, t });
useProjectBootstrapEffect({
project,
allowInitialMetadataSeed,
isInitialLoad,
isPortalActive,
isI18nReady,
t,
});

// QNBS-v3: PR3 — auto-launch the product tour once for first-run installs, after the welcome
// portal closes and the nav has rendered. Returning users (or anyone who already finished/closed
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<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-7317%2B_%2F_594_files-22C55E" alt="7317+ tests / 594 files">
<img src="https://img.shields.io/badge/Tests-7322%2B_%2F_594_files-22C55E" alt="7322+ tests / 594 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 @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2937 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
| **Testing** | Vitest 4.x (7317+ tests / 594 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (7322+ tests / 594 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 (7317+ tests, 594 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (7322+ tests, 594 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 @@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |

**Current test metrics (2026-08-30, source-synchronized; CI remains authoritative for pass/fail):**
- **7317+ unit tests** across **594 test files** — CI is authoritative for pass/fail
- **7322+ unit tests** across **594 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)

Expand Down
7 changes: 4 additions & 3 deletions components/WelcomePortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ICONS } from '../constants';
import { projectActions } from '../features/project/projectSlice';
import { importProjectThunk } from '../features/project/thunks/projectManagementThunks';
import { statusActions } from '../features/status/statusSlice';
import type { PortalExitOptions } from '../hooks/useApp';
import { useTranslation } from '../hooks/useTranslation';
import { storageService } from '../services/storageService';
import type { View } from '../types';
Expand All @@ -13,7 +14,7 @@ import { CustomIcon } from './ui/Icon';
import { LanguageSelector } from './ui/LanguageSelector';

interface WelcomePortalProps {
onExit: (view?: View) => void;
onExit: (view?: View, options?: PortalExitOptions) => void;
}

type PortalView = 'main' | 'new_project' | 'open_project';
Expand Down Expand Up @@ -118,7 +119,7 @@ export const WelcomePortal: React.FC<WelcomePortalProps> = ({ onExit }) => {
title: t('settings.data.importSuccess'),
}),
);
onExit('manuscript');
onExit('manuscript', { allowInitialMetadataSeed: false });
} else {
dispatch(
statusActions.addNotification({
Expand Down Expand Up @@ -173,7 +174,7 @@ export const WelcomePortal: React.FC<WelcomePortalProps> = ({ onExit }) => {
title: t('settings.data.importSuccess'),
}),
);
onExit('manuscript');
onExit('manuscript', { allowInitialMetadataSeed: false });
} else {
dispatch(
statusActions.addNotification({
Expand Down
12 changes: 10 additions & 2 deletions hooks/useApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,18 @@ function readInitialView(): View {
return 'dashboard';
}

// QNBS-v3: portal exit context distinguishes imported content from a project created in this app.
export interface PortalExitOptions {
allowInitialMetadataSeed?: boolean;
}

export const useApp = ({ isNewUser }: { isNewUser: boolean }) => {
const [currentView, setCurrentView] = useState<View>(() => readInitialView());
// QNBS-v3: remember the view navigated away from, so view-aware Help can open to the matching
// category (once inside Help, currentView is 'help' and no longer tells us where the user was).
const previousViewRef = useRef<View>('dashboard');
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [allowInitialMetadataSeed, setAllowInitialMetadataSeed] = useState(isNewUser);
// QNBS-v3: initialize from isNewUser (already stable pre-mount) instead of a hardcoded false, so no transient first commit exposes a stale value to a sibling effect.
const [isPortalActive, setIsPortalActive] = useState(isNewUser);
const [isInitialLoad, setIsInitialLoad] = useState(true);
Expand Down Expand Up @@ -131,7 +137,8 @@ export const useApp = ({ isNewUser }: { isNewUser: boolean }) => {
}, [currentView]);

const handlePortalExit = useCallback(
(view?: View) => {
(view?: View, options?: PortalExitOptions) => {
if (options?.allowInitialMetadataSeed === false) setAllowInitialMetadataSeed(false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (view) {
switchView(view);
pushHash(view);
Expand All @@ -156,10 +163,11 @@ export const useApp = ({ isNewUser }: { isNewUser: boolean }) => {
isSidebarOpen,
isPortalActive,
isInitialLoad,
allowInitialMetadataSeed,
handlePortalExit,
handleNavigate,
setIsSidebarOpen,
};
};

export type UseAppReturnType = ReturnType<typeof useApp>;
export type UseAppReturnType = ReturnType<typeof useApp>;
25 changes: 20 additions & 5 deletions hooks/useProjectBootstrapEffect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useAppDispatch } from '../app/hooks';
import { projectActions } from '../features/project/projectSlice';
import type { ProjectMetaSlice, TranslateFn } from '../services/projectI18nRepair';
Expand All @@ -22,31 +22,46 @@ export function shouldRunProjectBootstrap({
}

export interface UseProjectBootstrapEffectParams extends ProjectBootstrapGateState {
// QNBS-v3: explicit portal intent controls one-time metadata seeding without persisting schema state.
allowInitialMetadataSeed: boolean;
t: TranslateFn;
}

/** Repairs raw-i18n-key project fields, or seeds a fresh blank project, once bootstrap has settled. */
export function useProjectBootstrapEffect({
project,
allowInitialMetadataSeed,
isInitialLoad,
isPortalActive,
isI18nReady,
t,
}: UseProjectBootstrapEffectParams): void {
const dispatch = useAppDispatch();
const hasCompletedFreshUserBootstrap = useRef(false);

useEffect(() => {
// QNBS-v3: narrows project directly (not via the predicate's own return type) so the redundant post-check codecov flagged as dead code isn't needed.
if (!project || !shouldRunProjectBootstrap({ project, isInitialLoad, isPortalActive, isI18nReady }))
if (
!project ||
!shouldRunProjectBootstrap({
project,
isInitialLoad,
isPortalActive,
isI18nReady,
})
)
return;

const repair = repairProjectI18nFields(project, t);
const repair = repairProjectI18nFields(project, t, {
seedInitialMetadata: allowInitialMetadataSeed && !hasCompletedFreshUserBootstrap.current,
});
Comment thread
qnbs marked this conversation as resolved.
if (allowInitialMetadataSeed) hasCompletedFreshUserBootstrap.current = true;
if (repair) {
if (repair.title !== undefined) dispatch(projectActions.updateTitle(repair.title));
if (repair.logline !== undefined) dispatch(projectActions.updateLogline(repair.logline));
if (repair.manuscript !== undefined)
dispatch(projectActions.setManuscript(repair.manuscript));
}
// QNBS-v3: no further branch here — repairProjectI18nFields already treats any blank title/logline/manuscript as needing repair, so it always returns non-null for a blank project; a separate resetProject dispatch for that same condition was unreachable dead code, removed rather than tested around.
}, [project, isInitialLoad, isPortalActive, isI18nReady, dispatch, t]);
// QNBS-v3: blank metadata is seeded only once for a fresh user; later empty strings remain user intent.
}, [project, allowInitialMetadataSeed, isInitialLoad, isPortalActive, isI18nReady, dispatch, t]);
}
10 changes: 7 additions & 3 deletions services/projectI18nRepair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,23 @@ export type ProjectI18nRepair = {
manuscript?: StorySection[];
};

/** Repair project fields that were saved as raw i18n keys during cold start. */
/** Repair raw i18n keys, optionally seeding metadata only for the first fresh-user bootstrap. */
export function repairProjectI18nFields(
project: ProjectMetaSlice,
t: TranslateFn,
{ seedInitialMetadata = false }: { seedInitialMetadata?: boolean } = {},
): ProjectI18nRepair | null {
const repair: ProjectI18nRepair = {};
let changed = false;

if (!project.title || isKnownPersistedTranslationKey(project.title)) {
if ((seedInitialMetadata && !project.title) || isKnownPersistedTranslationKey(project.title)) {
Comment thread
qnbs marked this conversation as resolved.
Outdated
repair.title = t('initialProject.title');
changed = true;
}
if (!project.logline || isKnownPersistedTranslationKey(project.logline)) {
if (
(seedInitialMetadata && !project.logline) ||
isKnownPersistedTranslationKey(project.logline)
) {
repair.logline = t('initialProject.logline');
Comment thread
qnbs marked this conversation as resolved.
Outdated
changed = true;
}
Expand Down
1 change: 0 additions & 1 deletion tests/e2e/onboarding-entry-precondition.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ test.describe('WelcomePortal entry precondition (CI-only)', () => {
// QNBS-v3: the helper's contract is locale-independent portal-reached, not English — assert the stable testid, not the translated button label.
await page.addInitScript(() => localStorage.setItem('worldscript-language', 'es'));
await page.goto('/');
await expect(page.getByTestId('welcome-portal')).toBeVisible();
await ensureWelcomePortalEntry(page);
await expect(page.getByTestId('welcome-portal')).toBeVisible();
});
Expand Down
26 changes: 24 additions & 2 deletions tests/unit/WelcomePortal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WelcomePortal } from '../../components/WelcomePortal';

Expand Down Expand Up @@ -49,7 +49,17 @@ vi.mock('../../features/status/statusSlice', () => ({
}));

vi.mock('../../features/project/thunks/projectManagementThunks', () => ({
importProjectThunk: vi.fn(() => async () => undefined),
importProjectThunk: Object.assign(
vi.fn(() => ({ type: 'project/importProject/pending' })),
{
fulfilled: {
match: (action: unknown) =>
typeof action === 'object' &&
action !== null &&
(action as { type?: string }).type === 'project/importProject/fulfilled',
},
},
),
}));

vi.mock('../../services/storageService', () => ({
Expand Down Expand Up @@ -116,4 +126,16 @@ describe('WelcomePortal', () => {
fireEvent.click(screen.getByRole('button', { name: 'portal.welcome.openProject' }));
expect(screen.getByText('portal.open.title')).toBeTruthy();
});

it('marks a welcome-portal import as ineligible for fresh metadata seeding', async () => {
mockDispatch.mockResolvedValue({ type: 'project/importProject/fulfilled' });
const onExit = vi.fn();
render(<WelcomePortal onExit={onExit} />);

fireEvent.click(screen.getByRole('button', { name: 'portal.welcome.tryDemo' }));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

await waitFor(() =>
expect(onExit).toHaveBeenCalledWith('manuscript', { allowInitialMetadataSeed: false }),
);
});
});
24 changes: 23 additions & 1 deletion tests/unit/projectI18nRepair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,34 @@ describe('repairProjectI18nFields', () => {
expect(repair?.manuscript?.[0]?.title).toBe('Chapter 1');
});

it('seeds manuscript when empty', () => {
it('preserves intentionally empty title and logline while still seeding an empty manuscript', () => {
const repair = repairProjectI18nFields({ title: '', logline: '', manuscript: [] }, t);
expect(repair?.title).toBeUndefined();
expect(repair?.logline).toBeUndefined();
expect(repair?.manuscript).toHaveLength(1);
});

it('seeds blank metadata only when fresh-user bootstrap explicitly authorizes it', () => {
const repair = repairProjectI18nFields({ title: '', logline: '', manuscript: [] }, t, {
seedInitialMetadata: true,
});
expect(repair?.title).toBe('My Untitled Story');
expect(repair?.logline).toBe('A journey...');
expect(repair?.manuscript).toHaveLength(1);
});

it('leaves intentionally empty metadata untouched when the project has real content', () => {
const repair = repairProjectI18nFields(
{
title: '',
logline: '',
manuscript: [{ id: '1', title: 'Chapter 1', content: 'Existing work' }],
},
t,
);
expect(repair).toBeNull();
});

it('returns null when project metadata is already human-readable', () => {
const repair = repairProjectI18nFields(
{
Expand Down
11 changes: 11 additions & 0 deletions tests/unit/useApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ describe('useApp', () => {
expect(result.current.isPortalActive).toBe(false);
});

it('disables fresh metadata seeding when the portal exits after an import', () => {
const { result } = renderHook(() => useApp({ isNewUser: true }));
expect(result.current.allowInitialMetadataSeed).toBe(true);

act(() => {
result.current.handlePortalExit('manuscript', { allowInitialMetadataSeed: false });
});

expect(result.current.allowInitialMetadataSeed).toBe(false);
});

it('activates portal for new users', () => {
const { result } = renderHook(() => useApp({ isNewUser: true }));
expect(result.current.isPortalActive).toBe(true);
Expand Down
Loading
Loading