Implement CHUNK #2: server layer, core schemas/adapters, contract tests, seed

Complete the server-backed storage layer for PubScript so the app can run
against a real Fastify + SQLite backend instead of only local IndexedDB.

Server (@pubscript/server):
- db.ts: better-sqlite3 schema + migrations (users, shares, projects, books,
  chapters, entities, boards, ai_sessions, audit); WAL mode; idempotent
  admin bootstrap via PUBSCRIPT_ADMIN_EMAIL/PASSWORD.
- auth.ts: argon2 hashing, JWT-ish session tokens, guards, auth routes.
- routes.ts: REST CRUD for projects/books/chapters/entities/boards/ai_sessions
  with multi-tenant isolation and optimistic concurrency (baseRev).
- ws.ts: Fastify websocket hub for autosave + presence events.
- mapping.ts: row <-> core type conversion.

Core (@pubscript/core):
- Zod domain schemas (projects, books, chapters, entities, boards, ai_sessions)
  exported from index.
- storage-local.ts (IndexedDB/Dexie) and storage-server.ts (REST) adapters
  implementing the StorageAdapter contract.

Tests & tooling:
- storage.contract.test.ts: parameterized over both adapters; wired as
  `pnpm contract` and added to CI.
- seed.ts (`pnpm --filter server seed`): creates demo project "The Glass Harbor"
  — 3 chapters of prose, 4 linked characters, 1 world location, 6-node board.
- Dockerfile + compose wiring for the server runtime.

Acceptance: seed project opens with 3 chapters, 4 linked characters, 1 board.
This commit is contained in:
Schwitzz
2026-10-24 18:31:20 -05:00
parent 53f4c5b4c7
commit 1c9b531996
95 changed files with 10006 additions and 16 deletions
+15
View File
@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.{yml,yaml,json}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+41
View File
@@ -0,0 +1,41 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install pnpm
run: corepack enable && corepack prepare pnpm@9.15.9 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Typecheck
run: pnpm typecheck
- name: Test
run: pnpm test
- name: Storage contract (Local + Server adapters)
run: pnpm contract
- name: Build web + server
run: pnpm build
- name: Docker build (smoke)
run: docker build -f docker/Dockerfile .
+39
View File
@@ -0,0 +1,39 @@
# Dependencies
node_modules/
.pnpm-store/
# Build output
dist/
build/
*.tsbuildinfo
# Environment
.env
.env.*
!.env.example
# Databases
*.sqlite
*.sqlite-journal
*.sqlite-wal
*.sqlite-shm
data/
# Tauri / desktop
desktop/target/
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# OS / editor
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
# Coverage / test artifacts
coverage/
.vitest/
+10
View File
@@ -0,0 +1,10 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always"
}
+1 -1
View File
@@ -1,6 +1,6 @@
# CHUNK #1 — Scaffold & Foundations # CHUNK #1 — Scaffold & Foundations
**Duration:** ~1 day · **Prereqs:** none (this chunk creates the repo) · **Follows:** — · **Unblocks:** CHUNK #2 **Status:** ✅ **COMPLETE** — verified green (typecheck, test, lint, build, and `docker build` + runtime health check all pass). **Duration:** ~1 day · **Prereqs:** none (this chunk creates the repo) · **Follows:** — · **Unblocks:** CHUNK #2
**Purpose:** Stand up the entire monorepo with strict TypeScript, the app shell, the theming system, the `packages/core` contracts, a Fastify server skeleton, and a green CI pipeline. When this chunk is done, `pnpm dev` runs a themed, navigable app shell and `pnpm test` is green — everything later chunks build on. **Purpose:** Stand up the entire monorepo with strict TypeScript, the app shell, the theming system, the `packages/core` contracts, a Fastify server skeleton, and a green CI pipeline. When this chunk is done, `pnpm dev` runs a themed, navigable app shell and `pnpm test` is green — everything later chunks build on.
+1 -1
View File
@@ -81,7 +81,7 @@ Implement against the core types (zod-validated):
- [ ] Admin creates a second user; that user logs in, sees only their projects; admin shares a project (edit) → it appears; disable → their session is dead within one event round-trip. - [ ] Admin creates a second user; that user logs in, sees only their projects; admin shares a project (edit) → it appears; disable → their session is dead within one event round-trip.
- [ ] Autosave indicator cycles correctly; kill the tab mid-typing, reopen → ≤1 s of work lost. - [ ] Autosave indicator cycles correctly; kill the tab mid-typing, reopen → ≤1 s of work lost.
- [ ] Two browser windows on the same project: save in one appears in the other within ~1 s (WS). - [ ] Two browser windows on the same project: save in one appears in the other within ~1 s (WS).
- [ ] Seed project opens with 3 chapters, 4 linked characters, 1 board. - [x] Seed project opens with 3 chapters, 4 linked characters, 1 board.
- [ ] CI green including the contract suite. - [ ] CI green including the contract suite.
## Definition of done ## Definition of done
+16 -14
View File
@@ -265,22 +265,24 @@ Desktop adds: native file-system access (open/save anywhere), system print-to-PD
Each chunk is a 1–3 day work order in `CHUNK#n.md`, written so a human *or* a strictly-instructed coding agent can execute it top-to-bottom. Each has prerequisites, numbered steps with exact paths/commands, and testable acceptance criteria. Each chunk is a 1–3 day work order in `CHUNK#n.md`, written so a human *or* a strictly-instructed coding agent can execute it top-to-bottom. Each has prerequisites, numbered steps with exact paths/commands, and testable acceptance criteria.
| # | Chunk | ~Days | Delivers | | # | Chunk | ~Days | Status | Delivers |
|---|---|---|---| |---|---|---|---|---|
| 1 | **Scaffold & Foundations** | 1 | Monorepo, strict TS, tooling, app shell, theme system, CI, settings store | | 1 | **Scaffold & Foundations** | 1 | ✅ Done | Monorepo, strict TS, tooling, app shell, theme system, CI, settings store |
| 2 | **Data Model, Storage & Auth** | 3 | Full domain types, StorageAdapter, Local + Server adapters, Fastify auth/multi-tenant, autosave, contract tests | | 2 | **Data Model, Storage & Auth** | 3 | ⬜ Not started | Full domain types, StorageAdapter, Local + Server adapters, Fastify auth/multi-tenant, autosave, contract tests |
| 3 | **Writing Surface** | 3 | TipTap editor, document tree, fonts, focus mode, Markdown I/O, performance | | 3 | **Writing Surface** | 3 | ⬜ Not started | TipTap editor, document tree, fonts, focus mode, Markdown I/O, performance |
| 4 | **Characters & World-Building** | 2 | Entity schema, character sheets, world items, search, mentions, backlinks | | 4 | **Characters & World-Building** | 2 | ⬜ Not started | Entity schema, character sheets, world items, search, mentions, backlinks |
| 5 | **Plot & Whiteboard** | 3 | Beat board + templates, timeline, React Flow whiteboard (200-node perf) | | 5 | **Plot & Whiteboard** | 3 | ⬜ Not started | Beat board + templates, timeline, React Flow whiteboard (200-node perf) |
| 6 | **Export Pipeline** | 3 | MD/HTML/EPUB/DOCX/ODT + PDF, export dialog, CI validation | | 6 | **Export Pipeline** | 3 | ⬜ Not started | MD/HTML/EPUB/DOCX/ODT + PDF, export dialog, CI validation |
| 7 | **Spell & Grammar** | 2 | hunspell-WASM spellcheck, LanguageTool grammar, decorations + panel | | 7 | **Spell & Grammar** | 2 | ⬜ Not started | hunspell-WASM spellcheck, LanguageTool grammar, decorations + panel |
| 8 | **AI Provider Layer** | 2 | Provider config + discovery, streaming, thinking params, token budget | | 8 | **AI Provider Layer** | 2 | ⬜ Not started | Provider config + discovery, streaming, thinking params, token budget |
| 9 | **AI Roles & Suggestions** | 3 | Editor/Reader/Publisher/Co-Author, context builder, tools, accept/reject/pin | | 9 | **AI Roles & Suggestions** | 3 | ⬜ Not started | Editor/Reader/Publisher/Co-Author, context builder, tools, accept/reject/pin |
| 10 | **Multi-User Admin & Sharing** | 2 | Admin user management, roles, project sharing, audit, security | | 10 | **Multi-User Admin & Sharing** | 2 | ⬜ Not started | Admin user management, roles, project sharing, audit, security |
| 11 | **Packaging, Parity & Polish** | 3 | Docker compose, Tauri bundles (3 OS), onboarding, import/backup, perf/a11y, parity matrix | | 11 | **Packaging, Parity & Polish** | 3 | ⬜ Not started | Docker compose, Tauri bundles (3 OS), onboarding, import/backup, perf/a11y, parity matrix |
**Total: ~27 focused working days**, sequenced so each chunk is independently shippable and testable. **Total: ~27 focused working days**, sequenced so each chunk is independently shippable and testable.
**Progress:** 1 of 11 chunks complete. CHUNK #1 is built and CI-green (`pnpm typecheck`, `pnpm test`, `pnpm lint`, `pnpm build` all pass; `pnpm dev` serves the themed app shell).
--- ---
## 10. Risks & Mitigations ## 10. Risks & Mitigations
@@ -296,4 +298,4 @@ Each chunk is a 1–3 day work order in `CHUNK#n.md`, written so a human *or* a
--- ---
*Next: read `CHUNK#1.md` and start executing. Each chunk's acceptance criteria are the gate to the next.* *CHUNK #1 is complete. Next: read `CHUNK#2.md` and start executing. Each chunk's acceptance criteria are the gate to the next.*
+42
View File
@@ -0,0 +1,42 @@
# PubScript
A self-hostable, multi-user writing studio for novelists — one codebase that runs as a web app and as a native desktop app, with a local-first AI co-writing engine built in.
See **[PLAN.md](./PLAN.md)** for the full vision, architecture, and data model. The `CHUNK#n.md` files are the executable work orders.
## Quickstart
```bash
pnpm i && pnpm dev # run the app shell at http://localhost:5173
pnpm test # run every workspace's unit tests
pnpm typecheck # strict TypeScript across the monorepo
pnpm build # build web + server
```
## Docker (self-host)
```bash
docker compose -f docker/compose.yaml up --build
# → http://localhost:8080 (health: curl localhost:8080/api/health)
```
One container serves the web app, runs the Fastify API, and stores SQLite on a volume.
## Repository layout
| Path | What it is |
|---|---|
| `PLAN.md` | Single source of truth for *what* and *why* |
| `CHUNK#n.md` | Executable, step-by-step work orders |
| `apps/web/` | React 19 + Vite app (runs in browser AND in Tauri) |
| `packages/core/` | Domain types, ULIDs, time, token budget, `StorageAdapter` contract |
| `packages/ui/` | Design tokens, theme engine (Ink / Parchment / Nocturne / Terminal), component kit |
| `server/` | Fastify + SQLite: auth, multi-tenant, WS, admin, AI proxy |
| `docker/` | Dockerfile + compose (one container serves app + API + data) |
| `docs/` | ADRs, data model reference, AI role prompts, parity matrix |
## Status
**CHUNK #1 — Scaffold & Foundations** is complete: monorepo, strict TypeScript, the app shell, the theming system, the `packages/core` contracts, a Fastify server skeleton, and a green CI pipeline.
*Next: [CHUNK #2](./CHUNK%232.md) — Data Model, Storage & Auth.*
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>PubScript</title>
<meta name="description" content="A local-first writing environment for authors." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@pubscript/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173",
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@pubscript/core": "workspace:*",
"@pubscript/ui": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1",
"typescript": "^5.7.2",
"vite": "^6.0.3",
"vitest": "^2.1.8",
"@types/node": "^22.10.2"
}
}
+63
View File
@@ -0,0 +1,63 @@
import { useState, useEffect } from "react";
import { Routes, Route } from "react-router-dom";
import { Sidebar, Topbar, CommandPalette } from "./components/index.js";
import { ToastViewport } from "@pubscript/ui";
import { Write } from "./routes/Write.js";
import { Characters } from "./routes/Characters.js";
import { Plot } from "./routes/Plot.js";
import { Whiteboard } from "./routes/Whiteboard.js";
import { AI } from "./routes/AI.js";
import { Projects } from "./routes/Projects.js";
import { Settings } from "./routes/Settings.js";
import { Admin } from "./routes/Admin.js";
const NAV = [
{ to: "/", label: "Write", icon: "✎", end: true },
{ to: "/characters", label: "Characters", icon: "👤", end: false },
{ to: "/plot", label: "Plot", icon: "🗺", end: false },
{ to: "/whiteboard", label: "Whiteboard", icon: "🧩", end: false },
{ to: "/ai", label: "AI", icon: "✨", end: false },
{ to: "/projects", label: "Projects", icon: "📁", end: false },
{ to: "/settings", label: "Settings", icon: "⚙", end: false },
{ to: "/admin", label: "Admin", icon: "🛠", end: false },
];
export default function App() {
const [paletteOpen, setPaletteOpen] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setPaletteOpen((o) => !o);
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
return (
<div className="ps-app">
<Sidebar nav={NAV} />
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<Topbar onOpenPalette={() => setPaletteOpen(true)} />
<main className="ps-main" style={{ flex: 1, overflow: "hidden" }}>
<Routes>
<Route path="/" element={<Write />} />
<Route path="/characters" element={<Characters />} />
<Route path="/plot" element={<Plot />} />
<Route path="/whiteboard" element={<Whiteboard />} />
<Route path="/ai" element={<AI />} />
<Route path="/projects" element={<Projects />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />
<Route path="*" element={<Write />} />
</Routes>
</main>
</div>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
<ToastViewport />
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import type { NavItem } from "./Sidebar.js";
export interface CommandPaletteProps {
open: boolean;
onClose: () => void;
nav: NavItem[];
}
export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) {
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const commands = useMemo(
() =>
nav.map((n) => ({
id: n.to,
label: n.label,
icon: n.icon,
path: n.to,
kbd: "",
})),
[nav],
);
const filtered = useMemo(
() =>
query
? commands.filter((c) => c.label.toLowerCase().includes(query.toLowerCase()))
: commands,
[commands, query],
);
useEffect(() => {
if (open) {
setQuery("");
setActive(0);
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => Math.min(a + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(a - 1, 0));
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, filtered, onClose]);
if (!open) return null;
const run = (id: string) => {
navigate(id);
onClose();
};
return (
<div className="ps-command-palette-overlay" onClick={onClose}>
<div
className="ps-command-palette"
role="dialog"
aria-label="Command palette"
onClick={(e) => e.stopPropagation()}
>
<input
ref={inputRef}
className="ps-command-input"
placeholder="Type a command or search…"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
/>
<div className="ps-command-list">
{filtered.length === 0 ? (
<div className="ps-command-empty">No matches</div>
) : (
filtered.map((c, i) => (
<button
key={c.id}
className={`ps-command-item${i === active ? " is-active" : ""}`}
onClick={() => run(c.id)}
onMouseEnter={() => setActive(i)}
>
<span className="ps-command-icon">{c.icon}</span>
<span style={{ flex: 1, textAlign: "left" }}>{c.label}</span>
<span className="ps-command-kbd">{c.path}</span>
</button>
))
)}
</div>
</div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useSettings, type Density } from "../store/settings.js";
import { Select, Button, toast } from "@pubscript/ui";
import { THEME_IDS, THEME_LABELS } from "@pubscript/ui";
export function Settings() {
const s = useSettings();
const set = useSettings((st) => st.set);
const reset = useSettings((st) => st.reset);
return (
<div className="ps-content">
<div className="ps-settings-group">
<h3>Appearance</h3>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Theme</div>
<div className="ps-settings-desc">Four themes ship out of the box.</div>
</div>
<div className="ps-swatches">
{THEME_IDS.map((id) => (
<button
key={id}
className={`ps-swatch${s.theme === id ? " is-active" : ""}`}
style={{ background: `var(--ps-${id === "ink" ? "accent" : "bg"})` }}
title={THEME_LABELS[id]}
onClick={() => set({ theme: id })}
>
{s.theme === id && <span style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", color: "var(--ps-accent-contrast)" }}>✓</span>}
</button>
))}
</div>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Density</div>
<div className="ps-settings-desc">Spacing between UI elements.</div>
</div>
<Select
value={s.density}
onChange={(e) => set({ density: e.target.value as Density })}
options={[
{ value: "compact", label: "Compact" },
{ value: "comfortable", label: "Comfortable" },
]}
/>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Font size</div>
<div className="ps-settings-desc">Base UI font size in pixels.</div>
</div>
<Select
value={String(s.fontSize)}
onChange={(e) => set({ fontSize: Number(e.target.value) })}
options={[13, 14, 15, 16, 17, 18].map((n) => ({ value: String(n), label: `${n}px` }))}
/>
</div>
</div>
<div className="ps-settings-group">
<h3>Writing</h3>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Word count goal</div>
<div className="ps-settings-desc">Target length for your manuscript.</div>
</div>
<Select
value={String(s.wordCountGoal)}
onChange={(e) => set({ wordCountGoal: Number(e.target.value) })}
options={[20_000, 40_000, 60_000, 80_000, 100_000].map((n) => ({ value: String(n), label: `${(n / 1000).toFixed(0)}k` }))}
/>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Show line numbers</div>
<div className="ps-settings-desc">Display line numbers in the editor gutter.</div>
</div>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={s.showLineNumbers}
onChange={(e) => set({ showLineNumbers: e.target.checked })}
/>
</label>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Auto-save</div>
<div className="ps-settings-desc">Persist changes automatically.</div>
</div>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={s.autoSave}
onChange={(e) => set({ autoSave: e.target.checked })}
/>
</label>
</div>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Reset to defaults</div>
</div>
<Button variant="danger" size="sm" onClick={() => { reset(); toast.info("Settings reset"); }}>
Reset
</Button>
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { NavLink } from "react-router-dom";
export interface NavItem {
to: string;
label: string;
icon: string;
end?: boolean;
}
export interface SidebarProps {
nav: NavItem[];
}
export function Sidebar({ nav }: SidebarProps) {
return (
<aside className="ps-sidebar">
<div className="ps-logo">
<span className="ps-logo-mark">✒</span> PubScript
</div>
<div className="ps-nav-section-title">Project</div>
<button className="ps-nav-item">
<span className="ps-nav-icon">📖</span>
<span>
<div>The Silent Cartographer</div>
<div style={{ fontSize: 12, opacity: 0.6 }}>12 chapters · 47k words</div>
</span>
</button>
<div className="ps-nav-section-title" style={{ marginTop: 16 }}>Navigate</div>
{nav.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end ?? false}
className={({ isActive }) => `ps-nav-item${isActive ? " is-active" : ""}`}
>
<span className="ps-nav-icon">{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</aside>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useLocation, NavLink } from "react-router-dom";
import { useSettings } from "../store/settings.js";
import { Select, Badge } from "@pubscript/ui";
import { THEME_IDS, THEME_LABELS } from "@pubscript/ui";
const TITLES: Record<string, string> = {
"/": "Write",
"/characters": "Characters",
"/plot": "Plot",
"/whiteboard": "Whiteboard",
"/ai": "AI",
"/projects": "Projects",
"/settings": "Settings",
"/admin": "Admin",
};
export interface TopbarProps {
onOpenPalette: () => void;
}
export function Topbar({ onOpenPalette }: TopbarProps) {
const location = useLocation();
const theme = useSettings((s) => s.theme);
const setTheme = useSettings((s) => s.setTheme);
const autoSave = useSettings((s) => s.autoSave);
const title = TITLES[location.pathname] ?? "PubScript";
return (
<header className="ps-topbar">
<h1 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>{title}</h1>
{autoSave && <Badge variant="success">✓ autosave on</Badge>}
<div style={{ flex: 1 }} />
<button
className="ps-nav-item"
onClick={onOpenPalette}
style={{ width: "auto", padding: "6px 10px" }}
>
<span>Search</span>
<span className="ps-command-kbd" style={{ marginLeft: 8 }}>⌘K</span>
</button>
<Select
value={theme}
onChange={(e) => setTheme(e.target.value as never)}
aria-label="Theme"
options={THEME_IDS.map((id) => ({ value: id, label: THEME_LABELS[id] }))}
/>
<NavLink to="/settings" className="ps-icon-btn" style={{ fontSize: 18 }} aria-label="Settings">
⚙
</NavLink>
</header>
);
}
+7
View File
@@ -0,0 +1,7 @@
export { Sidebar } from "./Sidebar.js";
export type { SidebarProps, NavItem } from "./Sidebar.js";
export { Topbar } from "./Topbar.js";
export type { TopbarProps } from "./Topbar.js";
export { CommandPalette } from "./CommandPalette.js";
export type { CommandPaletteProps } from "./CommandPalette.js";
export { Settings } from "./Settings.js";
+12
View File
@@ -0,0 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.js";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+32
View File
@@ -0,0 +1,32 @@
import { Button, Input, toast } from "@pubscript/ui";
export function AI() {
return (
<div className="ps-content">
<h1 style={{ margin: "0 0 16px", fontSize: 24 }}>AI Assistant</h1>
<p className="ps-settings-desc" style={{ marginBottom: 20 }}>
Local-first AI: prompts run against your configured provider. Nothing leaves your machine unless you allow it.
</p>
<Input
label="Prompt"
placeholder="Summarize Mara's arc so far…"
style={{ width: "100%", marginBottom: 12 }}
/>
<div style={{ display: "flex", gap: 10 }}>
<Button variant="primary" onClick={() => toast.success("Prompt queued locally.")}>Run locally</Button>
<Button variant="secondary">Refine character</Button>
<Button variant="secondary">Outline next chapter</Button>
</div>
<div style={{ marginTop: 24, padding: 16, background: "var(--ps-surface)", border: "1px solid var(--ps-border)", borderRadius: 8 }}>
<h3 style={{ marginTop: 0, fontSize: 14 }}>Privacy</h3>
<ul style={{ margin: 0, paddingLeft: 18, lineHeight: 1.8, fontSize: 14 }}>
<li>Documents are never uploaded without explicit consent.</li>
<li>Context is chunked to stay within the token budget.</li>
<li>Session history is stored locally and deletable.</li>
</ul>
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { Badge, Button } from "@pubscript/ui";
export function Admin() {
return (
<div className="ps-content">
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 16 }}>
<h1 style={{ margin: 0, fontSize: 24 }}>Admin</h1>
<Badge variant="danger">Beta</Badge>
</div>
<p className="ps-settings-desc" style={{ marginBottom: 20 }}>
Local administration: manage the device token, inspect the store, and reset persisted state.
</p>
<div style={{ display: "grid", gap: 12 }}>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Device token</div>
<div className="ps-settings-desc">Unique per-device identifier for the storage adapter.</div>
</div>
<Button variant="secondary">Regenerate</Button>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Store diagnostics</div>
<div className="ps-settings-desc">Inspect the active storage backend and its health.</div>
</div>
<Button variant="secondary">Run</Button>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Clear local data</div>
<div className="ps-settings-desc">Delete all cached documents and settings.</div>
</div>
<Button variant="danger">Clear</Button>
</div>
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { Tabs, Badge, Button, EmptyState } from "@pubscript/ui";
const CHARACTERS = [
{ id: "c1", name: "Mara Vell", role: "Protagonist", kind: "Human" },
{ id: "c2", name: "Old Corvin", role: "Mentor", kind: "Human" },
{ id: "c3", name: "The Cartographer", role: "Antagonist", kind: "Unknown" },
];
export function Characters() {
return (
<div className="ps-content">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<h1 style={{ margin: 0, fontSize: 24 }}>Characters</h1>
<Button variant="primary">+ New character</Button>
</div>
<Tabs
defaultActive="characters"
tabs={[
{
id: "characters",
label: "All",
badge: CHARACTERS.length,
content: (
<div style={{ display: "grid", gap: 10 }}>
{CHARACTERS.map((c) => (
<div key={c.id} className="ps-settings-row" style={{ cursor: "pointer" }}>
<div>
<div style={{ fontWeight: 600 }}>{c.name}</div>
<div className="ps-settings-desc">{c.role}</div>
</div>
<Badge variant="accent">{c.kind}</Badge>
</div>
))}
</div>
),
},
{
id: "locations",
label: "Locations",
content: <EmptyState icon="📍" title="Locations" description="Locations appear here once added to a chapter." />,
},
{
id: "factions",
label: "Factions",
content: <EmptyState icon="🏳️" title="No factions yet" description="Group characters into factions to track loyalties." />,
},
]}
/>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Badge } from "@pubscript/ui";
const ACTS = [
{ act: "Act I — Setup", beats: ["The world as it is", "The inciting incident", "Crossing the threshold"] },
{ act: "Act II — Confrontation", beats: ["Rising action", "The midpoint reversal", "All is lost"] },
{ act: "Act III — Resolution", beats: ["The climax", "The new equilibrium"] },
];
export function Plot() {
return (
<div className="ps-content">
<h1 style={{ margin: "0 0 16px", fontSize: 24 }}>Plot</h1>
{ACTS.map((a) => (
<section key={a.act} style={{ marginBottom: 28 }}>
<h2 style={{ fontSize: 18, display: "flex", alignItems: "center", gap: 10 }}>
{a.act} <Badge variant="neutral">{String(a.beats.length)}</Badge>
</h2>
<ul style={{ fontFamily: "var(--ps-font-body)", lineHeight: 2, margin: 0, paddingLeft: 20 }}>
{a.beats.map((b) => (
<li key={b}>{b}</li>
))}
</ul>
</section>
))}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { Badge, Button, EmptyState } from "@pubscript/ui";
const PROJECTS = [
{ id: "p1", title: "The Silent Cartographer", words: 47_200, status: "Draft" },
{ id: "p2", title: "Anthology of Tides", words: 12_400, status: "Planning" },
];
export function Projects() {
return (
<div className="ps-content">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<h1 style={{ margin: 0, fontSize: 24 }}>Projects</h1>
<Button variant="primary">+ New project</Button>
</div>
<div style={{ display: "grid", gap: 12 }}>
{PROJECTS.map((p) => (
<div key={p.id} className="ps-settings-row" style={{ cursor: "pointer" }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 16 }}>{p.title}</div>
<div className="ps-settings-desc">{p.words.toLocaleString()} words</div>
</div>
<Badge variant="accent">{p.status}</Badge>
</div>
))}
</div>
<EmptyState
icon="📚"
title="No more projects"
description="Start a new manuscript or open an existing one."
/>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Settings as SettingsPanel } from "../components/Settings.js";
export function Settings() {
return <SettingsPanel />;
}
+28
View File
@@ -0,0 +1,28 @@
import { Button } from "@pubscript/ui";
const CARDS = [
{ id: "w1", title: "The lost map", note: "Mara inherits a map with no destination.", x: 0 },
{ id: "w2", title: "The harbor", note: "Where the story begins.", x: 240 },
{ id: "w3", title: "The Cartographer", note: "Knows more than they say.", x: 480 },
];
export function Whiteboard() {
return (
<div className="ps-content">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<h1 style={{ margin: 0, fontSize: 24 }}>Whiteboard</h1>
<Button variant="primary">+ Add card</Button>
</div>
<div style={{ display: "flex", gap: 16, overflowX: "auto", paddingBottom: 12 }}>
{CARDS.map((c) => (
<div key={c.id} className="ps-modal" style={{ width: 220, height: 160, flexShrink: 0, cursor: "grab", position: "relative", transform: "none", top: "auto", left: "auto", boxShadow: "0 4px 16px rgba(0,0,0,0.2)" }}>
<div className="ps-modal-body" style={{ padding: 14 }}>
<h3 style={{ margin: "0 0 6px", fontSize: 15 }}>{c.title}</h3>
<p style={{ margin: 0, fontSize: 13, color: "var(--ps-text-muted)" }}>{c.note}</p>
</div>
</div>
))}
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useMemo } from "react";
import { useSettings } from "../store/settings.js";
export function Write() {
const wordCountGoal = useSettings((s) => s.wordCountGoal);
const showLineNumbers = useSettings((s) => s.showLineNumbers);
const content = `# Chapter One\n\nThe harbor mist clung to the docks like a held breath. Mara set down her satchel and watched the gulls wheel against the grey sky…\n\n(This is a placeholder for the ProseMirror editor. CHUNK #1 establishes the shell; the rich-text editor lands in a later chunk.)`;
const lines = useMemo(() => content.split("\n"), [content]);
const words = useMemo(() => content.trim().split(/\s+/).filter(Boolean).length, [content]);
const pct = Math.min(100, Math.round((words / wordCountGoal) * 100));
return (
<div className="ps-content">
<input
type="text"
defaultValue="Chapter One"
style={{
fontSize: 28,
fontWeight: 700,
border: "none",
background: "transparent",
color: "var(--ps-text)",
marginBottom: 16,
width: "100%",
}}
/>
{showLineNumbers && (
<div style={{ display: "flex", textAlign: "right", color: "var(--ps-text-muted)", userSelect: "none", paddingRight: 8, marginBottom: 4 }}>
{lines.map((_, i) => (
<span key={i} style={{ fontFamily: "var(--ps-font-mono)", fontSize: 12 }}>{i + 1}</span>
))}
</div>
)}
<textarea
defaultValue={content}
spellCheck={false}
style={{
width: "100%",
minHeight: 480,
resize: "vertical",
background: "var(--ps-surface)",
color: "var(--ps-text)",
border: "1px solid var(--ps-border)",
borderRadius: 8,
padding: 16,
fontFamily: "var(--ps-font-body)",
fontSize: 17,
lineHeight: 1.75,
}}
/>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 16, fontSize: 13, color: "var(--ps-text-muted)" }}>
<span>{words.toLocaleString()} words</span>
<div style={{ flex: 1, height: 6, background: "var(--ps-surface-2)", borderRadius: 3, overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", background: "var(--ps-accent)", transition: "width 0.3s" }} />
</div>
<span>{pct}% of {wordCountGoal.toLocaleString()} goal</span>
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { create } from "zustand";
import { applyTheme, type ThemeId } from "@pubscript/ui";
export type Density = "compact" | "comfortable";
export interface AppSettings {
theme: ThemeId;
accentColor: string;
density: Density;
fontSize: number;
showLineNumbers: boolean;
wordCountGoal: number;
autoSave: boolean;
}
export const DEFAULT_SETTINGS: AppSettings = {
theme: "ink",
accentColor: "#7c8bf5",
density: "comfortable",
fontSize: 15,
showLineNumbers: true,
wordCountGoal: 80_000,
autoSave: true,
};
const STORAGE_KEY = "pubscript.settings.v1";
function loadSettings(): AppSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
const parsed = JSON.parse(raw) as Partial<AppSettings>;
return { ...DEFAULT_SETTINGS, ...parsed };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
export interface SettingsStore extends AppSettings {
set: (patch: Partial<AppSettings>) => void;
setTheme: (theme: ThemeId) => void;
reset: () => void;
}
export const useSettings = create<SettingsStore>((set, get) => {
const initial = loadSettings();
// Apply the persisted theme on boot.
applyTheme(initial.theme);
return {
...initial,
set: (patch) => {
const next = { ...get(), ...patch };
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
set(next);
},
setTheme: (theme) => {
applyTheme(theme);
get().set({ theme });
},
reset: () => {
localStorage.removeItem(STORAGE_KEY);
applyTheme(DEFAULT_SETTINGS.theme);
set({ ...DEFAULT_SETTINGS });
},
};
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"types": ["vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 5173,
strictPort: false,
},
preview: {
host: "0.0.0.0",
port: 4173,
},
});
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
passWithNoTests: true,
},
});
+51
View File
@@ -0,0 +1,51 @@
# Multi-stage build: build the web bundle + server, then serve them from a
# slim runtime image as a single self-hosted container (PLAN.md §7).
FROM node:22-alpine AS build
WORKDIR /app
# Build tools for native modules (better-sqlite3) in the build stage.
RUN apk add --no-cache python3 make g++
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
COPY packages ./packages
COPY apps ./apps
COPY server ./server
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate \
&& pnpm install --frozen-lockfile \
&& pnpm build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Build tools for native modules (better-sqlite3, argon2) + su-exec for the
# entrypoint to drop privileges to the non-root user at runtime.
RUN apk add --no-cache python3 make g++ su-exec
# Non-root user.
RUN addgroup --system --gid 1001 pubscript \
&& adduser --system --uid 1001 pubscript
# Source + lockfile from the build stage (apps/web/dist carries the built web bundle).
COPY --from=build /app/package.json /app/pnpm-workspace.yaml /app/pnpm-lock.yaml ./
COPY --from=build /app/packages ./packages
COPY --from=build /app/apps ./apps
COPY --from=build /app/server ./server
# Install production deps only (native modules compile here).
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate \
&& pnpm install --prod --frozen-lockfile
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Data volume for the SQLite file.
VOLUME ["/app/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/api/health || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["sh", "-c", "cd /app/server && exec node --import tsx src/index.ts"]
+16
View File
@@ -0,0 +1,16 @@
services:
pubscript:
build:
context: ..
dockerfile: docker/Dockerfile
image: pubscript:latest
ports:
- "8080:8080"
environment:
- PUBSCRIPT_DB=/app/data/pubscript.sqlite
- PUBSCRIPT_ORIGIN=http://localhost:8080
- PUBSCRIPT_ADMIN_EMAIL=admin@pubscript.local
- PORT=8080
volumes:
- ./data:/app/data
restart: unless-stopped
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -e
# Runs as root (the image does not set USER), so it can fix ownership of the
# data volume, then drops privileges to the non-root pubscript user before
# exec'ing the server. This lets SQLite write to the mounted data dir.
mkdir -p /app/data
chown -R pubscript:pubscript /app/data
exec su-exec pubscript "$@"
+52
View File
@@ -0,0 +1,52 @@
# PubScript Conventions
Guidelines every chunk follows so a human and a strictly-instructed coding agent stay in lockstep.
## Language & tooling
- **TypeScript `strict`** everywhere, with `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes` on. No `any` in new code.
- **pnpm workspaces** with a committed lockfile. Never run `pnpm install` without `--frozen-lockfile` in CI.
- **ESM throughout** (`"type": "module"`). Import sibling modules with a `.js` extension even though the source is `.ts` (bundler module resolution).
- **Prettier** (100-col, double quotes) + **ESLint flat config** (`typescript-eslint` recommended, `no-explicit-any` off, unused-vars on).
## File layout
```
PubScript/
├── PLAN.md ← single source of truth for *what* and *why*
├── CHUNK#n.md ← executable work orders (this chunk's steps)
├── apps/web/ ← React 19 + Vite app (runs in browser AND Tauri)
├── packages/
│ ├── core/ ← domain types, IDs, time, token budget, StorageAdapter contract
│ ├── ui/ ← design tokens, theme engine, component kit
│ ├── editor/ boards/ exporters/ ai/ ← later chunks
├── server/ ← Fastify + SQLite: auth, multi-tenant, WS, admin, AI proxy
├── desktop/ ← Tauri 2 shell (later chunk)
├── docker/ ← Dockerfile + compose
└── docs/ ← ADRs, data model, role prompts, parity matrix
```
## The golden rule: code against `packages/core`, never against a backend
Every feature codes against the **`StorageAdapter` contract** in `packages/core/src/storage.ts`, never against `ServerAdapter` or `LocalAdapter` directly. This is what makes web, desktop, and local modes interchangeable and lets a single contract-test suite enforce parity.
## Domain conventions
- **IDs are ULIDs** (sortable, collision-free). See `packages/core/src/ids.ts`.
- **Timestamps are UTC ISO-8601** strings. See `packages/core/src/time.ts`.
- **All domain types live in `packages/core/src/types.ts`** and are shared by app *and* server. Do not redefine them locally.
## Testing
- **Vitest** for unit tests colocated as `src/**/*.test.ts(x)`.
- **Contract suite** (`packages/core` `contract` script) runs the same assertions against every `StorageAdapter` implementation.
- Acceptance criteria in each `CHUNK#n.md` are the gate to the next chunk.
## Commits
Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`, `build:`). One logical change per commit; reference the chunk in the body when relevant.
## Risks & fallbacks
- Tailwind v4 + React 19 friction → pin exact versions and record them here.
- `better-sqlite3` native build in Docker → `node:22-alpine` with `python3 make g++` in the build stage; fall back to `node:22-slim` if needed (record in an ADR).
+26
View File
@@ -0,0 +1,26 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
export default tseslint.config(
{
ignores: ["**/dist/**", "**/node_modules/**", "**/build/**", "**/*.d.ts"],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
},
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-empty-object-type": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
eslintConfigPrettier,
);
+28
View File
@@ -0,0 +1,28 @@
{
"name": "pubscript",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "A self-hostable, multi-user writing studio for novelists — one codebase that runs as a web app and as a native desktop app, with a local-first AI co-writing engine.",
"packageManager": "pnpm@9.15.9",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "pnpm -r --filter ./apps/* run dev",
"build": "pnpm -r run build",
"test": "pnpm -r run test",
"lint": "pnpm -r run lint",
"typecheck": "pnpm -r run typecheck",
"contract": "pnpm --filter @pubscript/core run contract"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.4.2",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.1",
"vitest": "^2.1.8"
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@pubscript/core",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./local": "./src/storage-local.ts",
"./package.json": "./package.json"
},
"scripts": {
"dev": "echo \"@pubscript/core has no dev server\" (exit 0)",
"build": "tsc --noEmit",
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"contract": "vitest run src/storage.contract.test.ts"
},
"dependencies": {
"dexie": "^4.0.11",
"gpt-tokenizer": "^2.6.0",
"ulid": "^2.3.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"fake-indexeddb": "^6.0.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect, vi } from "vitest";
import { createAutosave } from "./autosave.js";
import type { StorageAdapter } from "./storage.js";
import type { DocumentRef } from "./types.js";
function mockAdapter(): StorageAdapter & { saved: DocumentRef[] } {
const saved: DocumentRef[] = [];
return {
saved,
async listProjects() {
return [];
},
async createProject() {
throw new Error("not implemented");
},
async getProject() {
throw new Error("not implemented");
},
async getChapter() {
return null;
},
async saveEntity() {},
async saveBoard() {},
async saveAISession() {},
async saveDocument(doc: DocumentRef) {
saved.push(doc);
},
onEvent() {
return () => {};
},
};
}
const ref: DocumentRef = {
id: "doc1",
projectId: "p1",
title: "Chapter 1",
rev: 0,
doc: { type: "doc", content: [] },
};
describe("autosave", () => {
it("debounces saves and reports status transitions", async () => {
const adapter = mockAdapter();
const as = createAutosave(adapter, { intervalMs: 50 });
const statuses: string[] = [];
as.onStatus((s) => statuses.push(s.status));
as.save(ref);
as.save(ref); // second save within window should not double-fire
expect(as.state.dirty).toBe(true);
expect(as.state.saving).toBe(true);
await vi.waitFor(() => expect(adapter.saved.length).toBe(1), { timeout: 500 });
expect(adapter.saved[0]!.id).toBe("doc1");
expect(as.state.dirty).toBe(false);
expect(as.state.saving).toBe(false);
expect(as.indicatorLabel()).toMatch(/✓ saved/);
});
it("flush persists immediately and clears dirty state", async () => {
const adapter = mockAdapter();
const as = createAutosave(adapter, { intervalMs: 10_000 });
as.save(ref);
await as.flush();
expect(adapter.saved.length).toBe(1);
expect(as.state.dirty).toBe(false);
});
});
+107
View File
@@ -0,0 +1,107 @@
import type { StorageAdapter } from "./storage.js";
import type { DocumentRef } from "./types.js";
import { clockLabel } from "./time.js";
export type AutosaveStatus = "saved" | "saving" | "unsaved";
export interface AutosaveState {
dirty: boolean;
saving: boolean;
status: AutosaveStatus;
lastSavedAt: string | null;
}
export type AutosaveListener = (state: AutosaveState) => void;
export interface AutosaveOptions {
/** Debounce window in ms. Defaults to 800. */
intervalMs?: number;
}
/**
* Debounced autosave engine. Tracks dirty/saving/saved state and exposes an
* `onStatus` listener for the top-bar indicator (● unsaved / ◌ saving / ✓ saved).
* See CHUNK #2 step 6.
*/
export function createAutosave(
adapter: StorageAdapter,
options: AutosaveOptions = {},
) {
const intervalMs = options.intervalMs ?? 800;
let timer: ReturnType<typeof setTimeout> | null = null;
let current: DocumentRef | null = null;
let lastSavedAt: string | null = null;
const listeners = new Set<AutosaveListener>();
function emit(): void {
const state = currentState();
for (const l of listeners) l(state);
}
function currentState(): AutosaveState {
return {
dirty: current !== null,
saving: timer !== null,
status: timer !== null ? "saving" : current !== null ? "unsaved" : "saved",
lastSavedAt,
};
}
function schedule(doc: DocumentRef): void {
current = doc;
if (timer) clearTimeout(timer);
timer = setTimeout(async () => {
timer = null;
const doc = current;
emit();
try {
if (doc) {
await adapter.saveDocument(doc);
lastSavedAt = new Date().toISOString();
current = null;
}
} catch (err) {
// Persist failure: keep dirty so the next tick retries.
console.error("Autosave failed:", err);
}
emit();
}, intervalMs);
emit();
}
return {
/** Live view of the current dirty/saving/saved state. */
get state(): AutosaveState {
return currentState();
},
/** Queue (or re-queue) a document for autosave. */
save(doc: DocumentRef): void {
schedule(doc);
},
/** Flush immediately if there is pending work. */
async flush(): Promise<void> {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (current) {
await adapter.saveDocument(current);
lastSavedAt = new Date().toISOString();
current = null;
emit();
}
},
onStatus(cb: AutosaveListener): () => void {
listeners.add(cb);
cb(currentState());
return () => listeners.delete(cb);
},
/** Render the top-bar indicator label, e.g. "✓ saved 14:03". */
indicatorLabel(): string {
const s = currentState();
if (s.saving) return "◌ saving";
if (s.dirty) return "● unsaved";
return lastSavedAt ? `✓ saved ${clockLabel(new Date(lastSavedAt))}` : "✓ saved";
},
};
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { findMentionsInDoc, collectMentionedEntityIds, docHasMentions } from "./backlinks.js";
import type { ProseMirrorDoc } from "./types.js";
const docWithMention: ProseMirrorDoc = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "She met " },
{ type: "CharacterMention", attrs: { entityId: "c1", name: "Mara" } },
{ type: "text", text: " at the harbor." },
],
},
],
};
describe("backlinks", () => {
it("finds the mention path for a known entity id", () => {
const paths = findMentionsInDoc(docWithMention, new Set(["c1"]), new Set());
expect(paths.length).toBe(1);
// paragraph(0) -> CharacterMention(1)
expect(paths[0]).toEqual([0, 1]);
});
it("ignores unknown entity ids", () => {
const paths = findMentionsInDoc(docWithMention, new Set(["zzz"]), new Set());
expect(paths.length).toBe(0);
});
it("collects mentioned entity ids across chapters", () => {
const ids = collectMentionedEntityIds([
{ id: "ch1", doc: docWithMention },
{
id: "ch2",
doc: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "CharacterMention", attrs: { entityId: "c2" } }],
},
],
},
},
]);
expect(ids).toEqual(new Set(["c1", "c2"]));
});
it("docHasMentions detects mention nodes", () => {
expect(docHasMentions(docWithMention)).toBe(true);
expect(docHasMentions({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "hi" }] }] })).toBe(false);
});
});
+84
View File
@@ -0,0 +1,84 @@
import type { ProseMirrorDoc } from "./types.js";
export interface Backlink {
chapterId: string;
title: string;
/** ProseMirror node paths where the entity is referenced. */
paths: number[][];
}
export interface MentionNode {
type: "CharacterMention";
attrs?: { entityId?: string; name?: string };
[key: string]: unknown;
}
/**
* Walk a document's JSON for mention nodes and entity-name text matches.
* Caching is the caller's responsibility (invalidate on doc save).
* See CHUNK #4 step 2.3.
*/
export function findMentionsInDoc(
doc: ProseMirrorDoc,
entityIds: Set<string>,
_entityNames: Set<string>,
): Backlink["paths"] {
const paths: number[][] = [];
const visit = (content: unknown[], start: number[]) => {
for (let i = 0; i < content.length; i++) {
const node = content[i] as Record<string, unknown> | undefined;
if (!node || typeof node !== "object") continue;
const path = [...start, i];
if (node.type === "CharacterMention") {
const attrs = (node.attrs as { entityId?: string } | undefined) ?? {};
if (attrs.entityId && entityIds.has(attrs.entityId)) {
paths.push(path);
}
}
if (Array.isArray(node.content)) {
visit(node.content as unknown[], path);
}
}
};
visit(doc.content ?? [], []);
return paths;
}
/** Collect the set of entity IDs mentioned across a set of chapters. */
export function collectMentionedEntityIds(
chapters: Array<{ id: string; doc: ProseMirrorDoc }>,
): Set<string> {
const ids = new Set<string>();
for (const ch of chapters) {
walkForIds(ch.doc, (entityId) => ids.add(entityId));
}
return ids;
}
function walkForIds(doc: ProseMirrorDoc, onId: (id: string) => void): void {
const visit = (content: unknown[]) => {
for (const node of content as Record<string, unknown>[]) {
if (!node || typeof node !== "object") continue;
if (node.type === "CharacterMention") {
const entityId = (node.attrs as { entityId?: string } | undefined)?.entityId;
if (entityId) onId(entityId);
}
if (Array.isArray(node.content)) visit(node.content as unknown[]);
}
};
visit(doc.content ?? []);
}
/** True when a document contains any explicit @-mention node. */
export function docHasMentions(doc: ProseMirrorDoc): boolean {
let found = false;
const visit = (content: unknown[]) => {
for (const node of content as Record<string, unknown>[]) {
if (!node) continue;
if (node.type === "CharacterMention") found = true;
if (Array.isArray(node.content)) visit(node.content as unknown[]);
}
};
visit(doc.content ?? []);
return found;
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { createUlid, isUlid, assertUlid } from "./ids.js";
describe("ids", () => {
it("generates valid, unique ULIDs", () => {
const a = createUlid();
const b = createUlid();
expect(isUlid(a)).toBe(true);
expect(isUlid(b)).toBe(true);
expect(a).not.toBe(b);
});
it("produces sortable ULIDs", () => {
const a = createUlid();
const b = createUlid();
// Monotonic ULIDs sort in creation order.
expect([a, b].sort()).toEqual([a, b].slice().sort());
});
it("rejects non-ULID strings", () => {
expect(isUlid("not-a-ulid")).toBe(false);
expect(isUlid("")).toBe(false);
expect(isUlid("01I0X0Y0Z0".padEnd(26, "0"))).toBe(false);
});
it("assertUlid throws on invalid input", () => {
expect(() => assertUlid("nope")).toThrow(/Invalid ULID/);
expect(() => assertUlid(createUlid())).not.toThrow();
});
});
+23
View File
@@ -0,0 +1,23 @@
import { ulid } from "ulid";
const ULID_REGEX = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
/** Generate a monotonic ULID. */
export function createUlid(): string {
return ulid();
}
/** Validate a ULID string. */
export function isUlid(value: string): boolean {
return ULID_REGEX.test(value);
}
/**
* Assert a value is a ULID, throwing a clear error otherwise.
* Used by the server to reject malformed IDs at the boundary.
*/
export function assertUlid(value: string): void {
if (!isUlid(value)) {
throw new Error(`Invalid ULID: ${value}`);
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* @pubscript/core — shared domain types, contracts, and utilities.
* Code against this package, never against a specific backend.
*/
export * from "./types.js";
export * from "./storage.js";
export * from "./ids.js";
export * from "./time.js";
export * from "./tokens.js";
export * from "./schema.js";
export * from "./schemas.js";
export * from "./autosave.js";
export * from "./backlinks.js";
export * from "./storage-server.js";
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { ENTITY_FIELDS, ENTITY_KINDS } from "./schema.js";
describe("schema ENTITY_FIELDS", () => {
it("covers every entity kind from PLAN.md §3.3", () => {
for (const kind of ["character", "location", "faction", "item", "glossary", "system", "note"]) {
expect(ENTITY_FIELDS[kind]).toBeDefined();
expect(ENTITY_FIELDS[kind]!.fields.length).toBeGreaterThan(0);
}
});
it("includes a list field and a select field", () => {
const character = ENTITY_FIELDS.character;
expect(character).toBeDefined();
expect(character!.fields.some((f) => f.type === "list")).toBe(true);
expect(character!.fields.some((f) => f.type === "select")).toBe(true);
});
it("exposes a stable, ordered kind list", () => {
expect(ENTITY_KINDS).toContain("character");
expect(ENTITY_KINDS.length).toBeGreaterThanOrEqual(7);
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* The entity field registry — single source of truth that drives both the
* generic entity editor (Chunk 4) and the server's validation. Adding a field
* to a kind here makes it appear in the UI with no per-entity code.
*/
export type FieldType = "text" | "textarea" | "select" | "list" | "link";
export interface FieldDef {
key: string;
label: string;
type: FieldType;
/** Required for `select` and `link`. */
options?: string[];
/** Human-readable description shown under the field. */
hint?: string;
}
export interface EntityKindDef {
kind: string;
label: string;
fields: FieldDef[];
}
export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
character: {
kind: "character",
label: "Character",
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] },
{ key: "bio", label: "Bio", type: "textarea" },
{ key: "appearance", label: "Appearance", type: "textarea" },
{ key: "voice", label: "Voice", type: "textarea" },
{ key: "arc", label: "Arc", type: "textarea" },
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
{ key: "tags", label: "Tags", type: "list" },
],
},
location: {
kind: "location",
label: "Location",
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "region", label: "Region", type: "text" },
{ key: "climate", label: "Climate", type: "text" },
{ key: "keyPeople", label: "Key People", type: "list" },
{ key: "description", label: "Description", type: "textarea" },
],
},
faction: {
kind: "faction",
label: "Faction",
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "goals", label: "Goals", type: "textarea" },
{ key: "rivals", label: "Rivals", type: "list" },
{ key: "allies", label: "Allies", type: "list" },
{ key: "description", label: "Description", type: "textarea" },
],
},
item: {
kind: "item",
label: "Item",
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "origin", label: "Origin", type: "textarea" },
{ key: "powers", label: "Powers", type: "list" },
{ key: "holder", label: "Holder", type: "text" },
],
},
glossary: {
kind: "glossary",
label: "Glossary",
fields: [
{ key: "name", label: "Term", type: "text" },
{ key: "definition", label: "Definition", type: "textarea" },
],
},
system: {
kind: "system",
label: "System",
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "rules", label: "Rules", type: "textarea" },
{ key: "limitations", label: "Limitations", type: "textarea" },
],
},
note: {
kind: "note",
label: "Note",
fields: [
{ key: "body", label: "Body", type: "textarea" },
{ key: "tags", label: "Tags", type: "list" },
],
},
};
/** All entity kinds, in display order. */
export const ENTITY_KINDS = Object.keys(ENTITY_FIELDS);
+118
View File
@@ -0,0 +1,118 @@
import { z } from "zod";
export const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
displayName: z.string(),
role: z.enum(["admin", "author"]),
createdAt: z.string(),
});
export const ProjectSchema = z.object({
id: z.string(),
projectId: z.string(),
title: z.string(),
ownerId: z.string(),
sharedWith: z.array(z.object({ userId: z.string(), perm: z.enum(["read", "edit"]) })),
settings: z.record(z.unknown()),
cover: z.object({ color: z.string(), emoji: z.string().optional() }),
createdAt: z.string(),
updatedAt: z.string(),
});
export const ProjectMetaSchema = ProjectSchema;
export const BookSchema = z.object({
id: z.string(),
projectId: z.string(),
title: z.string(),
order: z.number().int(),
chapters: z.array(z.any()),
});
export const ChapterSchema = z.object({
id: z.string(),
bookId: z.string(),
projectId: z.string(),
title: z.string(),
doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }),
rev: z.number().int(),
order: z.number().int(),
meta: z.object({ wordCount: z.number().int(), scenes: z.number().int(), updatedAt: z.string() }),
});
export const CharacterSchema = z.object({
id: z.string(),
projectId: z.string(),
name: z.string(),
role: z.string(),
bio: z.string(),
appearance: z.string(),
voice: z.string(),
relationships: z.string(),
arc: z.string(),
status: z.enum(["active", "archived"]),
tags: z.array(z.string()),
});
export const WorldItemSchema = z.object({
id: z.string(),
projectId: z.string(),
kind: z.string(),
name: z.string(),
fields: z.record(z.string()),
});
export const NoteSchema = z.object({
id: z.string(),
projectId: z.string(),
body: z.string(),
pinned: z.boolean(),
tags: z.array(z.string()),
linkedTo: z.array(z.string()),
});
export const EntitySchema = z.union([CharacterSchema, WorldItemSchema, NoteSchema]);
export const BoardSchema = z.object({
id: z.string(),
projectId: z.string(),
nodes: z.array(z.object({
id: z.string(),
kind: z.string(),
label: z.string(),
pos: z.object({ x: z.number(), y: z.number() }),
data: z.record(z.unknown()),
})),
edges: z.array(z.object({ id: z.string(), from: z.string(), to: z.string(), label: z.string().optional() })),
});
export const AISessionSchema = z.object({
id: z.string(),
projectId: z.string(),
role: z.string(),
model: z.string(),
settings: z.record(z.unknown()),
messages: z.array(z.object({ role: z.string(), content: z.string(), at: z.string() })),
suggestions: z.array(z.object({ id: z.string(), kind: z.string(), text: z.string(), at: z.string() })),
});
/**
* The schema registry: a single map of domain type name -> zod schema.
* Adapters and the server validate against these so both backends agree.
*/
export const schemas = {
User: UserSchema,
Project: ProjectSchema,
ProjectMeta: ProjectMetaSchema,
Book: BookSchema,
Chapter: ChapterSchema,
Character: CharacterSchema,
WorldItem: WorldItemSchema,
Note: NoteSchema,
Entity: EntitySchema,
Board: BoardSchema,
AISession: AISessionSchema,
} as const;
export type Schemas = typeof schemas;
+133
View File
@@ -0,0 +1,133 @@
import Dexie, { type Table } from "dexie";
import type {
AISession,
Board,
Chapter,
DocumentRef,
Entity,
NewProject,
Project,
ProjectMeta,
} from "./types.js";
import type { StorageAdapter } from "./storage.js";
import { createUlid } from "./ids.js";
import { nowIso } from "./time.js";
interface EntityRec {
key: string;
projectId: string;
kind: string;
data: Entity;
}
class PubScriptDB extends Dexie {
projects!: Table<Project, string>;
chapters!: Table<Chapter, string>;
entities!: Table<EntityRec, string>;
boards!: Table<Board, string>;
aiSessions!: Table<AISession, string>;
constructor() {
super("pubscript");
this.version(1).stores({
projects: "id, projectId",
chapters: "id, bookId, projectId",
entities: "key, projectId, kind",
boards: "id, projectId",
aiSessions: "id, projectId",
});
}
}
const db = new PubScriptDB();
const entityKey = (projectId: string, kind: string, id: string): string =>
`${projectId}|${kind}|${id}`;
const kindOf = (e: Entity): string =>
"fields" in e ? "world" : "body" in e ? "note" : "character";
/**
* The local (offline-first) `StorageAdapter`, backed by IndexedDB via Dexie.
* Implements the exact `StorageAdapter` contract so the app can run fully
* offline and sync later. `onEvent` is a no-op offline (no remote peers).
*/
export const storageLocal: StorageAdapter = {
async listProjects(): Promise<ProjectMeta[]> {
const rows = await db.projects.toArray();
return rows.map(
({ id, projectId, title, ownerId, sharedWith, settings, cover, createdAt, updatedAt }) => ({
id, projectId, title, ownerId, sharedWith, settings, cover, createdAt, updatedAt,
perm: "edit" as const, owned: true,
}),
);
},
async createProject(p: NewProject): Promise<Project> {
const id = createUlid();
const t = nowIso();
const project: Project = {
id,
projectId: id,
title: p.title,
ownerId: "local",
sharedWith: [],
settings: { language: p.language ?? "en" },
cover: { color: p.cover?.color ?? "#8b5cf6", ...(p.cover?.image ? { image: p.cover.image } : {}) },
createdAt: t,
updatedAt: t,
};
await db.projects.put(project);
return project;
},
async getProject(id: string): Promise<Project> {
const p = await db.projects.get(id);
if (!p) throw new Error(`project ${id} not found`);
return p;
},
async saveDocument(doc: DocumentRef): Promise<void> {
const existing = await db.chapters.get(doc.id);
const base: Chapter = existing ?? {
id: doc.id,
bookId: doc.bookId ?? "",
projectId: doc.projectId,
title: doc.title,
doc: doc.doc,
rev: 0,
order: 0,
meta: { wordCount: 0, scenes: [], updatedAt: nowIso() },
};
await db.chapters.put({
...base,
title: doc.title,
doc: doc.doc,
rev: doc.rev + 1,
meta: { ...base.meta, updatedAt: nowIso() },
});
},
async getChapter(id: string): Promise<Chapter | null> {
return (await db.chapters.get(id)) ?? null;
},
async saveEntity(e: Entity): Promise<void> {
const kind = kindOf(e);
await db.entities.put({ key: entityKey(e.projectId, kind, e.id), projectId: e.projectId, kind, data: e });
},
async saveBoard(b: Board): Promise<void> {
await db.boards.put(b);
},
async saveAISession(s: AISession): Promise<void> {
await db.aiSessions.put(s);
},
onEvent(): () => void {
// Offline: no remote live events to relay.
return () => {};
},
};
export type StorageLocal = typeof storageLocal;
+132
View File
@@ -0,0 +1,132 @@
import type {
AISession,
Board,
Chapter,
DocumentRef,
Entity,
NewProject,
Project,
ProjectMeta,
} from "./types.js";
import type { StorageAdapter, StoreEvent } from "./storage.js";
export interface ServerClientOptions {
/** Base URL of the PubScript server, e.g. `http://localhost:8080`. */
baseUrl: string;
/** Provide a custom fetch (e.g. for tests). Defaults to global fetch. */
fetch?: typeof fetch;
/** Returns the current bearer token, if any. */
getToken?: () => string | null | Promise<string | null>;
}
/**
* The server `StorageAdapter`: the same contract as `storageLocal`, backed by
* the PubScript REST API. This is what makes "code against core, not a
* backend" concrete — the app uses one interface whether it talks to the
* server or to IndexedDB.
*/
export function createServerClient(opts: ServerClientOptions): StorageAdapter {
const fetchImpl: typeof fetch = opts.fetch ?? fetch;
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {};
if (body !== undefined) headers["content-type"] = "application/json";
const token = await opts.getToken?.();
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetchImpl(`${opts.baseUrl}${path}`, {
method,
headers,
body: body === undefined ? null : JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`PubScript ${method} ${path} -> ${res.status}: ${text}`);
}
return (await res.json()) as T;
}
return {
async listProjects(): Promise<ProjectMeta[]> {
const r = await req<{ projects: ProjectMeta[] }>("GET", "/api/projects");
return r.projects;
},
async createProject(p: NewProject): Promise<Project> {
const r = await req<{ project: Project }>("POST", "/api/projects", {
title: p.title,
language: p.language,
cover: p.cover,
});
return r.project;
},
async getProject(id: string): Promise<Project> {
const r = await req<{ project: Project }>("GET", `/api/projects/${id}`);
return r.project;
},
async saveDocument(doc: DocumentRef): Promise<void> {
await req("POST", `/api/chapters/${doc.id}/autosave`, {
rev: doc.rev,
doc: doc.doc,
});
},
async getChapter(id: string): Promise<Chapter | null> {
try {
const r = await req<{ chapter: Chapter }>("GET", `/api/chapters/${id}`);
return r.chapter;
} catch {
return null;
}
},
async saveEntity(e: Entity): Promise<void> {
if ("fields" in e) {
// WorldItem — the server nests the world kind inside `fields`.
await req("POST", `/api/projects/${e.projectId}/entities`, {
id: e.id, kind: "world", name: e.name, fields: { kind: e.kind, ...e.fields },
});
} else if ("body" in e) {
// Note
await req("POST", `/api/projects/${e.projectId}/entities`, {
id: e.id, kind: "note", body: e.body, pinned: e.pinned, tags: e.tags, linkedTo: e.linkedTo,
});
} else {
// Character
await req("POST", `/api/projects/${e.projectId}/entities`, {
id: e.id, kind: "character", name: e.name, role: e.role, bio: e.bio,
appearance: e.appearance, voice: e.voice, relationships: e.relationships,
arc: e.arc, status: e.status, tags: e.tags,
});
}
},
async saveBoard(b: Board): Promise<void> {
await req("POST", `/api/projects/${b.projectId}/boards`, {
id: b.id,
nodes: b.nodes,
edges: b.edges,
});
},
async saveAISession(s: AISession): Promise<void> {
await req("POST", `/api/projects/${s.projectId}/ai/sessions`, {
id: s.id,
role: s.role,
model: s.model,
settings: s.settings,
messages: s.messages,
suggestions: s.suggestions,
});
},
onEvent(_cb: (e: StoreEvent) => void): () => void {
// Live updates arrive over the `/api/ws` socket; wiring that up is the
// app's concern. For the contract, return a no-op unsubscribe.
return () => {};
},
};
}
export type StorageServer = ReturnType<typeof createServerClient>;
+144
View File
@@ -0,0 +1,144 @@
import "fake-indexeddb/auto";
import { describe, expect, it } from "vitest";
import { createServerClient, type ProseMirrorDoc, type StorageAdapter } from "@pubscript/core";
import { storageLocal } from "@pubscript/core/local";
const doc = (text: string): ProseMirrorDoc => ({ type: "doc", content: [{ type: "text", text }] });
/**
* A minimal in-memory REST mock implementing exactly the endpoints the
* `StorageAdapter` scenario exercises. This lets us run the *server* adapter
* in Node without a live server, and compare it against the *local* adapter.
*/
function mockServerFetch(): typeof fetch {
const projects = new Map<string, any>();
const chapters = new Map<string, any>();
const entities = new Map<string, any>();
const boards = new Map<string, any>();
const sessions = new Map<string, any>();
let n = 0;
const id = () => `id-${++n}`;
const handler = async (url: string, init?: RequestInit): Promise<Response> => {
const u = new URL(url);
const method = (init?.method ?? "GET").toUpperCase();
const body = init?.body ? (JSON.parse(init.body as string) as any) : undefined;
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
if (method === "POST" && u.pathname === "/api/projects") {
const p = {
id: id(), projectId: "", title: body.title, ownerId: "u1", sharedWith: [],
settings: body.settings ?? {}, cover: body.cover ?? { color: "#000" }, createdAt: "t", updatedAt: "t",
};
p.projectId = p.id;
projects.set(p.id, p);
return json({ project: p });
}
if (method === "GET" && u.pathname === "/api/projects") return json({ projects: [...projects.values()] });
let m = u.pathname.match(/^\/api\/projects\/([^/]+)$/);
if (method === "GET" && m) {
const p = projects.get(m[1]!);
return p ? json({ project: p }) : json({ error: "not_found" }, 404);
}
m = u.pathname.match(/^\/api\/chapters\/([^/]+)\/autosave$/);
if (method === "POST" && m) {
const existing = chapters.get(m[1]!);
const rev = (existing?.rev ?? 0) + 1;
const ch = { id: m[1]!, projectId: "proj", title: "Untitled", doc: body.doc, rev, meta: { wordCount: body.wordCount, scenes: 0, updatedAt: "t" } };
chapters.set(m[1]!, ch);
return json({ chapter: ch, rev });
}
m = u.pathname.match(/^\/api\/chapters\/([^/]+)$/);
if (method === "GET" && m) {
const ch = chapters.get(m[1]!);
return ch ? json({ chapter: ch }) : json({ error: "not_found" }, 404);
}
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/entities$/);
if (method === "POST" && m) {
const e = { id: body.id ?? id(), projectId: m[1]!, ...body };
entities.set(`${m[1]!}|${body.kind}|${e.id}`, e);
return json({ entity: e });
}
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/boards$/);
if (method === "POST" && m) {
const b = { id: body.id ?? id(), projectId: m[1]!, nodes: body.nodes ?? [], edges: body.edges ?? [] };
boards.set(b.id, b);
return json({ board: b });
}
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/ai\/sessions$/);
if (method === "POST" && m) {
const s = { id: body.id ?? id(), projectId: m[1]!, role: body.role, model: body.model, settings: body.settings ?? {}, messages: body.messages ?? [], suggestions: body.suggestions ?? [] };
sessions.set(s.id, s);
return json({ session: s });
}
return json({ error: "not_found" }, 404);
};
return (async (input: string | URL | Request, init?: RequestInit) =>
handler(typeof input === "string" ? input : input.toString(), init)) as unknown as typeof fetch;
}
/** The shared scenario both adapters must pass identically. */
async function runScenario(adapter: StorageAdapter) {
const project = await adapter.createProject({ title: "Test Novel" });
const fetched = await adapter.getProject(project.id);
const list = await adapter.listProjects();
const docId = "doc-1";
await adapter.saveDocument({ id: docId, projectId: project.id, title: "Chapter 1", doc: doc("Hello world"), rev: 0 });
const chapter = await adapter.getChapter(docId);
const chapterText =
chapter && "content" in (chapter.doc as object)
? ((chapter.doc as { content?: Array<{ text?: string }> }).content?.[0]?.text)
: undefined;
await adapter.saveEntity({
id: "char-1", projectId: project.id, name: "Ada", role: "protagonist", bio: "x",
appearance: "", voice: "", relationships: [], arc: "", status: "active", tags: [],
});
await adapter.saveBoard({
id: "board-1", projectId: project.id,
nodes: [{ id: "n1", type: "character", position: { x: 0, y: 0 }, data: { refId: "char-1" } }],
edges: [],
});
await adapter.saveAISession({
id: "sess-1", projectId: project.id, role: "editor", model: "m", settings: {}, messages: [], suggestions: [],
});
const unsub = adapter.onEvent(() => {});
expect(typeof unsub).toBe("function");
unsub();
return {
title: project.title,
hasId: typeof project.id === "string" && project.id.length > 0,
fetchedTitle: fetched.title,
listed: list.some((p) => p.title === "Test Novel"),
chapterText,
chapterRev: chapter?.rev,
};
}
const EXPECTED = {
title: "Test Novel",
hasId: true,
fetchedTitle: "Test Novel",
listed: true,
chapterText: "Hello world",
chapterRev: 1,
};
describe("StorageAdapter contract (web/local vs server)", () => {
it("local (IndexedDB) adapter honors the contract", async () => {
await expect(runScenario(storageLocal)).resolves.toMatchObject(EXPECTED);
});
it("server (REST) adapter honors the contract", async () => {
const client = createServerClient({ baseUrl: "http://test", fetch: mockServerFetch() });
await expect(runScenario(client)).resolves.toMatchObject(EXPECTED);
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* The StorageAdapter contract — the seam that makes web / desktop / local modes
* possible. Defined once here; every feature codes against this, never a backend.
* See PLAN.md §3.4.
*/
import type {
Board,
Chapter,
DocumentRef,
Entity,
NewProject,
Project,
ProjectMeta,
AISession,
} from "./types.js";
/** Live-update events fanned out to subscribed clients. */
export type StoreEvent =
| { type: "doc-changed"; projectId: string; docId: string; rev: number }
| { type: "entity-changed"; projectId: string; entityId: string; kind: string }
| { type: "board-changed"; projectId: string; boardId: string }
| { type: "ai-session-changed"; projectId: string; sessionId: string }
| { type: "presence"; projectId: string; users: unknown[] }
| { type: "conflict"; docId: string; remoteRev: number };
export interface StorageAdapter {
// projects
listProjects(): Promise<ProjectMeta[]>;
createProject(p: NewProject): Promise<Project>;
getProject(id: string): Promise<Project>;
// content (debounced autosave)
saveDocument(doc: DocumentRef): Promise<void>;
getChapter(id: string): Promise<Chapter | null>;
saveEntity(e: Entity): Promise<void>;
saveBoard(b: Board): Promise<void>;
saveAISession(s: AISession): Promise<void>;
// live updates
onEvent(cb: (e: StoreEvent) => void): () => void;
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { nowIso, parseIso, toIso, clockLabel } from "./time.js";
describe("time", () => {
it("nowIso returns a valid UTC ISO string with Z", () => {
const s = nowIso();
expect(s).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(new Date(s).toISOString()).toBe(s);
});
it("round-trips through parseIso/toIso", () => {
const s = "2026-10-23T14:24:00.000Z";
expect(toIso(parseIso(s))).toBe(s);
});
it("parseIso throws on garbage", () => {
expect(() => parseIso("not-a-date")).toThrow(/Invalid ISO/);
});
it("clockLabel formats HH:MM", () => {
expect(clockLabel(new Date("2026-10-23T14:03:00.000Z"))).toBe("14:03");
});
});
+25
View File
@@ -0,0 +1,25 @@
/** UTC ISO-8601 time helpers. See PLAN.md §3.3. */
/** Current time as a UTC ISO-8601 string with Z suffix. */
export function nowIso(): string {
return new Date().toISOString();
}
/** Parse an ISO timestamp to a Date, throwing on invalid input. */
export function parseIso(value: string): Date {
const d = new Date(value);
if (Number.isNaN(d.getTime())) {
throw new Error(`Invalid ISO timestamp: ${value}`);
}
return d;
}
/** Format a Date as a UTC ISO-8601 string. */
export function toIso(d: Date): string {
return d.toISOString();
}
/** Compact HH:MM label (UTC) for the autosave indicator. */
export function clockLabel(d: Date = new Date()): string {
return d.toISOString().slice(11, 16);
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { countTokens, allocateBudget, DEFAULT_BUDGET } from "./tokens.js";
describe("tokens", () => {
it("countTokens returns 0 for empty input", async () => {
expect(await countTokens("")).toBe(0);
});
it("counts tokens for a known string (approx)", async () => {
// ~4 chars per token is the rough heuristic; this asserts a sane range.
const n = await countTokens("The quick brown fox jumps over the lazy dog.");
expect(n).toBeGreaterThan(0);
expect(n).toBeLessThan(40);
});
it("allocateBudget respects percentages of total", () => {
const b = allocateBudget(DEFAULT_BUDGET);
expect(b.total).toBe(200_000);
// 10% system ≈ 20000, 40% context ≈ 80000, etc. (floor, so allow small slack).
expect(b.system).toBeCloseTo(20_000, -2);
expect(b.context).toBeCloseTo(80_000, -2);
expect(b.history).toBeCloseTo(60_000, -2);
expect(b.output).toBeCloseTo(40_000, -2);
});
it("budget slots sum approximately to total", () => {
const b = allocateBudget({ system: 25, context: 25, history: 25, output: 25 });
expect(b.system + b.context + b.history + b.output).toBeGreaterThan(199_990);
expect(b.total).toBe(200_000);
});
it("throws when percentages sum to zero", () => {
expect(() => allocateBudget({ system: 0, context: 0, history: 0, output: 0 })).toThrow(
/positive/,
);
});
});
+63
View File
@@ -0,0 +1,63 @@
import api from "gpt-tokenizer";
/**
* Token counting + budget allocation. See PLAN.md §5.2.
*
* Uses gpt-tokenizer's o200k_base encoding (the default export) as the source
* of truth. The default export is a singleton, so no lazy-loading is needed.
*/
/** Count tokens in a string using the o200k_base encoding. */
export function countTokens(text: string): number {
if (!text) return 0;
return api.encode(text).length;
}
/**
* Allocate a token budget across four slots. Percentages sum to 100% of `total`
* (defaulting to a 200k context window). Returns per-slot integer caps.
*/
export interface BudgetInputs {
system: number;
context: number;
history: number;
output: number;
/** Total context window size. Defaults to 200_000. */
total?: number;
}
export interface Budget {
system: number;
context: number;
history: number;
output: number;
total: number;
}
export function allocateBudget({
system,
context,
history,
output,
total = 200_000,
}: BudgetInputs): Budget {
const sum = system + context + history + output;
if (sum <= 0) {
throw new Error("Budget percentages must sum to a positive number");
}
return {
system: Math.floor((system / sum) * total),
context: Math.floor((context / sum) * total),
history: Math.floor((history / sum) * total),
output: Math.floor((output / sum) * total),
total,
};
}
/** Default budget: system+role 10%, context 40%, history 30%, output 20%. */
export const DEFAULT_BUDGET: BudgetInputs = {
system: 10,
context: 40,
history: 30,
output: 20,
};
+259
View File
@@ -0,0 +1,259 @@
/**
* Core domain types — the single source of truth for the PubScript data model.
*
* These interfaces are consumed by the app, the packages, AND the server.
* Code against `packages/core`, never against a specific backend.
*
* All IDs are ULIDs (sortable, collision-free). All timestamps are UTC ISO-8601.
* See PLAN.md §3.3.
*/
/** A user's role within an instance. */
export type Role = "admin" | "author";
/** Permission levels a user may hold over a project. */
export type Permission = "read" | "edit";
export interface User {
id: string;
email: string;
displayName: string;
role: Role;
/** Optional, never required in v1. */
passKey?: string;
createdAt: string;
}
export interface Project {
id: string;
projectId: string;
title: string;
ownerId: string;
sharedWith: Array<{ userId: string; perm: Permission }>;
settings: ProjectSettings;
cover: Cover;
createdAt: string;
updatedAt: string;
}
export interface ProjectSettings {
/** ISO language code used for spellcheck / grammar. */
language: string;
/** Default AI provider id for this project. */
defaultProviderId?: string;
[key: string]: unknown;
}
export interface Cover {
color: string;
/** Optional data-URI or remote URL. */
image?: string;
}
/** Reduced project shape for list views. */
export interface ProjectMeta {
id: string;
projectId: string;
title: string;
ownerId: string;
cover: Cover;
createdAt: string;
updatedAt: string;
/** Permission the current user holds over this project. */
perm: Permission;
/** True when the current user owns the project. */
owned: boolean;
}
export interface NewProject {
title: string;
cover?: Partial<Cover>;
language?: string;
}
/** JSON form of a ProseMirror document (the TipTap schema). */
export interface ProseMirrorDoc {
type: "doc";
content: JsonNode[];
}
export type JsonNode = Record<string, unknown>;
export interface Book {
id: string;
projectId: string;
title: string;
order: number;
chapters: Chapter[];
}
export interface Chapter {
id: string;
bookId: string;
projectId: string;
title: string;
doc: ProseMirrorDoc;
/** Optimistic-concurrency revision counter. */
rev: number;
order: number;
meta: ChapterMeta;
}
export interface ChapterMeta {
wordCount: number;
/** Scene-break positions, computed from `doc`. */
scenes: Array<{ from: number; to: number }>;
updatedAt: string;
}
/** A scene is a `sceneBreak` node inside a document (Chunk 3). */
export interface SceneMeta {
id: string;
title?: string;
}
export interface Character {
id: string;
projectId: string;
name: string;
role: string;
bio: string;
appearance: string;
voice: string;
relationships: Array<{ targetId: string; kind: RelationshipKind; note: string }>;
arc: string;
status: string;
tags: string[];
}
export type RelationshipKind =
| "ally"
| "rival"
| "family"
| "mentor"
| "romantic"
| "custom";
export type WorldItemKind = "location" | "faction" | "item" | "glossary" | "system";
export interface WorldItem {
id: string;
projectId: string;
kind: WorldItemKind;
name: string;
fields: Record<string, string>;
}
export type BeatTemplate =
| "three-act"
| "save-the-cat"
| "seven-point"
| "custom";
export type BeatStatus = "draft" | "revised" | "locked";
export interface PlotBeat {
id: string;
projectId: string;
template: BeatTemplate;
title: string;
description: string;
status: BeatStatus;
chapterId?: string;
order: number;
notes: string;
}
export interface TimelineEvent {
id: string;
projectId: string;
/** Either a fictional calendar or a real date. */
when: { era?: string; day?: number } | string;
title: string;
description: string;
chapterId?: string;
links: string[];
}
export interface FlowNode {
id: string;
type: "character" | "world" | "chapter" | "note" | "image" | "group";
position: { x: number; y: number };
data: Record<string, unknown>;
}
export interface FlowEdge {
id: string;
source: string;
target: string;
type: "leads-to" | "contrasts" | "causes" | "custom";
label?: string;
}
export interface Board {
id: string;
projectId: string;
nodes: FlowNode[];
edges: FlowEdge[];
}
export interface Note {
id: string;
projectId: string;
body: string;
pinned: boolean;
tags: string[];
linkedTo?: string;
}
export type SuggestionKind =
| "replace"
| "insert"
| "delete"
| "entity-update"
| "draft";
export type SuggestionStatus = "pending" | "accepted" | "rejected" | "pinned";
export interface SuggestionTarget {
docId: string;
/** A stable node anchor, not a raw offset (see PLAN.md §5.5). */
range?: { nodeId: string };
nodePath?: number[];
}
export interface Suggestion {
id: string;
sessionId: string;
target: SuggestionTarget;
before: string;
after: string;
rationale: string;
kind: SuggestionKind;
status: SuggestionStatus;
}
export type AISessionRole = "editor" | "reader" | "publisher" | "co-author";
export interface AISession {
id: string;
projectId: string;
role: AISessionRole;
model: string;
settings: Record<string, unknown>;
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
suggestions: Suggestion[];
}
/** A reference to a document that can be saved through the adapter. */
export interface DocumentRef {
id: string;
projectId: string;
bookId?: string;
title: string;
doc: ProseMirrorDoc;
rev: number;
}
/** A generic entity — the discriminated union handled by the entity engine. */
export type Entity = Character | WorldItem | Note;
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
},
});
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@pubscript/ui",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
},
"scripts": {
"dev": "echo \"@pubscript/ui has no dev server\" (exit 0)",
"build": "tsc --noEmit",
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@pubscript/core": "workspace:*",
"react": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+416
View File
@@ -0,0 +1,416 @@
/* ============================================================================
* PubScript design system — CSS-variable token map + component styles.
* See PLAN.md §4.7 and CHUNK #1 step 3.
*
* All colors are theme tokens (--ps-*). Accent + density are runtime overrides.
* ============================================================================ */
:root {
--ps-accent: #7c8bf5;
--ps-density: 12px;
--ps-radius: 8px;
--ps-sidebar-w: 260px;
--ps-topbar-h: 48px;
--ps-font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--ps-font-body: Georgia, "Times New Roman", serif;
--ps-font-mono: "SFMono-Regular", ui-monospace, "Cascadia Code", Consolas, monospace;
}
/* ---- Theme token map (applied by applyTheme) ---- */
:root[data-theme="ink"] {
--ps-bg: #0e0f13;
--ps-surface: #17181f;
--ps-surface-2: #20222c;
--ps-border: #2a2d3a;
--ps-text: #e6e8ee;
--ps-text-muted: #9aa0b0;
--ps-accent: #7c8bf5;
--ps-accent-contrast: #0e0f13;
--ps-danger: #f16d7a;
--ps-success: #5fd08a;
--ps-warning: #f0b754;
}
:root[data-theme="parchment"] {
--ps-bg: #f6f1e7;
--ps-surface: #fffdf7;
--ps-surface-2: #f2ead8;
--ps-border: #e2d6bd;
--ps-text: #2c2720;
--ps-text-muted: #6f6656;
--ps-accent: #b5793a;
--ps-accent-contrast: #fffdf7;
--ps-danger: #c0392b;
--ps-success: #2e7d4f;
--ps-warning: #b8860b;
}
:root[data-theme="nocturne"] {
--ps-bg: #0c0b1e;
--ps-surface: #171533;
--ps-surface-2: #221f4a;
--ps-border: #2e2a5e;
--ps-text: #e8e6ff;
--ps-text-muted: #a49fcf;
--ps-accent: #9d7bff;
--ps-accent-contrast: #0c0b1e;
--ps-danger: #ff7b9c;
--ps-success: #6ee7a8;
--ps-warning: #ffd27b;
}
:root[data-theme="terminal"] {
--ps-bg: #05070a;
--ps-surface: #0a0f14;
--ps-surface-2: #0f1720;
--ps-border: #16232e;
--ps-text: #33ff9b;
--ps-text-muted: #2c8a63;
--ps-accent: #33ff9b;
--ps-accent-contrast: #05070a;
--ps-danger: #ff6b6b;
--ps-success: #33ff9b;
--ps-warning: #ffd93d;
}
* { box-sizing: border-box; }
html, body, #root {
height: 100%;
margin: 0;
}
body {
background: var(--ps-bg);
color: var(--ps-text);
font-family: var(--ps-font-ui);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ---- App shell ---- */
.ps-app {
display: grid;
grid-template-columns: var(--ps-sidebar-w) 1fr;
grid-template-rows: var(--ps-topbar-h) 1fr;
grid-template-areas:
"sidebar topbar"
"sidebar main";
height: 100vh;
overflow: hidden;
}
.ps-sidebar {
grid-area: sidebar;
background: var(--ps-surface);
border-right: 1px solid var(--ps-border);
overflow-y: auto;
padding: var(--ps-density);
}
.ps-topbar {
grid-area: topbar;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
border-bottom: 1px solid var(--ps-border);
background: var(--ps-surface);
}
.ps-main {
grid-area: main;
overflow: auto;
}
.ps-content {
max-width: 820px;
margin: 0 auto;
padding: 32px 24px 96px;
font-family: var(--ps-font-body);
font-size: 17px;
line-height: 1.75;
}
/* ---- Sidebar nav ---- */
.ps-logo {
font-weight: 700;
font-size: 16px;
letter-spacing: -0.02em;
padding: 6px 8px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.ps-logo .ps-logo-mark { color: var(--ps-accent); }
.ps-nav-section-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ps-text-muted);
padding: 12px 8px 4px;
}
.ps-nav-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
background: transparent;
color: var(--ps-text);
text-align: left;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
}
.ps-nav-item:hover { background: var(--ps-surface-2); }
.ps-nav-item.is-active { background: var(--ps-accent); color: var(--ps-accent-contrast); }
.ps-nav-item .ps-nav-icon { width: 18px; text-align: center; opacity: 0.85; }
/* ---- Buttons ---- */
.ps-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid transparent;
border-radius: 6px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
white-space: nowrap;
}
.ps-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ps-btn-sm { padding: 4px 10px; font-size: 13px; }
.ps-btn-md { padding: 7px 14px; font-size: 14px; }
.ps-btn-lg { padding: 10px 20px; font-size: 15px; }
.ps-btn-primary { background: var(--ps-accent); color: var(--ps-accent-contrast); }
.ps-btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
.ps-btn-secondary { background: var(--ps-surface-2); border-color: var(--ps-border); color: var(--ps-text); }
.ps-btn-secondary:hover:not(:disabled) { background: var(--ps-border); }
.ps-btn-ghost { background: transparent; color: var(--ps-text); }
.ps-btn-ghost:hover:not(:disabled) { background: var(--ps-surface-2); }
.ps-btn-danger { background: var(--ps-danger); color: #fff; }
.ps-btn-spinner {
width: 14px; height: 14px; border-radius: 50%;
border: 2px solid currentColor; border-top-color: transparent;
animation: ps-spin 0.6s linear infinite;
}
@keyframes ps-spin { to { transform: rotate(360deg); } }
/* ---- Fields ---- */
.ps-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
.ps-field-label { font-size: 13px; font-weight: 600; color: var(--ps-text-muted); }
.ps-input, .ps-select {
padding: 8px 10px;
border: 1px solid var(--ps-border);
border-radius: 6px;
background: var(--ps-bg);
color: var(--ps-text);
font-family: inherit;
font-size: 14px;
}
.ps-input:focus, .ps-select:focus {
outline: none;
border-color: var(--ps-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ps-accent) 30%, transparent);
}
.ps-input.is-error, .ps-select.is-error { border-color: var(--ps-danger); }
.ps-field-hint { font-size: 12px; color: var(--ps-text-muted); margin: 0; }
.ps-field-hint:only-child { color: var(--ps-danger); }
/* ---- Modal ---- */
.ps-modal-root { position: fixed; inset: 0; z-index: 1000; }
.ps-modal-overlay {
position: absolute; inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.ps-modal {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: min(560px, 92vw);
max-height: 88vh;
display: flex; flex-direction: column;
background: var(--ps-surface);
border: 1px solid var(--ps-border);
border-radius: 10px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
}
.ps-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 18px; border-bottom: 1px solid var(--ps-border);
}
.ps-modal-header h2 { margin: 0; font-size: 17px; }
.ps-modal-body { padding: 18px; overflow-y: auto; }
.ps-modal-footer {
display: flex; justify-content: flex-end; gap: 10px;
padding: 14px 18px; border-top: 1px solid var(--ps-border);
}
.ps-icon-btn {
border: none; background: transparent; color: var(--ps-text-muted);
cursor: pointer; font-size: 16px; line-height: 1; padding: 4px;
border-radius: 4px;
}
.ps-icon-btn:hover { background: var(--ps-surface-2); color: var(--ps-text); }
/* ---- Drawer ---- */
.ps-drawer-overlay {
position: fixed; inset: 0; z-index: 990;
background: rgba(0, 0, 0, 0.4);
}
.ps-drawer {
position: fixed; top: 0; bottom: 0; z-index: 991;
width: min(420px, 92vw);
background: var(--ps-surface);
border-left: 1px solid var(--ps-border);
display: flex; flex-direction: column;
box-shadow: -12px 0 40px rgba(0, 0, 0, 0.3);
}
.ps-drawer-left { border-left: none; border-right: 1px solid var(--ps-border); box-shadow: 12px 0 40px rgba(0,0,0,0.3); }
.ps-drawer-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 18px; border-bottom: 1px solid var(--ps-border);
}
.ps-drawer-header h2 { margin: 0; font-size: 17px; }
.ps-drawer-body { padding: 18px; overflow-y: auto; }
/* ---- Tooltip ---- */
.ps-tooltip-wrap { position: relative; display: inline-flex; }
.ps-tooltip {
position: absolute;
z-index: 1100;
background: var(--ps-text);
color: var(--ps-bg);
font-size: 12px;
padding: 4px 8px;
border-radius: 5px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s;
}
.ps-tooltip-wrap:hover .ps-tooltip { opacity: 1; }
.ps-tooltip-wrap[data-placement="bottom"] .ps-tooltip { top: 100%; left: 50%; transform: translate(-50%, 6px); }
.ps-tooltip-wrap[data-placement="top"] .ps-tooltip { bottom: 100%; left: 50%; transform: translate(-50%, -6px); }
/* ---- Tabs ---- */
.ps-tabs { display: flex; flex-direction: column; height: 100%; }
.ps-tabs-nav {
display: flex; gap: 2px; border-bottom: 1px solid var(--ps-border);
}
.ps-tab {
border: none; background: transparent; color: var(--ps-text-muted);
padding: 10px 14px; cursor: pointer; font-size: 14px;
border-bottom: 2px solid transparent; font-family: inherit;
}
.ps-tab:hover { color: var(--ps-text); }
.ps-tab.is-active { color: var(--ps-text); border-bottom-color: var(--ps-accent); }
.ps-tab-badge {
display: inline-block; min-width: 18px; height: 18px; padding: 0 5px;
border-radius: 9px; background: var(--ps-surface-2); font-size: 11px;
line-height: 18px; text-align: center; margin-left: 6px;
}
.ps-tabs-panel { padding: 16px 0; }
/* ---- Badge ---- */
.ps-badge {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 12px; font-weight: 600;
}
.ps-badge-neutral { background: var(--ps-surface-2); color: var(--ps-text-muted); }
.ps-badge-accent { background: color-mix(in srgb, var(--ps-accent) 22%, transparent); color: var(--ps-accent); }
.ps-badge-success { background: color-mix(in srgb, var(--ps-success) 22%, transparent); color: var(--ps-success); }
.ps-badge-danger { background: color-mix(in srgb, var(--ps-danger) 22%, transparent); color: var(--ps-danger); }
.ps-badge-warning { background: color-mix(in srgb, var(--ps-warning) 22%, transparent); color: var(--ps-warning); }
/* ---- Empty state ---- */
.ps-emptystate {
text-align: center; padding: 48px 24px; color: var(--ps-text-muted);
}
.ps-emptystate-icon { font-size: 36px; margin-bottom: 8px; }
.ps-emptystate-title { margin: 0 0 6px; color: var(--ps-text); font-size: 16px; }
.ps-emptystate-desc { margin: 0 0 16px; }
.ps-emptystate-action { display: flex; justify-content: center; gap: 10px; }
/* ---- Spinner ---- */
.ps-spinner { display: inline-flex; }
.ps-spinner > span {
display: inline-block; border-radius: 50%;
border: 2px solid currentColor; border-top-color: transparent;
animation: ps-spin 0.6s linear infinite;
}
.ps-spinner-sm > span { width: 14px; height: 14px; border-width: 2px; }
.ps-spinner-md > span { width: 24px; height: 24px; border-width: 2.5px; }
.ps-spinner-lg > span { width: 40px; height: 40px; border-width: 3px; }
/* ---- Toasts ---- */
.ps-toast-viewport {
position: fixed; bottom: 20px; right: 20px; z-index: 2000;
display: flex; flex-direction: column; gap: 8px; max-width: 360px;
}
.ps-toast {
padding: 12px 16px; border-radius: 8px;
background: var(--ps-surface-2); border: 1px solid var(--ps-border);
color: var(--ps-text); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
border-left: 3px solid var(--ps-accent);
}
.ps-toast-success { border-left-color: var(--ps-success); }
.ps-toast-error { border-left-color: var(--ps-danger); }
.ps-toast-warning { border-left-color: var(--ps-warning); }
/* ---- Command palette ---- */
.ps-command-palette-overlay {
position: fixed; inset: 0; z-index: 3000;
background: rgba(0, 0, 0, 0.55);
display: flex; align-items: flex-start; justify-content: center;
padding-top: 12vh;
}
.ps-command-palette {
width: min(620px, 92vw); max-height: 60vh;
background: var(--ps-surface); border: 1px solid var(--ps-border);
border-radius: 12px; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
display: flex; flex-direction: column; overflow: hidden;
}
.ps-command-input {
width: 100%; padding: 16px 18px; font-size: 16px;
border: none; background: transparent; color: var(--ps-text);
border-bottom: 1px solid var(--ps-border); font-family: inherit;
}
.ps-command-input:focus { outline: none; }
.ps-command-list { overflow-y: auto; padding: 8px; }
.ps-command-item {
display: flex; align-items: center; gap: 12px; width: 100%;
padding: 10px 12px; border: none; background: transparent; color: var(--ps-text);
text-align: left; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 14px;
}
.ps-command-item:hover, .ps-command-item.is-active { background: var(--ps-accent); color: var(--ps-accent-contrast); }
.ps-command-item .ps-command-icon { width: 20px; text-align: center; opacity: 0.8; }
.ps-command-item .ps-command-kbd { margin-left: auto; font-size: 12px; color: var(--ps-text-muted); }
.ps-command-empty { padding: 24px; text-align: center; color: var(--ps-text-muted); }
/* ---- Settings ---- */
.ps-settings { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
.ps-settings-row {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--ps-border);
}
.ps-settings-row:last-child { border-bottom: none; }
.ps-settings-label { font-weight: 600; }
.ps-settings-desc { font-size: 13px; color: var(--ps-text-muted); margin: 2px 0 0; }
.ps-settings-group { margin-bottom: 24px; }
.ps-settings-group h3 { margin: 0 0 12px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ps-text-muted); }
/* ---- Theme swatches ---- */
.ps-swatches { display: flex; gap: 10px; flex-wrap: wrap; }
.ps-swatch {
width: 44px; height: 44px; border-radius: 8px; border: 2px solid transparent;
cursor: pointer; position: relative;
}
.ps-swatch.is-active { border-color: var(--ps-text); }
/* ---- Scrollbars ---- */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--ps-border); border-radius: 5px; border: 2px solid var(--ps-bg); }
::-webkit-scrollbar-thumb:hover { background: var(--ps-text-muted); }
/* ---- Focus visible ---- */
:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }
+10
View File
@@ -0,0 +1,10 @@
export type BadgeVariant = "neutral" | "accent" | "success" | "danger" | "warning";
export interface BadgeProps {
children: string;
variant?: BadgeVariant;
}
export function Badge({ children, variant = "neutral" }: BadgeProps) {
return <span className={`ps-badge ps-badge-${variant}`}>{children}</span>;
}
+33
View File
@@ -0,0 +1,33 @@
import { type ButtonHTMLAttributes } from "react";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export type ButtonSize = "sm" | "md" | "lg";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
/** Show a spinner instead of content while loading. */
loading?: boolean;
}
export function Button({
variant = "primary",
size = "md",
loading = false,
className = "",
disabled,
children,
...rest
}: ButtonProps) {
return (
<button
type="button"
className={`ps-btn ps-btn-${variant} ps-btn-${size}${loading || disabled ? " is-disabled" : ""}${className ? " " + className : ""}`}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...rest}
>
{loading ? <span className="ps-btn-spinner" aria-hidden /> : children}
</button>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { type ReactNode, useEffect } from "react";
export interface DrawerProps {
open: boolean;
side?: "left" | "right";
title: string;
onClose: () => void;
children: ReactNode;
}
export function Drawer({ open, side = "right", title, onClose, children }: DrawerProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<>
<div className="ps-drawer-overlay" aria-hidden onClick={onClose} />
<aside
className={`ps-drawer ps-drawer-${side}`}
role="dialog"
aria-modal="true"
aria-label={title}
>
<header className="ps-drawer-header">
<h2>{title}</h2>
<button className="ps-icon-btn" onClick={onClose} aria-label="Close">✕</button>
</header>
<div className="ps-drawer-body">{children}</div>
</aside>
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { type ReactNode } from "react";
export interface EmptyStateProps {
icon?: string;
title: string;
description?: string;
action?: ReactNode;
}
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<div className="ps-emptystate">
{icon && <div className="ps-emptystate-icon" aria-hidden>{icon}</div>}
<h3 className="ps-emptystate-title">{title}</h3>
{description && <p className="ps-emptystate-desc">{description}</p>}
{action && <div className="ps-emptystate-action">{action}</div>}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { type InputHTMLAttributes } from "react";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
hint?: string;
error?: string;
}
export function Input({ label, hint, error, id, className = "", ...rest }: InputProps) {
const inputId = id ?? `input-${Math.random().toString(36).slice(2, 8)}`;
return (
<div className="ps-field">
{label && <label htmlFor={inputId} className="ps-field-label">{label}</label>}
<input
id={inputId}
className={`ps-input${error ? " is-error" : ""}${className ? " " + className : ""}`}
aria-invalid={error ? true : undefined}
aria-describedby={hint ? `${inputId}-hint` : undefined}
{...rest}
/>
{(hint || error) && (
<p className="ps-field-hint" id={`${inputId}-hint`} aria-live="polite">
{error ?? hint}
</p>
)}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { type ReactNode, useEffect } from "react";
export interface ModalProps {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}
/** Simple focus-trapping modal. */
export function Modal({ open, title, onClose, children, footer }: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="ps-modal-root" role="presentation" aria-hidden>
<div
className="ps-modal-overlay"
onClick={onClose}
aria-hidden
/>
<div
className="ps-modal"
role="dialog"
aria-modal="true"
aria-label={title}
>
<header className="ps-modal-header">
<h2>{title}</h2>
<button className="ps-icon-btn" onClick={onClose} aria-label="Close">✕</button>
</header>
<div className="ps-modal-body">{children}</div>
{footer && <footer className="ps-modal-footer">{footer}</footer>}
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { type SelectHTMLAttributes } from "react";
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
options: SelectOption[];
}
export function Select({ label, options, id, className = "", ...rest }: SelectProps) {
const selectId = id ?? `select-${Math.random().toString(36).slice(2, 8)}`;
return (
<div className="ps-field">
{label && <label htmlFor={selectId} className="ps-field-label">{label}</label>}
<select id={selectId} className={`ps-select ${className}`.trim()} {...rest}>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
export interface SpinnerProps {
size?: "sm" | "md" | "lg";
label?: string;
}
export function Spinner({ size = "md", label = "Loading" }: SpinnerProps) {
return (
<span
className={`ps-spinner ps-spinner-${size}`}
role="status"
aria-label={label}
>
<span aria-hidden />
</span>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { type ReactNode, useState } from "react";
export interface TabDef {
id: string;
label: string;
content: ReactNode;
/** Optional badge count. */
badge?: number;
}
export interface TabsProps {
tabs: TabDef[];
/** Controlled initial/active tab id. */
defaultActive?: string;
onTabChange?: (id: string) => void;
}
export function Tabs({ tabs, defaultActive, onTabChange }: TabsProps) {
const [active, setActive] = useState(defaultActive ?? tabs[0]?.id ?? "");
const select = (id: string) => {
setActive(id);
onTabChange?.(id);
};
return (
<div className="ps-tabs">
<nav className="ps-tabs-nav" role="tablist" aria-orientation="horizontal">
{tabs.map((t) => (
<button
key={t.id}
role="tab"
aria-selected={t.id === active}
aria-controls={`panel-${t.id}`}
id={`tab-${t.id}`}
className={`ps-tab${t.id === active ? " is-active" : ""}`}
onClick={() => select(t.id)}
>
{t.label}
{t.badge !== undefined && <span className="ps-tab-badge">{t.badge}</span>}
</button>
))}
</nav>
{tabs.map((t) => (
<div
key={t.id}
role="tabpanel"
id={`panel-${t.id}`}
aria-labelledby={`tab-${t.id}`}
hidden={t.id !== active}
className="ps-tabs-panel"
>
{t.content}
</div>
))}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useState, useEffect } from "react";
export type ToastVariant = "info" | "success" | "error" | "warning";
export type Toast = { id: string; message: string; variant: ToastVariant };
type Listener = (toasts: Toast[]) => void;
const listeners = new Set<Listener>();
let toasts: Toast[] = [];
function notify(): void {
for (const l of listeners) l(toasts);
}
function push(message: string, variant: ToastVariant = "info", durationMs = 3500): string {
const id = `toast-${Math.random().toString(36).slice(2, 9)}`;
toasts = [...toasts, { id, message, variant }];
notify();
if (durationMs > 0) {
setTimeout(() => toast.dismiss(id), durationMs);
}
return id;
}
export const toast = {
info: (m: string, d?: number) => push(m, "info", d),
success: (m: string, d?: number) => push(m, "success", d),
error: (m: string, d?: number) => push(m, "error", d),
warning: (m: string, d?: number) => push(m, "warning", d),
dismiss(id: string) {
toasts = toasts.filter((t) => t.id !== id);
notify();
},
};
/** React hook subscribing to the global toast store. */
export function useToasts(): Toast[] {
const [list, setList] = useState<Toast[]>(toasts);
useEffect(() => {
const l: Listener = (next) => setList(next);
listeners.add(l);
l(toasts);
return () => {
listeners.delete(l);
};
}, []);
return list;
}
/** Render the live toast container. Mount once near the app root. */
export function ToastViewport() {
const items = useToasts();
if (items.length === 0) return null;
return (
<div className="ps-toast-viewport" aria-live="polite">
{items.map((t) => (
<div key={t.id} className={`ps-toast ps-toast-${t.variant}`}>
{t.message}
</div>
))}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { type ReactNode } from "react";
export interface TooltipProps {
content: string;
children: ReactNode;
/** Placement hint. Defaults to "top". */
placement?: "top" | "bottom" | "left" | "right";
}
export function Tooltip({ content, children, placement = "top" }: TooltipProps) {
return (
<span className="ps-tooltip-wrap" data-placement={placement}>
{children}
<span className="ps-tooltip" role="tooltip">{content}</span>
</span>
);
}
+22
View File
@@ -0,0 +1,22 @@
export { Button } from "./Button.js";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./Button.js";
export { Input } from "./Input.js";
export type { InputProps } from "./Input.js";
export { Select } from "./Select.js";
export type { SelectProps, SelectOption } from "./Select.js";
export { Modal } from "./Modal.js";
export type { ModalProps } from "./Modal.js";
export { Drawer } from "./Drawer.js";
export type { DrawerProps } from "./Drawer.js";
export { Tooltip } from "./Tooltip.js";
export type { TooltipProps } from "./Tooltip.js";
export { Tabs } from "./Tabs.js";
export type { TabsProps, TabDef } from "./Tabs.js";
export { Badge } from "./Badge.js";
export type { BadgeProps, BadgeVariant } from "./Badge.js";
export { EmptyState } from "./EmptyState.js";
export type { EmptyStateProps } from "./EmptyState.js";
export { Spinner } from "./Spinner.js";
export type { SpinnerProps } from "./Spinner.js";
export { toast, useToasts, ToastViewport } from "./Toast.js";
export type { Toast, ToastVariant } from "./Toast.js";
+36
View File
@@ -0,0 +1,36 @@
/**
* Font families per theme. See PLAN.md §4.7.
* System fonts only — no network fetches.
*/
export interface FontSet {
/** UI chrome font. */
ui: string;
/** Body / prose font. */
body: string;
/** Monospace for code / terminal theme. */
mono: string;
}
export const FONTS: Record<string, FontSet> = {
ink: {
ui: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
body: "Georgia, 'Times New Roman', serif",
mono: "'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace",
},
parchment: {
ui: "Georgia, 'Times New Roman', serif",
body: "Georgia, 'Times New Roman', serif",
mono: "'SFMono-Regular', ui-monospace, Consolas, monospace",
},
nocturne: {
ui: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
body: "Georgia, 'Times New Roman', serif",
mono: "'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace",
},
terminal: {
ui: "'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace",
body: "'SFMono-Regular', ui-monospace, Consolas, monospace",
mono: "'SFMono-Regular', ui-monospace, Consolas, monospace",
},
};
+10
View File
@@ -0,0 +1,10 @@
/**
* @pubscript/ui — the PubScript design system: theme engine, component kit,
* and the AppShell stylesheet. See CHUNK #1 step 3.
*/
export * from "./theme.js";
export * from "./fonts.js";
export * from "./components/index.js";
import "./AppShell.css";
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { THEME_IDS, THEME_LABELS, THEME_TOKENS, getThemeTokens, applyTheme, getActiveTheme } from "./theme.js";
describe("theme token map", () => {
it("defines exactly four themes", () => {
expect(THEME_IDS).toEqual(["ink", "parchment", "nocturne", "terminal"]);
expect(Object.keys(THEME_TOKENS)).toHaveLength(4);
});
it("labels every theme", () => {
for (const id of THEME_IDS) {
expect(THEME_LABELS[id]).toBeTruthy();
}
});
it("each theme has a full set of color tokens", () => {
const required = [
"--ps-bg",
"--ps-surface",
"--ps-surface-2",
"--ps-border",
"--ps-text",
"--ps-text-muted",
"--ps-accent",
"--ps-accent-contrast",
"--ps-danger",
"--ps-success",
"--ps-warning",
];
for (const id of THEME_IDS) {
const css = THEME_TOKENS[id]!.css;
for (const key of required) {
expect(css[key], `${id} missing ${key}`).toBeTruthy();
}
}
});
it("getThemeTokens throws on unknown id", () => {
expect(() => getThemeTokens("nope" as never)).toThrow(/Unknown theme/);
});
it("applyTheme sets the active theme and returns it", () => {
expect(applyTheme("parchment")).toBe("parchment");
expect(getActiveTheme()).toBe("parchment");
// reset for other tests
applyTheme("ink");
expect(getActiveTheme()).toBe("ink");
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Theme engine — CSS-variable token map. See PLAN.md §4.7 and CHUNK #1 step 3.
*
* Four themes ship as token objects; `applyTheme(id)` sets
* `document.documentElement.dataset.theme`. Accent color and density are
* runtime overrides layered on top.
*/
export type ThemeId = "ink" | "parchment" | "nocturne" | "terminal";
export const THEME_IDS: ThemeId[] = ["ink", "parchment", "nocturne", "terminal"];
export const THEME_LABELS: Record<ThemeId, string> = {
ink: "Ink",
parchment: "Parchment",
nocturne: "Nocturne",
terminal: "Terminal",
};
export interface ThemeTokens {
label: string;
/** CSS custom properties applied to :root when this theme is active. */
css: Record<string, string>;
}
/**
* The four themes. Ink is the default (dark). Parchment is warm light,
* Nocturne is deep indigo, Terminal is monochrome monospace.
*/
export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
ink: {
label: "Ink",
css: {
"--ps-bg": "#0e0f13",
"--ps-surface": "#17181f",
"--ps-surface-2": "#20222c",
"--ps-border": "#2a2d3a",
"--ps-text": "#e6e8ee",
"--ps-text-muted": "#9aa0b0",
"--ps-accent": "#7c8bf5",
"--ps-accent-contrast": "#0e0f13",
"--ps-danger": "#f16d7a",
"--ps-success": "#5fd08a",
"--ps-warning": "#f0b754",
},
},
parchment: {
label: "Parchment",
css: {
"--ps-bg": "#f6f1e7",
"--ps-surface": "#fffdf7",
"--ps-surface-2": "#f2ead8",
"--ps-border": "#e2d6bd",
"--ps-text": "#2c2720",
"--ps-text-muted": "#6f6656",
"--ps-accent": "#b5793a",
"--ps-accent-contrast": "#fffdf7",
"--ps-danger": "#c0392b",
"--ps-success": "#2e7d4f",
"--ps-warning": "#b8860b",
},
},
nocturne: {
label: "Nocturne",
css: {
"--ps-bg": "#0c0b1e",
"--ps-surface": "#171533",
"--ps-surface-2": "#221f4a",
"--ps-border": "#2e2a5e",
"--ps-text": "#e8e6ff",
"--ps-text-muted": "#a49fcf",
"--ps-accent": "#9d7bff",
"--ps-accent-contrast": "#0c0b1e",
"--ps-danger": "#ff7b9c",
"--ps-success": "#6ee7a8",
"--ps-warning": "#ffd27b",
},
},
terminal: {
label: "Terminal",
css: {
"--ps-bg": "#05070a",
"--ps-surface": "#0a0f14",
"--ps-surface-2": "#0f1720",
"--ps-border": "#16232e",
"--ps-text": "#33ff9b",
"--ps-text-muted": "#2c8a63",
"--ps-accent": "#33ff9b",
"--ps-accent-contrast": "#05070a",
"--ps-danger": "#ff6b6b",
"--ps-success": "#33ff9b",
"--ps-warning": "#ffd93d",
},
},
};
/** The active theme id, persisted by the settings store. */
let activeTheme: ThemeId = "ink";
/** Return the token map for a theme id (throws on unknown). */
export function getThemeTokens(id: ThemeId): ThemeTokens {
const t = THEME_TOKENS[id];
if (!t) {
throw new Error(`Unknown theme: ${id}`);
}
return t;
}
/**
* Apply a theme by setting the data attribute and writing its CSS variables to
* :root. Returns the applied id. Safe to call before the DOM exists (no-op).
*/
export function applyTheme(id: ThemeId): ThemeId {
if (!THEME_TOKENS[id]) {
throw new Error(`Unknown theme: ${id}`);
}
activeTheme = id;
const root = typeof document !== "undefined" ? document.documentElement : undefined;
if (root) {
root.dataset.theme = id;
const { css } = THEME_TOKENS[id]!;
for (const [k, v] of Object.entries(css)) {
root.style.setProperty(k, v);
}
}
return id;
}
/** The currently active theme id. */
export function getActiveTheme(): ThemeId {
return activeTheme;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
},
});
+4420
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
packages:
- "apps/*"
- "packages/*"
- "server"
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@pubscript/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch --import tsx src/index.ts",
"start": "node --import tsx src/index.ts",
"seed": "tsx src/seed.ts",
"build": "tsc --noEmit",
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.1",
"@fastify/cors": "^11.0.1",
"@fastify/static": "^8.0.3",
"@fastify/websocket": "^11.0.1",
"@pubscript/core": "workspace:*",
"argon2": "^0.41.1",
"better-sqlite3": "^11.7.0",
"fastify": "^5.2.1",
"tsx": "^4.19.2",
"ulid": "^2.3.0",
"ws": "^8.18.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.2",
"@types/ws": "^8.5.12",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { Db } from "./db.js";
import type { User } from "@pubscript/core";
declare module "fastify" {
interface FastifyInstance {
db: Db;
}
interface FastifyRequest {
user: User | null;
}
}
+181
View File
@@ -0,0 +1,181 @@
import crypto from "node:crypto";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import argon2 from "argon2";
import { ulid } from "ulid";
import { z } from "zod";
import type { User } from "@pubscript/core";
import type { Db } from "./db.js";
import { toUser, type UserRow } from "./mapping.js";
const SESSION_COOKIE = "ps_session";
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
// ---- password hashing ----
export const hashPassword = (pw: string): Promise<string> => argon2.hash(pw, { type: argon2.argon2id });
export const verifyPassword = (hash: string, pw: string): Promise<boolean> => argon2.verify(hash, pw);
// ---- session tokens (store only the SHA-256 hash) ----
export function newToken(): string {
return crypto.randomBytes(32).toString("hex");
}
function tokenHash(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export function createSession(db: Db, userId: string): string {
const token = newToken();
const now = Date.now();
db.prepare("INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)").run(
tokenHash(token),
userId,
new Date(now + SESSION_TTL_MS).toISOString(),
new Date(now).toISOString(),
);
return token;
}
export function destroySession(db: Db, token: string): void {
db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash(token));
}
/** Resolve a presented token to a live, non-disabled user (or null). */
export function userFromToken(db: Db, token: string | null | undefined): UserRow | null {
if (!token) return null;
const row = db
.prepare(
`SELECT u.id, u.email, u.pass_hash, u.display_name, u.role, u.disabled, u.created_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > ? AND u.disabled = 0`,
)
.get(tokenHash(token), new Date().toISOString()) as UserRow | undefined;
return row ?? null;
}
// ---- tiny in-memory fixed-window rate limiter ----
export function makeRateLimiter(windowMs: number, max: number): (key: string) => boolean {
const hits = new Map<string, { count: number; resetAt: number }>();
return (key: string): boolean => {
const now = Date.now();
const e = hits.get(key);
if (!e || now > e.resetAt) {
hits.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
e.count += 1;
return e.count <= max;
};
}
// ---- request hook: populate req.user from bearer header or cookie ----
export function bearerToken(req: FastifyRequest): string | null {
const h = req.headers.authorization;
if (h?.startsWith("Bearer ")) return h.slice(7);
return null;
}
export function registerAuthHook(app: FastifyInstance): void {
app.addHook("onRequest", async (req) => {
const db = app.db;
const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
const row = userFromToken(db, token);
req.user = row ? toUser(row) : null;
});
}
export function setSessionCookie(reply: FastifyReply, token: string): void {
reply.setCookie(SESSION_COOKIE, token, {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: SESSION_TTL_MS / 1000,
});
}
export function clearSessionCookie(reply: FastifyReply): void {
reply.clearCookie(SESSION_COOKIE, { path: "/" });
}
// ---- guards ----
export function requireAuth(req: FastifyRequest): void {
if (!req.user) throw req.server.httpErrors.create(401, { error: "unauthorized" });
}
export function requireAdmin(req: FastifyRequest): void {
requireAuth(req);
if (req.user!.role !== "admin") throw req.server.httpErrors.create(403, { error: "forbidden" });
}
export function canAccessProject(db: Db, user: User, projectId: string, needWrite = false): boolean {
const proj = db.prepare("SELECT owner_id FROM projects WHERE id = ?").get(projectId) as
| { owner_id: string }
| undefined;
if (!proj) return false;
if (proj.owner_id === user.id) return true;
const share = db
.prepare("SELECT perm FROM project_shares WHERE project_id = ? AND user_id = ?")
.get(projectId, user.id) as { perm: "read" | "edit" } | undefined;
if (!share) return false;
return needWrite ? share.perm === "edit" : true;
}
// ---- auth routes ----
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
displayName: z.string().min(1).max(80),
});
const loginSchema = z.object({ email: z.string().email(), password: z.string() });
export function registerAuthRoutes(app: FastifyInstance): void {
const authLimiter = makeRateLimiter(60_000, 10); // 10 auth attempts / min / IP
app.post("/api/auth/register", async (req, reply) => {
if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" });
const body = registerSchema.parse(req.body);
const db = app.db;
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
if (existing) throw app.httpErrors.create(409, { error: "email_taken" });
const isFirst = (db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n === 0;
const role = isFirst ? "admin" : "author";
const id = ulid();
const now = new Date().toISOString();
const passHash = await hashPassword(body.password);
db.prepare(
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)",
).run(id, body.email, passHash, body.displayName, role, now);
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)").run(
ulid(),
now,
id,
JSON.stringify({ email: body.email, first: isFirst }),
);
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
const token = createSession(db, id);
setSessionCookie(reply, token);
return { token, user: toUser(row) };
});
app.post("/api/auth/login", async (req, reply) => {
if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" });
const body = loginSchema.parse(req.body);
const db = app.db;
const row = db.prepare("SELECT * FROM users WHERE email = ?").get(body.email) as UserRow | undefined;
if (!row || row.disabled || !(await verifyPassword(row.pass_hash, body.password))) {
throw app.httpErrors.create(401, { error: "bad_credentials" });
}
const token = createSession(db, row.id);
setSessionCookie(reply, token);
return { token, user: toUser(row) };
});
app.post("/api/auth/logout", async (req, reply) => {
const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
if (token) destroySession(app.db, token);
clearSessionCookie(reply);
return { ok: true };
});
app.get("/api/auth/me", async (req) => {
requireAuth(req);
return { user: req.user };
});
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Environment-driven server configuration.
*
* All values are read from `process.env` at import time with sensible
* defaults so the server boots with zero configuration for local dev.
*/
function env(key: string, fallback: string): string {
const value = process.env[key];
return value === undefined || value === "" ? fallback : value;
}
function envInt(key: string, fallback: number): number {
const raw = process.env[key];
if (raw === undefined || raw === "") return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : fallback;
}
export interface ServerConfig {
/** Path to the SQLite database file. */
dbPath: string;
/** Port the HTTP server listens on. */
port: number;
/** Origin allowed for CORS. */
origin: string;
/** Email of the bootstrap admin user. */
adminEmail: string;
/** Session token TTL in seconds. */
tokenTtl: number;
/** Version string reported by /api/version. */
version: string;
}
export const config: ServerConfig = {
dbPath: env("PUBSCRIPT_DB", "./data/pubscript.sqlite"),
port: envInt("PORT", 8080),
origin: env("PUBSCRIPT_ORIGIN", "http://localhost:5173"),
adminEmail: env("PUBSCRIPT_ADMIN_EMAIL", "admin@pubscript.local"),
tokenTtl: envInt("PUBSCRIPT_TOKEN_TTL", 60 * 60 * 24 * 30),
version: env("PUBSCRIPT_VERSION", "0.1.0"),
};
+77
View File
@@ -0,0 +1,77 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { ulid } from "ulid";
import { config } from "./config.js";
export type Db = Database.Database;
/**
* Open (or create) the SQLite database and bring it up to the latest
* migration. Migrations are plain `.sql` files in `src/migrations/`, applied
* in filename order and recorded in `schema_migrations` so each runs once.
*/
export function openDb(dbPath: string = config.dbPath): Db {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
migrate(db);
return db;
}
function migrate(db: Db): void {
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)
`);
const applied = new Set(
(db.prepare("SELECT name FROM schema_migrations").all() as Array<{ name: string }>).map((r) => r.name),
);
const dir = path.resolve(import.meta.dirname, "migrations");
const files = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".sql"))
.sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(dir, file), "utf8");
const apply = db.transaction(() => {
db.exec(sql);
db.prepare("INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)").run(
file,
new Date().toISOString(),
);
});
apply();
}
}
/** Bootstrap the first admin from env on first boot (idempotent). */
export function ensureAdmin(db: Db, hashPassword: (pw: string) => Promise<string>): Promise<void> {
const email = process.env.PUBSCRIPT_ADMIN_EMAIL;
const password = process.env.PUBSCRIPT_ADMIN_PASSWORD;
if (!email || !password) return Promise.resolve();
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email) as { id: string } | undefined;
if (existing) return Promise.resolve();
return hashPassword(password).then((passHash) => {
const id = ulid();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
).run(id, email, passHash, email.split("@")[0] ?? "admin", now);
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.bootstrap', ?)").run(
ulid(),
now,
id,
JSON.stringify({ email }),
);
});
}
+75
View File
@@ -0,0 +1,75 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import cookie from "@fastify/cookie";
import websocket from "@fastify/websocket";
import { ZodError } from "zod";
import { config } from "./config.js";
import { openDb, ensureAdmin } from "./db.js";
import { hashPassword, registerAuthHook, registerAuthRoutes } from "./auth.js";
import { registerRoutes } from "./routes.js";
import { registerWs } from "./ws.js";
/** A Fastify error carrying a status code and a serializable payload. */
export interface HttpError extends Error {
statusCode: number;
payload: object;
}
declare module "fastify" {
interface FastifyInstance {
/** Create a throwable HTTP error: `throw app.httpErrors.create(404, { error: "not_found" })`. */
httpErrors: { create(status: number, payload: object): HttpError };
}
}
function makeHttpError(status: number, payload: object): HttpError {
const e = new Error("http_error") as HttpError;
e.statusCode = status;
e.payload = payload;
return e;
}
export async function buildApp() {
const app = Fastify({ logger: true });
await app.register(cors, { origin: true, credentials: true });
await app.register(cookie);
await app.register(websocket);
const db = openDb();
app.decorate("db", db);
app.decorate("httpErrors", { create: makeHttpError });
await ensureAdmin(db, hashPassword);
// Serialize thrown errors into the API's `{ error: "code" }` shape.
app.setErrorHandler((err, _req, reply) => {
if (err instanceof ZodError) {
return reply.code(400).send({ error: "invalid_input", issues: err.issues });
}
const status = (err as HttpError).statusCode ?? 500;
const payload = (err as HttpError).payload ?? { error: "internal_error" };
return reply.code(status).send(payload);
});
app.get("/api/health", async () => {
let dbUp = false;
try {
dbUp = db.prepare("SELECT 1").get() != null;
} catch {
dbUp = false;
}
return { ok: dbUp, db: dbUp ? "up" : "down", version: config.version, uptime: process.uptime() };
});
registerAuthHook(app);
registerAuthRoutes(app);
registerRoutes(app);
registerWs(app);
return app;
}
export async function start() {
const app = await buildApp();
await app.listen({ port: config.port, host: "0.0.0.0" });
return app;
}
+268
View File
@@ -0,0 +1,268 @@
import type {
AISession,
AISessionRole,
Board,
Book,
Chapter,
Character,
Entity,
Note,
Project,
ProjectMeta,
User,
WorldItem,
} from "@pubscript/core";
// ---- Row shapes (mirror the SQLite schema) ----
export interface UserRow {
id: string;
email: string;
pass_hash: string;
display_name: string;
role: "admin" | "author";
disabled: number;
created_at: string;
}
export interface ShareRow {
project_id: string;
user_id: string;
perm: "read" | "edit";
}
export interface ProjectRow {
id: string;
owner_id: string;
title: string;
settings: string;
cover: string;
created_at: string;
updated_at: string;
}
export interface BookRow {
id: string;
project_id: string;
title: string;
ord: number;
}
export interface ChapterRow {
id: string;
book_id: string;
project_id: string;
title: string;
doc: string;
rev: number;
ord: number;
updated_at: string;
}
export interface EntityRow {
id: string;
project_id: string;
kind: string;
name: string;
fields: string;
created_at: string;
updated_at: string;
}
export interface BoardRow {
id: string;
project_id: string;
nodes: string;
edges: string;
updated_at: string;
}
export interface AISessionRow {
id: string;
project_id: string;
role: string;
model: string;
settings: string;
messages: string;
suggestions: string;
updated_at: string;
}
// ---- Row -> core types ----
export function toUser(r: UserRow): User {
return {
id: r.id,
email: r.email,
displayName: r.display_name,
role: r.role,
createdAt: r.created_at,
};
}
export function toProject(r: ProjectRow, shares: ShareRow[]): Project {
return {
id: r.id,
projectId: r.id,
title: r.title,
ownerId: r.owner_id,
sharedWith: shares.map((s) => ({ userId: s.user_id, perm: s.perm })),
settings: JSON.parse(r.settings),
cover: JSON.parse(r.cover),
createdAt: r.created_at,
updatedAt: r.updated_at,
};
}
export function toProjectMeta(r: ProjectRow, shares: ShareRow[], user: User): ProjectMeta {
const owned = r.owner_id === user.id;
const mine = shares.find((s) => s.user_id === user.id);
return {
id: r.id,
projectId: r.id,
title: r.title,
ownerId: r.owner_id,
cover: JSON.parse(r.cover),
createdAt: r.created_at,
updatedAt: r.updated_at,
perm: owned ? "edit" : (mine?.perm ?? "read"),
owned,
};
}
export function toBook(r: BookRow, chapters: ChapterRow[]): Book {
return {
id: r.id,
projectId: r.project_id,
title: r.title,
order: r.ord,
chapters: chapters
.slice()
.sort((a, b) => a.ord - b.ord)
.map(toChapter),
};
}
export function toChapter(r: ChapterRow): Chapter {
return {
id: r.id,
bookId: r.book_id,
projectId: r.project_id,
title: r.title,
doc: JSON.parse(r.doc),
rev: r.rev,
order: r.ord,
meta: {
wordCount: wordCount(JSON.parse(r.doc)),
scenes: [],
updatedAt: r.updated_at,
},
};
}
export function toBoard(r: BoardRow): Board {
return {
id: r.id,
projectId: r.project_id,
nodes: JSON.parse(r.nodes),
edges: JSON.parse(r.edges),
};
}
export function toAISession(r: AISessionRow): AISession {
return {
id: r.id,
projectId: r.project_id,
role: r.role as AISessionRole,
model: r.model,
settings: JSON.parse(r.settings),
messages: JSON.parse(r.messages),
suggestions: JSON.parse(r.suggestions),
};
}
export function toEntity(r: EntityRow): Entity {
const f = JSON.parse(r.fields);
if (r.kind === "character") {
return {
id: r.id,
projectId: r.project_id,
name: r.name,
role: f.role ?? "",
bio: f.bio ?? "",
appearance: f.appearance ?? "",
voice: f.voice ?? "",
relationships: f.relationships ?? "",
arc: f.arc ?? "",
status: f.status ?? "active",
tags: f.tags ?? [],
} satisfies Character;
}
if (r.kind === "world") {
return {
id: r.id,
projectId: r.project_id,
kind: f.kind ?? "location",
name: r.name,
fields: f.fields ?? {},
} satisfies WorldItem;
}
return {
id: r.id,
projectId: r.project_id,
body: f.body ?? "",
pinned: !!f.pinned,
tags: f.tags ?? [],
linkedTo: f.linkedTo ?? [],
} satisfies Note;
}
// ---- core types -> row (for writes) ----
export function entityToRow(e: Entity): Omit<EntityRow, "created_at" | "updated_at"> {
if ("fields" in e) {
// WorldItem
return {
id: e.id,
project_id: e.projectId,
kind: "world",
name: e.name,
fields: JSON.stringify({ kind: e.kind, fields: e.fields }),
};
}
if ("body" in e) {
// Note
return {
id: e.id,
project_id: e.projectId,
kind: "note",
name: e.body.slice(0, 80),
fields: JSON.stringify({ body: e.body, pinned: e.pinned, tags: e.tags, linkedTo: e.linkedTo }),
};
}
// Character
const c = e as Character;
return {
id: c.id,
project_id: c.projectId,
kind: "character",
name: c.name,
fields: JSON.stringify({
role: c.role,
bio: c.bio,
appearance: c.appearance,
voice: c.voice,
relationships: c.relationships,
arc: c.arc,
status: c.status,
tags: c.tags,
}),
};
}
// ---- helpers ----
/** Count words in a ProseMirror doc (text nodes only). */
export function wordCount(doc: unknown): number {
let count = 0;
const walk = (node: any): void => {
if (node && typeof node === "object") {
if (node.type === "text") {
const t = String(node.text ?? "");
count += t.split(/\s+/).filter(Boolean).length;
}
if (Array.isArray(node.content)) for (const c of node.content) walk(c);
}
};
walk(doc);
return count;
}
+101
View File
@@ -0,0 +1,101 @@
-- 001_initial.sql — the full PubScript schema (CHUNK #2 step 2).
-- Applied in filename order; tracked in `schema_migrations`.
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
pass_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'author' CHECK (role IN ('admin', 'author')),
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
settings TEXT NOT NULL DEFAULT '{}',
cover TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id);
CREATE TABLE IF NOT EXISTS project_shares (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
perm TEXT NOT NULL CHECK (perm IN ('read', 'edit')),
PRIMARY KEY (project_id, user_id)
);
CREATE TABLE IF NOT EXISTS books (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
ord INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_books_project ON books(project_id);
CREATE TABLE IF NOT EXISTS chapters (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
doc TEXT NOT NULL DEFAULT '{"type":"doc","content":[]}',
rev INTEGER NOT NULL DEFAULT 0,
ord INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chapters_project ON chapters(project_id);
CREATE INDEX IF NOT EXISTS idx_chapters_book ON chapters(book_id);
CREATE TABLE IF NOT EXISTS entities (
id TEXT NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
name TEXT NOT NULL,
fields TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (project_id, kind, id)
);
CREATE INDEX IF NOT EXISTS idx_entities_project_kind ON entities(project_id, kind);
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
nodes TEXT NOT NULL DEFAULT '[]',
edges TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_boards_project ON boards(project_id);
CREATE TABLE IF NOT EXISTS ai_sessions (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
role TEXT NOT NULL,
model TEXT NOT NULL,
settings TEXT NOT NULL DEFAULT '{}',
messages TEXT NOT NULL DEFAULT '[]',
suggestions TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ai_sessions_project ON ai_sessions(project_id);
CREATE TABLE IF NOT EXISTS audit (
id TEXT PRIMARY KEY,
at TEXT NOT NULL,
user_id TEXT,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit(at);
+584
View File
@@ -0,0 +1,584 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { ulid } from "ulid";
import {
requireAuth,
requireAdmin,
canAccessProject,
} from "./auth.js";
import {
toProject,
toProjectMeta,
toBook,
toChapter,
toBoard,
toAISession,
toEntity,
entityToRow,
wordCount,
type ProjectRow,
type ShareRow,
type BookRow,
type ChapterRow,
type EntityRow,
type BoardRow,
type AISessionRow,
} from "./mapping.js";
// ---- input schemas ----
const projectCreate = z.object({ title: z.string().min(1).max(200) });
const projectPatch = z.object({
title: z.string().min(1).max(200).optional(),
settings: z.record(z.unknown()).optional(),
cover: z.object({ color: z.string(), emoji: z.string().optional() }).optional(),
});
const shareAdd = z.object({ userId: z.string(), perm: z.enum(["read", "edit"]) });
const bookCreate = z.object({ title: z.string().min(1).max(200) });
const bookPatch = z.object({
title: z.string().min(1).max(200).optional(),
order: z.number().int().optional(),
});
const chapterCreate = z.object({ title: z.string().min(1).max(200) });
const chapterPatch = z.object({
title: z.string().min(1).max(200).optional(),
doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }).optional(),
});
const autosaveBody = z.object({
rev: z.number().int(),
doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }),
wordCount: z.number().int().optional(),
});
const entityCreate = z.object({
id: z.string().optional(),
kind: z.enum(["character", "world", "note"]),
name: z.string().optional(),
body: z.string().optional(),
fields: z.record(z.string()).optional(),
role: z.string().optional(),
bio: z.string().optional(),
appearance: z.string().optional(),
voice: z.string().optional(),
relationships: z.string().optional(),
arc: z.string().optional(),
status: z.enum(["active", "archived"]).optional(),
tags: z.array(z.string()).optional(),
pinned: z.boolean().optional(),
linkedTo: z.array(z.string()).optional(),
});
const boardCreate = z.object({
id: z.string().optional(),
nodes: z.array(z.any()).default([]),
edges: z.array(z.any()).default([]),
});
const boardPatch = z.object({
nodes: z.array(z.any()).optional(),
edges: z.array(z.any()).optional(),
});
const aiSessionCreate = z.object({
id: z.string().optional(),
role: z.string().min(1),
model: z.string().min(1),
settings: z.record(z.unknown()).default({}),
messages: z.array(z.any()).optional(),
suggestions: z.array(z.any()).optional(),
});
const aiMessageAppend = z.object({ role: z.string(), content: z.string() });
const aiSuggestionAdd = z.object({ kind: z.string(), text: z.string() });
const userPatch = z.object({
role: z.enum(["admin", "author"]).optional(),
disabled: z.boolean().optional(),
displayName: z.string().min(1).max(80).optional(),
});
// ---- helpers ----
function requireProjectAccess(app: FastifyInstance, req: any, projectId: string, needWrite = false): void {
requireAuth(req);
if (!canAccessProject(app.db, req.user, projectId, needWrite)) {
throw app.httpErrors.create(403, { error: "forbidden" });
}
}
function now(): string {
return new Date().toISOString();
}
function sharesFor(db: any, projectId: string): ShareRow[] {
return db.prepare("SELECT project_id, user_id, perm FROM project_shares WHERE project_id = ?").all(projectId) as ShareRow[];
}
function projectRow(db: any, id: string): ProjectRow | undefined {
return db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as ProjectRow | undefined;
}
export function registerRoutes(app: FastifyInstance): void {
const db = app.db;
// ---------- Projects ----------
app.get("/api/projects", async (req) => {
requireAuth(req);
const u = req.user!;
const owned = db.prepare("SELECT * FROM projects WHERE owner_id = ?").all(u.id) as ProjectRow[];
const shared = db
.prepare("SELECT p.* FROM projects p JOIN project_shares s ON s.project_id = p.id WHERE s.user_id = ?")
.all(u.id) as ProjectRow[];
const byId = new Map<string, ProjectRow>();
for (const r of [...owned, ...shared]) byId.set(r.id, r);
return { projects: [...byId.values()].map((r) => toProjectMeta(r, sharesFor(db, r.id), u)) };
});
app.post("/api/projects", async (req) => {
requireAuth(req);
const { title } = projectCreate.parse(req.body);
const id = ulid();
const t = now();
db.prepare(
"INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at) VALUES (?, ?, ?, '{}', '{}', ?, ?)",
).run(id, req.user!.id, title, t, t);
const row = projectRow(db, id)!;
return { project: toProject(row, []) };
});
app.get("/api/projects/:id", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id);
const row = projectRow(db, id);
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
return { project: toProject(row, sharesFor(db, id)) };
});
app.patch("/api/projects/:id", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const body = projectPatch.parse(req.body);
const row = projectRow(db, id);
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
const title = body.title ?? row.title;
const settings = body.settings ? JSON.stringify(body.settings) : row.settings;
const cover = body.cover ? JSON.stringify(body.cover) : row.cover;
const t = now();
db.prepare("UPDATE projects SET title = ?, settings = ?, cover = ?, updated_at = ? WHERE id = ?").run(
title,
settings,
cover,
t,
id,
);
return { project: toProject({ ...row, title, settings, cover, updated_at: t }, sharesFor(db, id)) };
});
app.delete("/api/projects/:id", async (req) => {
const { id } = req.params as { id: string };
requireAuth(req);
const row = projectRow(db, id);
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
if (row.owner_id !== req.user!.id) throw app.httpErrors.create(403, { error: "forbidden" });
db.prepare("DELETE FROM projects WHERE id = ?").run(id);
return { ok: true };
});
app.post("/api/projects/:id/shares", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const { userId, perm } = shareAdd.parse(req.body);
db.prepare("INSERT OR REPLACE INTO project_shares (project_id, user_id, perm) VALUES (?, ?, ?)").run(id, userId, perm);
return { ok: true };
});
app.delete("/api/projects/:id/shares/:userId", async (req) => {
const { id, userId } = req.params as { id: string; userId: string };
requireProjectAccess(app, req, id, true);
db.prepare("DELETE FROM project_shares WHERE project_id = ? AND user_id = ?").run(id, userId);
return { ok: true };
});
// ---------- Books ----------
app.get("/api/projects/:id/books", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id);
const books = db.prepare("SELECT * FROM books WHERE project_id = ? ORDER BY ord").all(id) as BookRow[];
return {
books: books.map((b) => {
const chapters = db.prepare("SELECT * FROM chapters WHERE book_id = ?").all(b.id) as ChapterRow[];
return toBook(b, chapters);
}),
};
});
app.post("/api/projects/:id/books", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const { title } = bookCreate.parse(req.body);
const bid = ulid();
const ord = (db.prepare("SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM books WHERE project_id = ?").get(id) as { n: number }).n;
db.prepare("INSERT INTO books (id, project_id, title, ord) VALUES (?, ?, ?, ?)").run(bid, id, title, ord);
const row = db.prepare("SELECT * FROM books WHERE id = ?").get(bid) as BookRow;
return { book: toBook(row, []) };
});
app.patch("/api/books/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM books WHERE id = ?").get(id) as BookRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const body = bookPatch.parse(req.body);
const title = body.title ?? row.title;
const ord = body.order ?? row.ord;
db.prepare("UPDATE books SET title = ?, ord = ? WHERE id = ?").run(title, ord, id);
const chapters = db.prepare("SELECT * FROM chapters WHERE book_id = ?").all(id) as ChapterRow[];
return { book: toBook({ ...row, title, ord }, chapters) };
});
app.delete("/api/books/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM books WHERE id = ?").get(id) as BookRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
db.prepare("DELETE FROM books WHERE id = ?").run(id);
return { ok: true };
});
// ---------- Chapters ----------
app.get("/api/books/:id/chapters", async (req) => {
const { id } = req.params as { id: string };
const book = db.prepare("SELECT * FROM books WHERE id = ?").get(id) as BookRow | undefined;
if (!book) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, book.project_id);
const chapters = db.prepare("SELECT * FROM chapters WHERE book_id = ? ORDER BY ord").all(id) as ChapterRow[];
return { chapters: chapters.map(toChapter) };
});
app.post("/api/books/:id/chapters", async (req) => {
const { id } = req.params as { id: string };
const book = db.prepare("SELECT * FROM books WHERE id = ?").get(id) as BookRow | undefined;
if (!book) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, book.project_id, true);
const { title } = chapterCreate.parse(req.body);
const cid = ulid();
const ord = (db.prepare("SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM chapters WHERE book_id = ?").get(id) as { n: number }).n;
const t = now();
db.prepare(
"INSERT INTO chapters (id, book_id, project_id, title, doc, rev, ord, updated_at) VALUES (?, ?, ?, ?, '{\"type\":\"doc\",\"content\":[]}', 0, ?, ?)",
).run(cid, id, book.project_id, title, ord, t);
const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(cid) as ChapterRow;
return { chapter: toChapter(row) };
});
app.get("/api/chapters/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id);
return { chapter: toChapter(row) };
});
app.patch("/api/chapters/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const body = chapterPatch.parse(req.body);
const title = body.title ?? row.title;
const doc = body.doc ? JSON.stringify(body.doc) : row.doc;
const t = now();
db.prepare("UPDATE chapters SET title = ?, doc = ?, rev = rev + 1, updated_at = ? WHERE id = ?").run(title, doc, t, id);
const updated = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow;
return { chapter: toChapter(updated) };
});
// Optimistic autosave (CHUNK #2 acceptance criterion).
app.post("/api/chapters/:id/autosave", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const { rev, doc, wordCount: wc } = autosaveBody.parse(req.body);
if (rev === row.rev) {
const t = now();
const count = wc ?? wordCount(doc);
db.prepare("UPDATE chapters SET doc = ?, rev = rev + 1, updated_at = ? WHERE id = ?").run(JSON.stringify(doc), t, id);
return { ok: true, rev: row.rev + 1, wordCount: count };
}
// Stale client — return the server's current state so it can reconcile.
throw app.httpErrors.create(409, {
error: "conflict",
serverRev: row.rev,
serverDoc: JSON.parse(row.doc),
});
});
app.delete("/api/chapters/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
db.prepare("DELETE FROM chapters WHERE id = ?").run(id);
return { ok: true };
});
// ---------- Entities (characters / world / notes) ----------
app.get("/api/projects/:id/entities", async (req) => {
const { id } = req.params as { id: string };
const { kind } = req.query as { kind?: string };
requireProjectAccess(app, req, id);
let rows: EntityRow[];
if (kind) {
rows = db.prepare("SELECT * FROM entities WHERE project_id = ? AND kind = ?").all(id, kind) as EntityRow[];
} else {
rows = db.prepare("SELECT * FROM entities WHERE project_id = ?").all(id) as EntityRow[];
}
return { entities: rows.map(toEntity) };
});
app.post("/api/projects/:id/entities", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const body = entityCreate.parse(req.body);
const eid = body.id ?? ulid();
const t = now();
// Build the core Entity from the input, then map to a row.
let entity: any;
if (body.kind === "character") {
entity = {
id: eid, projectId: id, name: body.name ?? "", role: body.role ?? "", bio: body.bio ?? "",
appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? "",
arc: body.arc ?? "", status: body.status ?? "active", tags: body.tags ?? [],
};
} else if (body.kind === "world") {
entity = { id: eid, projectId: id, kind: body.fields?.kind ?? "location", name: body.name ?? "", fields: body.fields ?? {} };
} else {
entity = { id: eid, projectId: id, body: body.body ?? "", pinned: body.pinned ?? false, tags: body.tags ?? [], linkedTo: body.linkedTo ?? [] };
}
const row = entityToRow(entity);
db.prepare(
`INSERT INTO entities (id, project_id, kind, name, fields, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id, kind, id) DO UPDATE SET
name = excluded.name, fields = excluded.fields, updated_at = excluded.updated_at`,
).run(eid, id, row.kind, row.name, row.fields, t, t);
const stored = db.prepare("SELECT * FROM entities WHERE project_id = ? AND id = ?").get(id, eid) as EntityRow;
return { entity: toEntity(stored) };
});
app.get("/api/projects/:projectId/entities/:kind/:id", async (req) => {
const { projectId, kind, id } = req.params as { projectId: string; kind: string; id: string };
requireProjectAccess(app, req, projectId);
const row = db.prepare("SELECT * FROM entities WHERE project_id = ? AND kind = ? AND id = ?").get(projectId, kind, id) as EntityRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
return { entity: toEntity(row) };
});
app.patch("/api/projects/:projectId/entities/:kind/:id", async (req) => {
const { projectId, kind, id } = req.params as { projectId: string; kind: string; id: string };
requireProjectAccess(app, req, projectId, true);
const existing = db.prepare("SELECT * FROM entities WHERE project_id = ? AND kind = ? AND id = ?").get(projectId, kind, id) as EntityRow | undefined;
if (!existing) throw app.httpErrors.create(404, { error: "not_found" });
const body = entityCreate.parse(req.body);
let entity: any;
if (kind === "character") {
entity = {
id, projectId, name: body.name ?? existing.name, role: body.role ?? "", bio: body.bio ?? "",
appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? "",
arc: body.arc ?? "", status: body.status ?? "active", tags: body.tags ?? [],
};
} else if (kind === "world") {
entity = { id, projectId, kind: body.fields?.kind ?? "location", name: body.name ?? existing.name, fields: body.fields ?? {} };
} else {
entity = { id, projectId, body: body.body ?? "", pinned: body.pinned ?? false, tags: body.tags ?? [], linkedTo: body.linkedTo ?? [] };
}
const row = entityToRow(entity);
const t = now();
db.prepare(
"UPDATE entities SET name = ?, fields = ?, updated_at = ? WHERE project_id = ? AND kind = ? AND id = ?",
).run(row.name, row.fields, t, projectId, kind, id);
const stored = db.prepare("SELECT * FROM entities WHERE project_id = ? AND kind = ? AND id = ?").get(projectId, kind, id) as EntityRow;
return { entity: toEntity(stored) };
});
app.delete("/api/projects/:projectId/entities/:kind/:id", async (req) => {
const { projectId, kind, id } = req.params as { projectId: string; kind: string; id: string };
requireProjectAccess(app, req, projectId, true);
const res = db.prepare("DELETE FROM entities WHERE project_id = ? AND kind = ? AND id = ?").run(projectId, kind, id);
if (res.changes === 0) throw app.httpErrors.create(404, { error: "not_found" });
return { ok: true };
});
// ---------- Boards ----------
app.get("/api/projects/:id/boards", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id);
const boards = db.prepare("SELECT * FROM boards WHERE project_id = ?").all(id) as BoardRow[];
return { boards: boards.map(toBoard) };
});
app.post("/api/projects/:id/boards", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const body = boardCreate.parse(req.body);
const bid = body.id ?? ulid();
const t = now();
db.prepare(`INSERT INTO boards (id, project_id, nodes, edges, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
project_id = excluded.project_id, nodes = excluded.nodes, edges = excluded.edges, updated_at = excluded.updated_at`).run(
bid, id, JSON.stringify(body.nodes), JSON.stringify(body.edges), t,
);
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(bid) as BoardRow;
return { board: toBoard(row) };
});
app.get("/api/boards/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(id) as BoardRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id);
return { board: toBoard(row) };
});
app.patch("/api/boards/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(id) as BoardRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const body = boardPatch.parse(req.body);
const nodes = body.nodes ? JSON.stringify(body.nodes) : row.nodes;
const edges = body.edges ? JSON.stringify(body.edges) : row.edges;
const t = now();
db.prepare("UPDATE boards SET nodes = ?, edges = ?, updated_at = ? WHERE id = ?").run(nodes, edges, t, id);
const updated = db.prepare("SELECT * FROM boards WHERE id = ?").get(id) as BoardRow;
return { board: toBoard(updated) };
});
app.delete("/api/boards/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(id) as BoardRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
db.prepare("DELETE FROM boards WHERE id = ?").run(id);
return { ok: true };
});
// ---------- AI sessions ----------
app.get("/api/projects/:id/ai/sessions", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id);
const rows = db.prepare("SELECT * FROM ai_sessions WHERE project_id = ?").all(id) as AISessionRow[];
return { sessions: rows.map(toAISession) };
});
app.post("/api/projects/:id/ai/sessions", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const body = aiSessionCreate.parse(req.body);
const sid = body.id ?? ulid();
const t = now();
db.prepare(
`INSERT INTO ai_sessions (id, project_id, role, model, settings, messages, suggestions, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
project_id = excluded.project_id, role = excluded.role, model = excluded.model,
settings = excluded.settings, messages = excluded.messages, suggestions = excluded.suggestions,
updated_at = excluded.updated_at`,
).run(sid, id, body.role, body.model, JSON.stringify(body.settings),
JSON.stringify(body.messages ?? []), JSON.stringify(body.suggestions ?? []), t);
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(sid) as AISessionRow;
return { session: toAISession(row) };
});
app.get("/api/ai/sessions/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id);
return { session: toAISession(row) };
});
app.patch("/api/ai/sessions/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const body = z.object({ settings: z.record(z.unknown()).optional(), model: z.string().optional() }).parse(req.body);
const settings = body.settings ? JSON.stringify(body.settings) : row.settings;
const model = body.model ?? row.model;
const t = now();
db.prepare("UPDATE ai_sessions SET settings = ?, model = ?, updated_at = ? WHERE id = ?").run(settings, model, t, id);
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
return { session: toAISession(updated) };
});
app.delete("/api/ai/sessions/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
db.prepare("DELETE FROM ai_sessions WHERE id = ?").run(id);
return { ok: true };
});
app.post("/api/ai/sessions/:id/messages", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const { role, content } = aiMessageAppend.parse(req.body);
const messages = JSON.parse(row.messages) as any[];
messages.push({ role, content, at: now() });
const t = now();
db.prepare("UPDATE ai_sessions SET messages = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(messages), t, id);
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
return { session: toAISession(updated) };
});
app.post("/api/ai/sessions/:id/suggestions", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const { kind, text } = aiSuggestionAdd.parse(req.body);
const suggestions = JSON.parse(row.suggestions) as any[];
const sug = { id: ulid(), kind, text, at: now() };
suggestions.push(sug);
const t = now();
db.prepare("UPDATE ai_sessions SET suggestions = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(suggestions), t, id);
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
return { session: toAISession(updated) };
});
app.delete("/api/ai/sessions/:id/suggestions/:sid", async (req) => {
const { id, sid } = req.params as { id: string; sid: string };
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const suggestions = (JSON.parse(row.suggestions) as any[]).filter((s) => s.id !== sid);
const t = now();
db.prepare("UPDATE ai_sessions SET suggestions = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(suggestions), t, id);
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
return { session: toAISession(updated) };
});
// ---------- Admin ----------
app.get("/api/admin/users", async (req) => {
requireAdmin(req);
const rows = db.prepare("SELECT id, email, display_name, role, disabled, created_at FROM users").all() as any[];
return { users: rows };
});
app.patch("/api/admin/users/:id", async (req) => {
requireAdmin(req);
const { id } = req.params as { id: string };
const body = userPatch.parse(req.body);
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as any;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
const role = body.role ?? row.role;
const disabled = body.disabled ? 1 : row.disabled;
const displayName = body.displayName ?? row.display_name;
db.prepare("UPDATE users SET role = ?, disabled = ?, display_name = ? WHERE id = ?").run(role, disabled, displayName, id);
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.user_update', ?)").run(
ulid(), now(), req.user!.id, JSON.stringify({ target: id, role, disabled: !!disabled }),
);
return { user: { id, email: row.email, displayName, role, disabled: !!disabled, createdAt: row.created_at } };
});
app.get("/api/admin/audit", async (req) => {
requireAdmin(req);
const limit = Math.min(Number((req.query as any).limit ?? 100), 500);
const rows = db.prepare("SELECT id, at, user_id, action, detail FROM audit ORDER BY at DESC LIMIT ?").all(limit) as any[];
return { audit: rows.map((r) => ({ ...r, detail: JSON.parse(r.detail) })) };
});
}
+275
View File
@@ -0,0 +1,275 @@
/**
* Seed script for PubScript.
*
* Creates a self-contained demo project — "The Glass Harbor" — so every later
* chunk has something concrete to show: a book with three chapters of real
* prose, four linked characters, one world location, and a six-node story
* board. Idempotent: re-running it leaves the existing demo untouched.
*
* Usage:
* pnpm --filter server seed
*
* The demo is owned by the bootstrap admin user (created by `ensureAdmin` on
* first boot, or by `PUBSCRIPT_ADMIN_EMAIL` / `PUBSCRIPT_ADMIN_PASSWORD`).
*/
import { ulid } from "ulid";
import { openDb } from "./db.js";
import { ensureAdmin } from "./db.js";
import { hashPassword } from "./auth.js";
import { entityToRow } from "./mapping.js";
import type { Character, WorldItem, Board } from "@pubscript/core";
const DEMO_TITLE = "The Glass Harbor";
/** A single ProseMirror paragraph. */
function para(text: string) {
return { type: "paragraph", content: [{ type: "text", text }] };
}
/** Build a ProseMirror doc from an array of paragraphs. */
function doc(paras: string[]) {
return { type: "doc", content: paras.map(para) };
}
/** Insert a chapter with real prose, returning its id. */
function addChapter(db: any, bookId: string, projectId: string, title: string, paras: string[]): string {
const id = ulid();
const t = new Date().toISOString();
db.prepare(
"INSERT INTO chapters (id, book_id, project_id, title, doc, rev, ord, updated_at) " +
"VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
).run(id, bookId, projectId, title, JSON.stringify(doc(paras)), paras.length, t);
return id;
}
/** Upsert a character entity. */
function addCharacter(db: any, projectId: string, c: Character): void {
const row = entityToRow(c);
const t = new Date().toISOString();
db.prepare(
`INSERT INTO entities (id, project_id, kind, name, fields, created_at, updated_at) ` +
"VALUES (?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT(project_id, kind, id) DO UPDATE SET " +
"name = excluded.name, fields = excluded.fields, updated_at = excluded.updated_at",
).run(row.id, projectId, row.kind, row.name, row.fields, t, t);
}
/** Upsert a world location entity. */
function addWorld(db: any, projectId: string, w: WorldItem): void {
const row = entityToRow(w);
const t = new Date().toISOString();
db.prepare(
`INSERT INTO entities (id, project_id, kind, name, fields, created_at, updated_at) ` +
"VALUES (?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT(project_id, kind, id) DO UPDATE SET " +
"name = excluded.name, fields = excluded.fields, updated_at = excluded.updated_at",
).run(row.id, projectId, row.kind, row.name, row.fields, t, t);
}
/** Upsert the story board with six nodes. */
function addBoard(db: any, projectId: string, board: Board): void {
const t = new Date().toISOString();
db.prepare(
`INSERT INTO boards (id, project_id, nodes, edges, updated_at) VALUES (?, ?, ?, ?, ?) ` +
"ON CONFLICT(id) DO UPDATE SET " +
"project_id = excluded.project_id, nodes = excluded.nodes, " +
"edges = excluded.edges, updated_at = excluded.updated_at",
).run(board.id, projectId, JSON.stringify(board.nodes), JSON.stringify(board.edges), t);
}
async function main(): Promise<void> {
const db = openDb();
// Ensure an admin owner exists (idempotent).
await ensureAdmin(db, hashPassword);
const owner = db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").get() as { id: string };
if (!owner) throw new Error("seed: no admin user to own the demo project");
// Idempotent: skip if the demo already exists.
const existing = db.prepare("SELECT id FROM projects WHERE title = ?").get(DEMO_TITLE) as
| { id: string }
| undefined;
if (existing) {
console.log(`seed: "${DEMO_TITLE}" already exists (id ${existing.id}); leaving it untouched.`);
db.close();
return;
}
const t = new Date().toISOString();
const projectId = ulid();
// Project.
db.prepare(
"INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at) VALUES (?, ?, ?, '{}', ?, ?, ?)",
).run(projectId, owner.id, DEMO_TITLE, JSON.stringify({ color: "#0f3d3e", emoji: "🌊" }), t, t);
// Book.
const bookId = ulid();
db.prepare("INSERT INTO books (id, project_id, title, ord) VALUES (?, ?, ?, 0)").run(bookId, projectId, DEMO_TITLE);
// Three chapters of real prose.
addChapter(
db,
bookId,
projectId,
"Chapter One — The Arrival",
[
"The ferry that carried Mara Vell to the Glass Harbor left the mainland before dawn, and by the time it came to rest the sea had turned the color of a bruised plum.",
"She pressed her palm against the cold window and watched the lighthouse appear out of the mist — a thin white line standing where the land gave up to the water.",
"Three years since she had last seen it, and yet it seemed to have shrunk, or perhaps she had simply grown too large for the memory of it.",
"The harbor master waited for her on the quay, wrapped in oilskin against a wind that did not so much blow as insist. He did not wave. He never waved.",
'"You came," he said, as if to say he had not expected her, and had been wrong about it all morning.',
'"The light is failing," he added. "We have had three nights without it. The fish are turning away."',
],
);
addChapter(
db,
bookId,
projectId,
"Chapter Two — The Storm",
[
"The storm arrived on the second night, and it came the way everything at the Glass Harbor came — without apology and without warning.",
"Mara climbed the one hundred and fourteen steps to the lamp, each one slick with salt spray, and found that the mechanism had seized with the same stubborn rust that had seized everything else on this rock.",
"She worked through the small hours with a wrench and her own two hands, coaxing the gears until at last the great beam swung free and threw its light out across the black water.",
"Below her, in the harbor, the boats threw their lines and danced on them, and she thought of all the sailors who had been lost here when the light was dead.",
'"Four ships in ten years," said a voice behind her. It was Old Fen, the hermit, half-frozen and grinning as if the storm were a joke only he understood.',
'"They did not die because the light failed," Fen said. "They died because they forgot that someone had to keep it."',
],
);
addChapter(
db,
bookId,
projectId,
"Chapter Three — The Glass in the Dark",
[
"In the morning the sea lay flat and exhausted, strewn with the kelp and driftwood the storm had disgorged.",
"Mara walked the shoreline and found, half-buried in the wrack, a shard of glass the size of her palm — but the glass was warm, and it held inside it a light that did not belong to the rising sun.",
"This was the secret the Glass Harbor had kept for a hundred years: the light of the lighthouse was not made, it was mined — drawn up from the deep in shards of living glass that only a keeper knew how to kindle.",
"She understood then why the old keeper had never waved, and why he had never left. Some duties are not chosen. They are inherited, like the tide.",
"When Tomas Reed returned from his rounds that evening, he found Mara at the lamp, her hands on the warm glass, and he stopped in the doorway with the old grief on his face.",
'"I can teach you," he said. "If you want it. No one has ever wanted it before."',
"Mara looked out at the water, at the fragile line of the quay, at the little lights of the town that had welcomed her without asking why she had come.",
'"I want it," she said, and the light answered for her.',
],
);
// Four linked characters.
addCharacter(db, projectId, {
id: "char-mara-vell",
projectId,
name: "Mara Vell",
role: "The New Keeper",
bio: "A cartographer who returned to the Glass Harbor she fled three years ago, carrying a grief she has never named. Precise, stubborn, and quietly afraid of being needed.",
appearance: "Late thirties, salt-short hair, salt-calloused hands, a scar through one eyebrow from a rigging line she did not remember cutting herself on.",
voice: "Dry, understated, speaks like someone counting the cost of every word.",
relationships: [
{ targetId: "char-tomas-reed", kind: "family", note: "Uncle; the keeper before her." },
{ targetId: "char-old-fen", kind: "mentor", note: "Taught her to swim in waters too cold for children." },
],
arc: "From someone running from duty to someone who chooses to keep the light.",
status: "active",
tags: ["protagonist", "keeper"],
});
addCharacter(db, projectId, {
id: "char-tomas-reed",
projectId,
name: "Tomas Reed",
role: "The Harbor Master",
bio: "The keeper of the quay and the keeper of the light before Mara. Carries the old grief of a storm he could not save. Does not wave because he believes grief is a full-time job.",
appearance: "Sixties, weathered like driftwood, one leg that aches before a storm.",
voice: "Few words. Speaks in the short sentences of a man who has learned words are expensive.",
relationships: [
{ targetId: "char-mara-vell", kind: "family", note: "Niece; the keeper he is training." },
{ targetId: "char-old-fen", kind: "ally", note: "Oldest friend and only confidant." },
],
arc: "From guarding a secret alone to trusting someone to inherit it.",
status: "active",
tags: ["mentor", "keeper"],
});
addCharacter(db, projectId, {
id: "char-iris-kade",
projectId,
name: "Iris Kade",
role: "The Smuggler",
bio: "Runs the supply boat between the harbor and the mainland, carrying glass as carefully as she carries secrets. Witty, generous, and impossible to trust with a straight face.",
appearance: "Forties, sun-leathered, a silver ring through each ear, always barefoot even in winter.",
voice: "Fast, warm, teasing. Lies beautifully and enjoys it.",
relationships: [
{ targetId: "char-mara-vell", kind: "ally", note: "Funds the lighthouse quietly; becomes Mara's ally on the mainland." },
{ targetId: "char-tomas-reed", kind: "rival", note: "Clashes over how to keep the harbor alive." },
],
arc: "From a self-interested survivor to someone with a place to belong.",
status: "active",
tags: ["ally", "rival"],
});
addCharacter(db, projectId, {
id: "char-old-fen",
projectId,
name: "Old Fen",
role: "The Hermit",
bio: "Lives in a cottage where the land gives up to the water. Has seen four ships lost and never once said how he knew it was coming. Everyone fears him and loves him in the same breath.",
appearance: "Ageless, thin, eyes the pale gray of the sea before a storm.",
voice: "Speaks in riddles that turn out to be warnings. Grins at the wrong moments.",
relationships: [
{ targetId: "char-mara-vell", kind: "mentor", note: "The child he taught to swim; the keeper he is guiding." },
{ targetId: "char-tomas-reed", kind: "ally", note: "Oldest friend; the only one who understands him." },
],
arc: "From keeper of impossible knowledge to the one who finally tells the truth.",
status: "active",
tags: ["mysterious", "hermit"],
});
// One world location.
addWorld(db, projectId, {
id: "world-glass-harbor",
projectId,
kind: "location",
name: "The Glass Harbor",
fields: {
kind: "location",
description:
"A fishing village on a wind-battered rock at the edge of the mainland shelf, lit by a lighthouse whose beam is fed by shards of living glass drawn from the deep.",
population: "Under two hundred",
economy: "Fishing, and the quiet, illegal trade in harbor-glass that keeps the light alive.",
notable:
"The Lighthouse (one hundred and fourteen steps to the lamp) · The Quay (where Tomas waits and Iris unloads her cargo) · The Wrack Line (where the sea deposits what the storm has taken).",
},
});
// Story board with six nodes.
addBoard(db, projectId, {
id: "board-story",
projectId,
nodes: [
{ id: "n1", type: "note", position: { x: 80, y: 120 }, data: { text: "Mara arrives at the harbor" } },
{ id: "n2", type: "note", position: { x: 320, y: 120 }, data: { text: "The light is failing" } },
{ id: "n3", type: "note", position: { x: 560, y: 120 }, data: { text: "The storm arrives" } },
{ id: "n4", type: "note", position: { x: 800, y: 120 }, data: { text: "Mara saves the light" } },
{ id: "n5", type: "note", position: { x: 320, y: 320 }, data: { text: "The secret of the glass" } },
{ id: "n6", type: "note", position: { x: 560, y: 320 }, data: { text: "Mara chooses the duty" } },
],
edges: [
{ id: "e1", source: "n1", target: "n2", type: "leads-to" },
{ id: "e2", source: "n2", target: "n3", type: "leads-to" },
{ id: "e3", source: "n3", target: "n4", type: "leads-to" },
{ id: "e4", source: "n4", target: "n5", type: "causes" },
{ id: "e5", source: "n5", target: "n6", type: "leads-to" },
],
});
console.log(`seed: created demo project "${DEMO_TITLE}" (id ${projectId})`);
console.log(`seed: 1 book, 3 chapters, 4 characters, 1 world location, 1 board`);
console.log(`seed: owner: ${owner.id}`);
console.log(`seed: login with PUBSCRIPT_ADMIN_EMAIL=${process.env.PUBSCRIPT_ADMIN_EMAIL ?? "admin@pubscript.local"}`);
db.close();
}
main().catch((err) => {
console.error("seed failed:", err);
process.exit(1);
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect, afterEach } from "vitest";
import { buildApp } from "./index.js";
afterEach(async () => {
// Each test boots its own server; close it to free the DB handle.
// (No shared server state is asserted across tests.)
});
describe("server config", () => {
it("exposes sensible defaults", async () => {
const { config } = await import("./config.js");
expect(config.port).toBe(8080);
expect(config.version).toBeTruthy();
expect(typeof config.dbPath).toBe("string");
});
});
describe("health endpoint", () => {
it("reports ok and db up", async () => {
const app = await buildApp();
try {
const res = await app.inject({ method: "GET", url: "/api/health" });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.ok).toBe(true);
expect(body.db).toBe("up");
expect(body.version).toBeTruthy();
} finally {
await app.close();
}
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { FastifyInstance } from "fastify";
import { WebSocket } from "ws";
import { canAccessProject, bearerToken, userFromToken } from "./auth.js";
import { toUser } from "./mapping.js";
interface Client {
ws: WebSocket;
projectId: string;
userId: string;
}
/**
* Register the `/api/ws` endpoint. Clients connect with `?projectId=X` and an
* auth token (bearer header or cookie). The server validates access, then
* relays `autosave` and `presence` events to the other clients in the same
* project (never back to the sender).
*/
export function registerWs(app: FastifyInstance): void {
const rooms = new Map<string, Set<Client>>();
app.get("/api/ws", { websocket: true }, (socket: WebSocket, req) => {
const db = app.db;
const query = req.query as { projectId?: string };
const projectId = query.projectId;
// Authenticate (bearer header or cookie).
const cookieToken = (req.headers.cookie ?? "")
.split("; ")
.find((c) => c.startsWith("ps_session="))
?.split("=")[1] ?? null;
const token = bearerToken(req) ?? cookieToken;
const row = token ? userFromToken(db, token) : null;
if (!row || !projectId || !canAccessProject(db, toUser(row), projectId)) {
socket.close(4401);
return;
}
const client: Client = { ws: socket, projectId, userId: row.id };
const room = rooms.get(projectId) ?? new Set<Client>();
room.add(client);
rooms.set(projectId, room);
socket.on("message", (data: WebSocket.RawData) => {
let msg: any;
try {
msg = JSON.parse(String(data));
} catch {
return;
}
if (msg?.type !== "autosave" && msg?.type !== "presence") return;
const payload = JSON.stringify({ ...msg, userId: client.userId, at: new Date().toISOString() });
for (const other of room) {
if (other !== client && other.ws.readyState === WebSocket.OPEN) {
other.ws.send(payload);
}
}
});
const cleanup = (): void => {
room.delete(client);
if (room.size === 0) rooms.delete(projectId);
};
socket.on("close", cleanup);
socket.on("error", cleanup);
});
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
},
});
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["node"]
},
"exclude": ["node_modules", "dist"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
exclude: ["**/node_modules/**", "**/dist/**"],
},
});