Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
18 changes: 14 additions & 4 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,11 +187,14 @@ const ViewLoader: FC = () => {

interface AppProps {
isNewUser: boolean;
allowInitialMetadataSeed: boolean;
}

const App: FC<AppProps> = ({ isNewUser }) => {
const appState = useApp({ isNewUser });
const { currentView, handleNavigate, isPortalActive, isInitialLoad } = appState;
// QNBS-v3: keep boot project hydration authority separate from first-run portal semantics.
const App: FC<AppProps> = ({ isNewUser, allowInitialMetadataSeed: initialSeedAuthority }) => {
const appState = useApp({ isNewUser, allowInitialMetadataSeed: initialSeedAuthority });
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 +448,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-7331%2B_%2F_595_files-22C55E" alt="7331+ tests / 595 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 (7331+ tests / 595 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
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 (7331+ tests, 595 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
- **7331+ unit tests** across **595 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2937 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)

Expand Down
10 changes: 7 additions & 3 deletions components/WelcomePortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ 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';
import { Button } from './ui/Button';
import { CustomIcon } from './ui/Icon';
import { LanguageSelector } from './ui/LanguageSelector';

// QNBS-v3: imported/demo portal exits revoke boot seed authority so external content keeps intentional blank metadata.
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 +120,8 @@ export const WelcomePortal: React.FC<WelcomePortalProps> = ({ onExit }) => {
title: t('settings.data.importSuccess'),
}),
);
onExit('manuscript');
// QNBS-v3: imported project content must revoke fresh-project metadata seeding before bootstrap runs.
onExit('manuscript', { allowInitialMetadataSeed: false });
} else {
dispatch(
statusActions.addNotification({
Expand Down Expand Up @@ -173,7 +176,8 @@ export const WelcomePortal: React.FC<WelcomePortalProps> = ({ onExit }) => {
title: t('settings.data.importSuccess'),
}),
);
onExit('manuscript');
// QNBS-v3: demo content is imported content, so it must not be overwritten by fresh-project seeding.
onExit('manuscript', { allowInitialMetadataSeed: false });
} else {
dispatch(
statusActions.addNotification({
Expand Down
24 changes: 20 additions & 4 deletions hooks/useApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,25 @@ function readInitialView(): View {
return 'dashboard';
}

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

export const useApp = ({
isNewUser,
allowInitialMetadataSeed: initialSeedAuthority = isNewUser,
}: {
isNewUser: boolean;
allowInitialMetadataSeed?: 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);
// 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 [allowInitialMetadataSeed, setAllowInitialMetadataSeed] = useState(initialSeedAuthority);
// QNBS-v3: initialize from boot project authority, with the legacy first-run fallback retained for direct hook consumers.
const [isPortalActive, setIsPortalActive] = useState(isNewUser);
const [isInitialLoad, setIsInitialLoad] = useState(true);

Expand Down Expand Up @@ -131,7 +143,9 @@ export const useApp = ({ isNewUser }: { isNewUser: boolean }) => {
}, [currentView]);

const handlePortalExit = useCallback(
(view?: View) => {
(view?: View, options?: PortalExitOptions) => {
// QNBS-v3: imported/demo content revokes seed authority before bootstrap can treat it as fresh project data.
if (options?.allowInitialMetadataSeed === false) setAllowInitialMetadataSeed(false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (view) {
switchView(view);
pushHash(view);
Expand All @@ -156,10 +170,12 @@ export const useApp = ({ isNewUser }: { isNewUser: boolean }) => {
isSidebarOpen,
isPortalActive,
isInitialLoad,
// QNBS-v3: expose transient boot/import authority without persisting a new project-state field.
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.
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]);
}
26 changes: 17 additions & 9 deletions index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { type AppDispatch, appStoreRef, type RootState, setupStore } from './app
import { IdbUnlockModal } from './components/settings/IdbUnlockModal';
import { I18nProvider } from './contexts/I18nContext';
import { versionControlActions } from './features/versionControl/versionControlSlice';
import { loadPersistedRootState } from './services/appBootstrap';
import {
getPersistedProjectPayload,
loadPersistedRootState,
shouldAllowInitialMetadataSeed,
} from './services/appBootstrap';
import { initializeStorage } from './services/dbInitialization';
import { logger } from './services/logger';
import {
Expand Down Expand Up @@ -128,26 +132,30 @@ async function bootApp(): Promise<void> {
// We must manually reconstruct the undo envelope if we loaded flat data.
if (preloadedState?.project) {
const projectPart = preloadedState.project;
const persistedProjectData = getPersistedProjectPayload(projectPart);
const hasUndoPayload =
projectPart.present !== undefined &&
getPersistedProjectPayload({ present: projectPart.present }) !== undefined;

// Check if the loaded project is "flat" (i.e., it doesn't have a 'present' key, but HAS 'data')
const isFlatData = !projectPart.present && projectPart.data;

if (isFlatData && projectPart.data) {
if (persistedProjectData && !hasUndoPayload) {
logger.debug('Hydrating flat project state into Redux-Undo envelope.');
preloadedState.project = {
past: [],
present: { data: projectPart.data }, // Reconstruct the slice structure
present: { data: persistedProjectData }, // Reconstruct the slice structure
future: [],
_latestUnfiltered: projectPart.data, // Helper for redux-undo if needed
_latestUnfiltered: persistedProjectData, // Helper for redux-undo if needed
};
} else if (!projectPart.present && !projectPart.data) {
} else if (!persistedProjectData) {
// Fallback: Corrupt or empty project state
logger.warn('Project state corrupted. Resetting project.');
delete (preloadedState as Record<string, unknown>)['project'];
}
}
// --------------------------------

// QNBS-v3: derive metadata seeding from the normalized persisted-project boundary, not the broader first-run flag.
const allowInitialMetadataSeed = shouldAllowInitialMetadataSeed(preloadedState);
Comment thread
qnbs marked this conversation as resolved.
Resolved

const store = setupStore(preloadedState);
appStoreRef.current = store as unknown as { getState(): RootState; dispatch: AppDispatch };

Expand Down Expand Up @@ -184,7 +192,7 @@ async function bootApp(): Promise<void> {
root.render(
<React.StrictMode>
<Provider store={store}>
<App isNewUser={isNewUser} />
<App isNewUser={isNewUser} allowInitialMetadataSeed={allowInitialMetadataSeed} />
</Provider>
</React.StrictMode>,
);
Expand Down
17 changes: 17 additions & 0 deletions services/appBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,20 @@ export async function loadPersistedRootState(): Promise<PersistedRootState | und
if (project) result.project = { data: project as unknown as ProjectData };
return result;
}

// QNBS-v3: only an actual persisted payload grants project authority; malformed envelopes must still seed the synthetic project.
export function getPersistedProjectPayload(
project: PersistedRootState['project'] | undefined,
): ProjectData | undefined {
const payload = project?.present?.data ?? project?.data;
return typeof payload === 'object' && payload !== null && !Array.isArray(payload)
? payload
: undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

// QNBS-v3: seed authority follows hydrated project presence, so settings-only state can still initialize the synthetic project without overwriting real user intent.
export function shouldAllowInitialMetadataSeed(
preloadedState: PersistedRootState | undefined,
): boolean {
return getPersistedProjectPayload(preloadedState?.project) === undefined;
}
14 changes: 11 additions & 3 deletions services/projectI18nRepair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,27 @@ export type ProjectI18nRepair = {
manuscript?: StorySection[];
};

/** Repair project fields that were saved as raw i18n keys during cold start. */
// QNBS-v3: missing metadata is repairable while empty strings remain valid user intent.
function shouldRepairMetadata(value: unknown, seedInitialMetadata: boolean): boolean {
if (typeof value !== 'string') return true;
return (seedInitialMetadata && value === '') || isKnownPersistedTranslationKey(value);
}

/** 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)) {
// QNBS-v3: only explicit fresh-project authority may fill blanks; raw persisted keys remain independently repairable.
if (shouldRepairMetadata(project.title, seedInitialMetadata)) {
repair.title = t('initialProject.title');
changed = true;
}
if (!project.logline || isKnownPersistedTranslationKey(project.logline)) {
if (shouldRepairMetadata(project.logline, seedInitialMetadata)) {
repair.logline = t('initialProject.logline');
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
Loading
Loading