Thank you for your interest in contributing to WorldScript Studio β an AI-powered creative writing studio built with React, Redux Toolkit, and the Gemini API.
- Code of Conduct
- Development Setup
- Tech Stack
- Project Structure
- Development Workflow
- Testing
- Code Quality
- Accessibility
- Security Guidelines
- Known Technical Debt
- Pull Request Process
This project follows the Contributor Covenant Code of Conduct. By participating, you are expected to uphold it β report unacceptable behavior via GitHub Private Vulnerability Reporting, the same private channel documented in .github/SECURITY.md.
- Node.js β₯ 22 (see
.nvmrc; matchesenginesinpackage.json) - pnpm β₯ 11 (see
packageManagerinpackage.json; recommended via Corepack, ships with Node) - A Gemini API Key from Google AI Studio (optional if using Ollama in the desktop app only)
- Install Node.js 22+ LTS from nodejs.org (includes Corepack) or use nvm-windows and install
22from.nvmrc. - Open PowerShell or CMD as Administrator once and run:
corepack enable - In the repo folder:
corepack prepare pnpm@11.22.0 --activate(version matchespackageManagerinpackage.json; adjust if that field changes). - Confirm:
pnpm -vβ thennode scripts/dependency-state.mjs reconcile(never a barepnpm installβ see Installation) and usepnpm run β¦for all scripts (hooks expectpnpmonPATH).
If corepack is not recognized, reinstall Node or enable the βTools for Native Modulesβ / standard installation so corepack.cmd is on PATH.
Graphify is the graphifyy package on PyPI (CLI command graphify), not an npm package β see docs/graphify.md.
Recommended install (matches many setups):
pnpm run graphify:bootstrap # pip install/upgrade graphifyy (needs Python 3.11+ with pip)
pnpm run graphify:install # registers IDE/git integrations (run once per machine)
pnpm run graphify:hooks # optional: auto `graphify update .` on commit/checkout
pnpm run graphify:update # refresh graphify-out/ (AST-only)If graphify is not on your PATH (common on Windows after plain pip), use pnpm run graphify:* β they call scripts/graphify-cli.mjs. On Windows the launcher prefers python -m graphify (then python3, then py) so it works when graphify.exe lives under the pip user Scripts folder but that folder is not on PATH.
Alternatives: pipx install graphifyy or uv tool install graphifyy (often put graphify on PATH automatically). On Windows, pip user scripts often live under %APPDATA%\Python\Python3xx\Scripts β add that directory to your user PATH if you want the bare graphify command everywhere.
CodeGraph provides semantic code intelligence via MCP β symbol-level caller/callee/impact analysis with auto-sync. See docs/codegraph.md and docs/dual-graph-setup.md.
Install:
pnpm run codegraph:bootstrap # installs the exact version pinned in config/graph-tools-versions.json
pnpm run codegraph:init # in repo root; initializes the local index through the verified resolverAgent setup (Kimi Code CLI): add the MCP server to ~/.kimi/settings.json (see docs/codegraph.md Β§ MCP Integration).
Daily commands:
pnpm run codegraph:status # index stats
pnpm run codegraph:update # full re-index
pnpm run codegraph:affected # tests affected by uncommitted changes
pnpm run graphs:update # update both Graphify + CodeGraphgit clone https://github.com/qnbs/WorldScript-Studio.git
cd WorldScript-Studio
node scripts/dependency-state.mjs reconcile # frozen-lockfile install β never a bare `pnpm install`
pnpm run hooks:install # configures the pre-commit lint-staged hookUse node scripts/dependency-state.mjs reconcile, not a bare pnpm install β the bare form omits --frozen-lockfile, so on any manifest/lockfile drift it silently rewrites pnpm-lock.yaml instead of failing loudly, and it never writes this repo's own dependency fingerprint that ci:prepush and the pre-commit hook check. On a fresh clone, run that node form directly rather than pnpm run deps:reconcile β pnpm-workspace.yaml's verifyDepsBeforeRun: error makes pnpm itself refuse to launch any pnpm run script while node_modules doesn't exist yet, before the wrapped reconcile logic ever runs; pnpm run deps:reconcile only works once node_modules is already present, e.g. to re-sync after a later lockfile change.
hooks:install is a separate, explicit step rather than an automatic prepare script: pnpm v11's allowBuilds policy denies simple-git-hooks' own install-time script by default (supply-chain hardening β see pnpm-workspace.yaml), so a prepare script that shelled out to it would silently do nothing anyway. Skipping this step means commits bypass the lint-staged pre-commit check.
The app uses no build-time secrets. The Gemini API key is entered via the Settings UI and stored encrypted in IndexedDB.
See .env.example for details.
pnpm run dev # Vite dev server on http://localhost:3000
pnpm run dev:tauri # Tauri desktop app (requires Rust)| Layer | Technology |
|---|---|
| Frontend | React 19, TypeScript (strict + exactOptionalPropertyTypes) |
| State | Redux Toolkit 2.x + redux-undo |
| Styling | Tailwind CSS 4 via @tailwindcss/vite + CSS custom props |
| AI | Gemini (@google/genai), OpenAI, Ollama, WebLLM, ONNX, Transformers.js via aiProviderService.ts |
| Storage | IndexedDB (dbService) / Tauri filesystem (fileSystemService) |
| Build | Vite 8 + vite-plugin-pwa |
| Lint | Biome (pnpm run lint / lint:fix) |
| Tests | Vitest + Testing Library + Playwright |
| Desktop | Tauri 2 (optional) |
| Storybook | Storybook 10 with @storybook/react-vite |
WorldScript-Studio/
βββ app/ # Redux store, hooks, listenerMiddleware
βββ components/ # React UI components
β βββ ui/ # Primitive design-system components (Button, Card, Modalβ¦)
βββ contexts/ # React contexts per view + I18n + CommandExecutor
βββ features/ # Redux slices (project, settings, writer, status, featureFlags, plotBoard, progressTracker, sceneComments)
βββ hooks/ # Custom React hooks per view + shared hooks (e.g. useGlobalKeyboardShortcuts)
βββ locales/ # i18n source JSON β 19 locales: de/en/es/fr/it core + ar/he/fa RTL + el/ja/pt/zh/fi/sv/hu/is/eu/ru/ko Beta (key parity vs en)
βββ services/ # Adapters: AI, DB, storage, collaboration, EPUB; commands/, keyboard/, help/, settingsExchange
βββ stories/ # Storybook stories for UI components
βββ docs/ # Deep-dive docs (CI reference, history, graphify)
βββ tests/
β βββ unit/ # Vitest unit tests
β βββ e2e/ # Playwright end-to-end tests
β βββ setup.ts # Test setup (jsdom, mocks)
βββ types.ts # Core shared TypeScript types (plus collocated types in features)
βββ .github/
βββ workflows/
βββ ci.yml # security β quality β build / e2e / storybook β lighthouse β deploy (main)
Documentation index: README.md Β§ Documentation Hub lists every maintainer-facing .md; AUDIT.md Β§ Markdown corpus lists the 22 curated sources; canonical CI details β docs/CI.md (including Cloud CI-first β heavy E2E, coverage, Lighthouse run on GitHub Actions).
mainβ protected, deploys automatically to GitHub Pages- Feature branches:
feat/feature-name - Bug fixes:
fix/issue-description
simple-git-hooks + lint-staged run on commit:
- Biome
check --writeon staged files (biome.jsonpolicy)
Follow Conventional Commits:
feat: add X feature
fix: resolve Y bug
docs: update README
refactor: restructure Z module
test: add unit tests for W
chore: update dependencies
This repo is developed on constrained hardware (~3β4 GB RAM). Never run the full Vitest coverage suite, Playwright E2E, Stryker mutation, Lighthouse CI, or the Storybook test-runner locally β they OOM weak machines and are CI-only by design. Push to a branch and let GitHub Actions run the heavy tier; a green CI run is the merge bar.
Run the smallest gate that matches your change β sequentially, one heavy command at a time:
| Your change⦠| Run locally before pushing |
|---|---|
| Always | pnpm run ci:prepush |
| Touched any locale JSON | pnpm run i18n:check (parity + bundle rebuild) |
| Added/removed a feature flag | pnpm exec tsx scripts/audit-feature-parity.ts (must report 0 drifts) |
Touched packages/ai-core, workers/, or vite.config.ts |
pnpm run build && pnpm run smoke:prod (prod-only crash guard) |
Added a components/ui/ primitive |
add a .stories.tsx; the Storybook test-runner verifies it in CI |
| Added a dependency | pnpm audit --audit-level=high (override + document in AUDIT.md if needed) |
Coverage, E2E, Lighthouse, Stryker, and Storybook test-runner are CI gate jobs β do not attempt them locally.
- Before every push:
pnpm run ci:prepush(sequential single-checker typecheck, i18n parity/quality, release/doc truth, and lightweight guardrails); the pre-commit hook runs staged Biome checks. Optional: targetedpnpm exec vitest run <path>for a quick smoke. - Full local gate (manual):
pnpm run ci:local:full; full tests, coverage, E2E, Storybook, Lighthouse, and mutation testing remain CI-owned on low-end hardware. - Dependency recovery: run
node scripts/dependency-state.mjs reconcilewhen pnpm reports stale dependency state. It performs the frozen install and records the content fingerprint atomically;pnpm run deps:reconcileis the convenience wrapper when pnpm can launch. - Vitest hard rule: Never invoke
pnpm test,npm run test, or a bare Vitest wrapper; watch mode hangs constrained hardware. Use an explicit targetedpnpm exec vitest run <path>command. - Full gate: GitHub Actions runs Vitest with coverage thresholds, Playwright (desktop + mobile emulation in CI), Lighthouse, etc. A green CI run is the merge bar β you are not required to pass full E2E or LHCI on a weak laptop.
- Optional local E2E:
CI=true pnpm run test:e2ewhen debugging; optional mobile project:RUN_MOBILE_E2E=1(seedocs/CI.md). - CI artifacts: When Playwright, coverage, or Lighthouse fails remotely, open GitHub Actions β the workflow run β Artifacts and inspect the uploaded reports locally β faster than reproducing the full heavy stack on low-end hardware.
pnpm exec vitest run <path> # Targeted single-file run
pnpm exec vitest run <path> --coverage \
--coverage.thresholds.lines=0 --coverage.thresholds.functions=0 \
--coverage.thresholds.branches=0 --coverage.thresholds.statements=0 # Targeted coverage debuggingAfter a production build:
pnpm run bundle:budget # fails if any dist/assets/*.js chunk exceeds default cap (see script)
pnpm run analyze # writes dist/bundle-analysis.html (visualizer does not auto-open when CI=true)On 2β4 GB RAM (e.g. Ubuntu 20.04), use the bundled act + Eco-Forgejo stack β GitHub can stay an optional backup remote only:
- Install:
infra/low-end-ci/INSTALL.md - Daily workflow:
infra/low-end-ci/DAILY-DRIVER.md - Quick gate (no Docker):
pnpm run ci:quick; for one targeted unit file useVITEST_PATH=tests/unit/example.test.ts pnpm run ci:quick:unit - Full
ci.ymllocally:pnpm run ci:act(sequential act jobs)
Manual Act example:
act pull_request --sequential -j security -j quality --matrix node-version:lts/* -W .github/workflows/ci.ymlIf you need Codecov support locally:
export CODECOV_TOKEN="your_token_here"
act pull_request -j quality -s CODECOV_TOKEN=${CODECOV_TOKEN} -W .github/workflows/ci.ymlTests live in tests/unit/. Each UI component and core hook should have a test file.
CI sets CI=true (required by package.json scripts). GitHub Actions installs Chromium and runs desktop + mobile-emulated projects (still Chromium). Locally, Firefox is included when CI is not true; mobile emulation locally only if RUN_MOBILE_E2E=1.
Locally:
pnpm run dev # Optional: dev server for manual exploration
$env:CI='true'; pnpm run test:e2e # PowerShell
# CI=true pnpm run test:e2e # bash
$env:CI='true'; pnpm run test:e2e:uiVisual regression (tests/e2e/visual-regression.spec.ts) stores baselines under tests/e2e/*-snapshots/. The config uses a shared snapshotPathTemplate (no per-OS suffix) so one committed PNG can match Linux CI and local dev. After intentional UI changes:
$env:CI='true'; pnpm exec playwright test tests/e2e/visual-regression.spec.ts --update-snapshots --project=chromium- Never
waitForLoadState('networkidle')against the Vite dev server β HMR/WebSocket traffic prevents a stable idle state. UsewaitForSpaReady()/ DOM anchors instead. selectEnglish()β welcome-portal language toggle so assertions match English copy.ensureBlankProject()β exits the Welcome Portal with a blank manuscript so the main shell (#sidebaror mobile tab bar) exists (fresh CI contexts start as βnew usersβ with no IndexedDB).sidebar(page)β scopes clicks to#sidebarto avoid matching duplicate nav controls (desktop vs mobile).- Version History panel β when open, a full-screen backdrop intercepts pointer events; press Escape (panel closes when no inner modal is open) or use the close control before navigating elsewhere.
Tests live in tests/e2e/. Playwright tests verify core user flows:
- Navigation between views
- Export functionality
- Keyboard accessibility
- Mobile viewport behavior
Targets are defined once in stryker-scope.json and consumed by
stryker.config.mjs: 25 production files across 8 risk-tiered modules.
The manual workflow can run all, tier-a, or one module. Related-test selection avoids rerunning
the full Vitest suite for every mutant. Reports are kept under reports/mutation/ and uploaded
with module identity; the aggregate derives metrics from mutant statuses and fails on missing or
invalid shards while showing killed, survived, timeout, no-coverage, ignored, pending, and error counts. Current operational
thresholds are break: 75, low: 70, and high: 85; they are not a measured baseline until a
trusted force run establishes one.
pnpm run mutation # incremental; CI-only
pnpm run mutation:force # no-cache force audit; CI-only
pnpm run mutation:report # aggregate downloaded reportsDo not run broad Stryker locally on constrained hardware. Use targeted tests locally and dispatch the workflow for PR-related Tier-A/module diagnostics, recurring incremental checks, or release / security force audits. Treat equivalent mutants as a documented survivor class; never hide NoCoverage or timeout results and never change thresholds merely to make CI green.
pnpm run storybook # Start Storybook dev server on :6006
pnpm run build-storybook # Build static StorybookStories live in stories/. All primitive UI components (components/ui/) should have a story.
strict: trueis enforcedexactOptionalPropertyTypes: trueis enforced β do not assignundefinedto optional properties explicitly; omit the property instead- When adding new code, avoid
anyβ use proper types orunknown - Run:
pnpm run typecheck
Single toolchain for lint and format (biome.json):
pnpm run lint # check (CI-hard-fail)
pnpm run i18n:check # locale JSON key parity vs English (CI-hard-fail)
pnpm run lint:fix # check --write (lint + format)
pnpm run format # format onlyMissing keys (e.g. after adding strings to locales/en/*.json): run node scripts/check-i18n-keys.mjs --fix to copy English placeholders into other languages, then translate in a follow-up.
Never log the Gemini API key or any secrets to the console. The geminiService.ts handles key storage securely via IndexedDB with crypto.subtle encryption.
We target WCAG 2.2 AA patterns where practical (Biome a11y rules are strict β warnings fail CI).
Architecture (donβt bypass):
- Live announcements:
LiveRegionProvider/useAnnounce()incontexts/LiveRegionContext.tsxβ use for meaningful status changes; respectsettings.accessibility.liveRegionVerbosity. - Focus management: Modals and the Command Palette use
hooks/useFocusTrap.ts; restore focus when closing overlays. - Settings: Accessibility presets + Zod-normalized persistence β
features/settings/accessibilitySchema.ts, UI incomponents/settings/SystemSections.tsx.
Patterns:
- Interactive controls expose accessible names (
aria-label, visible text, oraria-labelledby). - Dialog surfaces use
role="dialog",aria-modal, and labelled titles (seecomponents/ui/Modal.tsx). - Long-running or AI-heavy regions may use
aria-busyand shortaria-live="polite"copy (e.g. Writer, Manuscript inspector). - Lists and boards: semantic roles (
list,tablist, grouped command palette options); prefer keyboard alternatives where interaction is mouse-first (e.g. Scene Board reorder controls).
When adding UI:
- Prefer semantic HTML; avoid redundant roles on native elements unless fixing SR gaps.
- Icon-only buttons require
aria-label(or tooltip + focus pattern that meets WCAG). - New copy is authored in the 5 core locale trees (de/en/es/fr/it);
i18n:checkenforces key parity across all 17 βpnpm run i18n:check.
Further reading: docs/ACCESSIBILITY.md; automated smoke β tests/e2e/a11y.spec.ts.
For the full security policy, threat model, and active security work items, see SECURITY.md.
- CSP:
index.htmlcontains aContent-Security-Policymeta tag restricting resource origins - API keys: Never hardcode API keys; always use the encrypted IndexedDB storage (
dbService.ts, AES-256-GCM) - AbortController: All AI API calls support cancellation via
AbortSignal - Rate limiting:
geminiService.tshandles 429 errors with exponential backoff - Input sanitization: User input displayed in HTML contexts must go through
DOMPurify.sanitize()beforedangerouslySetInnerHTML - AI responses: Never pass AI output through
eval(),new Function(), or dynamic<script>injection - Collaboration: Always provide a password when calling
collaborationService.connect()in production β passwordless connections throwCollabEncryptionRequiredError
Authoritative list: AUDIT.md and TODO.md. Short pointers:
StorageBackendcontract β implementservices/storageBackend.tson both backends; usestorageServicein UI (notdbServicedirectly) so Tauri and browser stay consistent.app/listenerMiddleware.tsβ occasional TypeScript friction withredux-undo'sStateWithHistory(typed carefully at boundaries).- Collaboration β optional configurable signaling URL; E2E encryption deferred (roadmap).
- i18n β All 17 locale trees must share the same keys as English (
pnpm run i18n:check). The in-app selector exposes all 17 (5 core + 12 Beta). You author new copy in the 5 core trees (de/en/es/fr/it);check-i18n-keys.mjs --fixbackfills the Beta locales with English placeholders, which the machine-translation pass then fills. Locale metadata lives in the SSOT registryi18n/locales.ts. To add a whole new language, followdocs/i18n/ADDING_A_NEW_LANGUAGE.md.
Open a focused PR per theme (storage vs. i18n vs. collaboration) to keep review manageable.
- Fork the repository and create a feature branch
- Write or update tests for your changes
- Run the sequential local gate:
pnpm run ci:prepush(and the optional targetedpnpm exec vitest run <path>when useful). - Let CI run the full lint and test suite; it is authoritative for the heavy tier.
- Ensure the build succeeds when build-affecting files changed; GitHub Actions runs the canonical build gate for every PR.
- Submit a PR against
mainwith a clear description - Request review from at least one maintainer
The CI pipeline will automatically run lint, i18n check, typecheck, tests, and build on every PR.
WorldScript Studio uses a multi-provider AI architecture. To add a new provider (e.g., Ollama, OpenAI):
Create services/yourProviderService.ts following the pattern in geminiService.ts:
// services/ollamaService.ts
export const generateText = async (
prompt: string,
creativity: AiCreativity,
signal?: AbortSignal
): Promise<string> => {
// Your provider's API call here
};
export const generateJson = async <T>(
prompt: string,
creativity: AiCreativity,
schema: GeminiSchema,
signal?: AbortSignal
): Promise<T> => {
// Your provider's structured output call
};Update services/aiProviderService.ts to include your provider in the registry:
import * as ollamaService from './ollamaService';
const providers = {
gemini: geminiService,
ollama: ollamaService,
// Add your provider here
};Extend features/settings/settingsSlice.ts with provider-specific settings, and update components/SettingsView.tsx with the UI controls.
Create tests/unit/yourProviderService.test.ts with mocked API calls.
Writing tools appear in the Writer view dropdown. To add a new tool:
In types.ts, add to the WritingToolType union (or equivalent):
export type WritingToolType = 'continue' | 'improve' | ... | 'yourNewTool';In services/geminiService.ts, add a case in getPrompts():
case 'yourNewTool':
return {
prompt: `Your prompt template with ${params.text}`,
schema: yourOutputSchema,
};Add translation keys to locales/en/ first (reference), then locales/de/ and locales/fr/, locales/es/, locales/it/ (or run node scripts/check-i18n-keys.mjs --fix and translate):
{
"tools": {
"yourNewTool": "Your Tool Name",
"yourNewToolDescription": "Description of what it does"
}
}The tool will automatically appear in WriterView if added to the tool list in hooks/useWriterView.ts.
New commits, outgoing pushes, and release tags must be signed. Run pnpm run signing:doctor
before installing hooks or when a signing operation fails, then install the repository hooks with
pnpm run hooks:install. The hooks reject missing or invalid local signatures; CI additionally
requires GitHub's verification.verified result for every introduced commit and for both an
annotated tag and its target commit. Do not bypass these checks with --no-gpg-sign, --no-verify,
or unsigned fallback objects. See docs/VERIFIED-SIGNING.md for
configuration precedence, recovery, and squash-history semantics.