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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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-7356%2B_%2F_595_files-22C55E" alt="7356+ 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 (7356+ 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 (7356+ 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
- **7356+ 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
25 changes: 23 additions & 2 deletions features/project/adapters.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
import type { EntityState } from '@reduxjs/toolkit';
import { createEntityAdapter } from '@reduxjs/toolkit';
import type { Character, World } from '../../types';

export const charactersAdapter = createEntityAdapter<Character>();
export const worldsAdapter = createEntityAdapter<World>();
// QNBS-v3: the stable no-op comparer selects RTK's object-safe update path without changing entity insertion order.
const preserveEntityOrder = () => 0;

// QNBS-v3: imported string IDs must survive EntityState construction even when they collide with Object.prototype.
/** Builds a JSON-safe EntityState without treating prototype names as inherited properties. */
export function createPrototypeSafeEntityState<T extends { id: string }>(
items: readonly T[],
): EntityState<T, string> | undefined {
const ids: string[] = [];
const entities = Object.create(null) as Record<string, T>;
for (const item of items) {
if (Object.hasOwn(entities, item.id)) return undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ids.push(item.id);
entities[item.id] = item;
}
return { ids, entities };
}

export const charactersAdapter = createEntityAdapter<Character>({
sortComparer: preserveEntityOrder,
Comment thread
qnbs marked this conversation as resolved.
Comment thread
qnbs marked this conversation as resolved.
});
export const worldsAdapter = createEntityAdapter<World>({ sortComparer: preserveEntityOrder });
87 changes: 63 additions & 24 deletions features/project/thunks/projectManagementThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,49 @@ import type { RootState } from '../../../app/store';
import { parseImportedProjectJson } from '../../../services/projectImportSchema';
import { storageService } from '../../../services/storageService';
import type { Character, World } from '../../../types';
import { charactersAdapter, worldsAdapter } from '../adapters';
import { createPrototypeSafeEntityState } from '../adapters';
import type { ProjectData } from '../projectSlice';

const LEGACY_PROJECT_DIRECTORY_METADATA_KEY = '__worldscriptLegacyProjectDirectory';

type ImportedEntityCollection<T extends { id: string }> =
| readonly T[]
| { ids: readonly string[]; entities: Record<string, T> };

// QNBS-v3: validate normalized import correspondence before image I/O so malformed collections cannot create partial imports.
/** Extracts imported entities while requiring exact ids-to-own-entities correspondence. */
function extractImportedEntities<T extends { id: string }>(
collection: ImportedEntityCollection<T> | undefined,
): T[] | undefined {
if (collection === undefined) return [];
if (!('ids' in collection)) return [...collection];
if (
!Array.isArray(collection.ids) ||
typeof collection.entities !== 'object' ||
collection.entities === null
) {
return undefined;
}

const seenIds = new Set<string>();
const importedEntities: T[] = [];
for (const id of collection.ids) {
if (typeof id !== 'string' || seenIds.has(id) || !Object.hasOwn(collection.entities, id)) {
return undefined;
Comment thread
qnbs marked this conversation as resolved.
}
const entity = collection.entities[id];
if (!entity || typeof entity !== 'object' || entity.id !== id) return undefined;
seenIds.add(id);
importedEntities.push(entity);
}

const entityKeys = Object.keys(collection.entities);
if (entityKeys.length !== seenIds.size || entityKeys.some((id) => !seenIds.has(id))) {
return undefined;
}
return importedEntities;
}

// QNBS-v3: compare only storage-owned target identity so mutable snapshot content cannot hide a project switch.
function restoreTargetIdentity(project: unknown): string | null {
if (typeof project !== 'object' || project === null) return null;
Expand All @@ -23,20 +61,28 @@ export const importProjectThunk = createAsyncThunk('project/importProject', asyn
const text = await file.text();
const projectDataJson = parseImportedProjectJson(text);

// QNBS-v3: setAll returns a new Immer-produced state — capture the return value, do not rely on in-place mutation
let charactersState = charactersAdapter.getInitialState();
let worldsState = worldsAdapter.getInitialState();
const charactersToSet: Character[] = [];
const worldsToSet: World[] = [];

let characterArray: (Character & { avatarBase64?: string })[] = [];
if (Array.isArray(projectDataJson.characters)) {
characterArray = projectDataJson.characters as (Character & { avatarBase64?: string })[];
} else if (projectDataJson.characters && 'ids' in projectDataJson.characters) {
const { ids, entities } = projectDataJson.characters;
characterArray = ids
.map((id: string) => entities[id])
.filter((item): item is Character & { avatarBase64?: string } => Boolean(item));
const characterArray = extractImportedEntities(
projectDataJson.characters as
| ImportedEntityCollection<Character & { avatarBase64?: string }>
| undefined,
);
const worldArray = extractImportedEntities(
projectDataJson.worlds as
| ImportedEntityCollection<World & { ambianceImageBase64?: string }>
| undefined,
);
if (!characterArray || !worldArray) {
throw new Error('Invalid project file: entity IDs do not match their collection entries.');
}

if (
!createPrototypeSafeEntityState(characterArray) ||
!createPrototypeSafeEntityState(worldArray)
) {
throw new Error('Invalid project file: duplicate character or world entity ID.');
}

for (const char of characterArray) {
Expand All @@ -48,17 +94,6 @@ export const importProjectThunk = createAsyncThunk('project/importProject', asyn
}
charactersToSet.push(newChar);
}
charactersState = charactersAdapter.setAll(charactersState, charactersToSet);

let worldArray: (World & { ambianceImageBase64?: string })[] = [];
if (Array.isArray(projectDataJson.worlds)) {
worldArray = projectDataJson.worlds as (World & { ambianceImageBase64?: string })[];
} else if (projectDataJson.worlds && 'ids' in projectDataJson.worlds) {
const { ids, entities } = projectDataJson.worlds;
worldArray = ids
.map((id: string) => entities[id])
.filter((item): item is World & { ambianceImageBase64?: string } => Boolean(item));
}

for (const world of worldArray) {
const newWorld = { ...world };
Expand All @@ -69,7 +104,11 @@ export const importProjectThunk = createAsyncThunk('project/importProject', asyn
}
worldsToSet.push(newWorld);
}
worldsState = worldsAdapter.setAll(worldsState, worldsToSet);
const charactersState = createPrototypeSafeEntityState(charactersToSet);
const worldsState = createPrototypeSafeEntityState(worldsToSet);
if (!charactersState || !worldsState) {
throw new Error('Invalid project file: duplicate character or world entity ID.');
}

const manuscript = projectDataJson.manuscript ?? [];

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.
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.
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]);
}
Loading
Loading