From 1c9b531996d5ee484a3b1d597513855eb72aca91 Mon Sep 17 00:00:00 2001 From: Schwitzz Date: Sat, 24 Oct 2026 18:31:20 -0500 Subject: [PATCH] Implement CHUNK #2: server layer, core schemas/adapters, contract tests, seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .editorconfig | 15 + .github/workflows/ci.yml | 41 + .gitignore | 39 + .prettierrc | 10 + CHUNK#1.md | 2 +- CHUNK#2.md | 2 +- PLAN.md | 30 +- README.md | 42 + apps/web/index.html | 14 + apps/web/package.json | 32 + apps/web/src/App.tsx | 63 + apps/web/src/components/CommandPalette.tsx | 109 + apps/web/src/components/Settings.tsx | 117 + apps/web/src/components/Sidebar.tsx | 44 + apps/web/src/components/Topbar.tsx | 58 + apps/web/src/components/index.ts | 7 + apps/web/src/main.tsx | 12 + apps/web/src/routes/AI.tsx | 32 + apps/web/src/routes/Admin.tsx | 39 + apps/web/src/routes/Characters.tsx | 52 + apps/web/src/routes/Plot.tsx | 27 + apps/web/src/routes/Projects.tsx | 35 + apps/web/src/routes/Settings.tsx | 5 + apps/web/src/routes/Whiteboard.tsx | 28 + apps/web/src/routes/Write.tsx | 65 + apps/web/src/store/settings.ts | 67 + apps/web/tsconfig.json | 8 + apps/web/vite.config.ts | 15 + apps/web/vitest.config.ts | 12 + docker/Dockerfile | 51 + docker/compose.yaml | 16 + docker/entrypoint.sh | 10 + docs/CONVENTIONS.md | 52 + eslint.config.js | 26 + package.json | 28 + packages/core/package.json | 32 + packages/core/src/autosave.test.ts | 69 + packages/core/src/autosave.ts | 107 + packages/core/src/backlinks.test.ts | 55 + packages/core/src/backlinks.ts | 84 + packages/core/src/ids.test.ts | 30 + packages/core/src/ids.ts | 23 + packages/core/src/index.ts | 15 + packages/core/src/schema.test.ts | 23 + packages/core/src/schema.ts | 100 + packages/core/src/schemas.ts | 118 + packages/core/src/storage-local.ts | 133 + packages/core/src/storage-server.ts | 132 + packages/core/src/storage.contract.test.ts | 144 + packages/core/src/storage.ts | 42 + packages/core/src/time.test.ts | 23 + packages/core/src/time.ts | 25 + packages/core/src/tokens.test.ts | 37 + packages/core/src/tokens.ts | 63 + packages/core/src/types.ts | 259 ++ packages/core/tsconfig.json | 8 + packages/core/vitest.config.ts | 9 + packages/ui/package.json | 28 + packages/ui/src/AppShell.css | 416 ++ packages/ui/src/components/Badge.tsx | 10 + packages/ui/src/components/Button.tsx | 33 + packages/ui/src/components/Drawer.tsx | 40 + packages/ui/src/components/EmptyState.tsx | 19 + packages/ui/src/components/Input.tsx | 28 + packages/ui/src/components/Modal.tsx | 46 + packages/ui/src/components/Select.tsx | 25 + packages/ui/src/components/Spinner.tsx | 16 + packages/ui/src/components/Tabs.tsx | 58 + packages/ui/src/components/Toast.tsx | 62 + packages/ui/src/components/Tooltip.tsx | 17 + packages/ui/src/components/index.ts | 22 + packages/ui/src/fonts.ts | 36 + packages/ui/src/index.ts | 10 + packages/ui/src/theme.test.ts | 49 + packages/ui/src/theme.ts | 132 + packages/ui/tsconfig.json | 9 + packages/ui/vitest.config.ts | 9 + pnpm-lock.yaml | 4420 ++++++++++++++++++++ pnpm-workspace.yaml | 4 + server/package.json | 36 + server/src/augment.d.ts | 11 + server/src/auth.ts | 181 + server/src/config.ts | 42 + server/src/db.ts | 77 + server/src/index.ts | 75 + server/src/mapping.ts | 268 ++ server/src/migrations/001_initial.sql | 101 + server/src/routes.ts | 584 +++ server/src/seed.ts | 275 ++ server/src/server.test.ts | 32 + server/src/ws.ts | 66 + server/tsconfig.json | 7 + server/vitest.config.ts | 9 + tsconfig.base.json | 23 + vitest.config.ts | 10 + 95 files changed, 10006 insertions(+), 16 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/components/CommandPalette.tsx create mode 100644 apps/web/src/components/Settings.tsx create mode 100644 apps/web/src/components/Sidebar.tsx create mode 100644 apps/web/src/components/Topbar.tsx create mode 100644 apps/web/src/components/index.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/routes/AI.tsx create mode 100644 apps/web/src/routes/Admin.tsx create mode 100644 apps/web/src/routes/Characters.tsx create mode 100644 apps/web/src/routes/Plot.tsx create mode 100644 apps/web/src/routes/Projects.tsx create mode 100644 apps/web/src/routes/Settings.tsx create mode 100644 apps/web/src/routes/Whiteboard.tsx create mode 100644 apps/web/src/routes/Write.tsx create mode 100644 apps/web/src/store/settings.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 apps/web/vitest.config.ts create mode 100644 docker/Dockerfile create mode 100644 docker/compose.yaml create mode 100644 docker/entrypoint.sh create mode 100644 docs/CONVENTIONS.md create mode 100644 eslint.config.js create mode 100644 package.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/autosave.test.ts create mode 100644 packages/core/src/autosave.ts create mode 100644 packages/core/src/backlinks.test.ts create mode 100644 packages/core/src/backlinks.ts create mode 100644 packages/core/src/ids.test.ts create mode 100644 packages/core/src/ids.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/schema.test.ts create mode 100644 packages/core/src/schema.ts create mode 100644 packages/core/src/schemas.ts create mode 100644 packages/core/src/storage-local.ts create mode 100644 packages/core/src/storage-server.ts create mode 100644 packages/core/src/storage.contract.test.ts create mode 100644 packages/core/src/storage.ts create mode 100644 packages/core/src/time.test.ts create mode 100644 packages/core/src/time.ts create mode 100644 packages/core/src/tokens.test.ts create mode 100644 packages/core/src/tokens.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/vitest.config.ts create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/AppShell.css create mode 100644 packages/ui/src/components/Badge.tsx create mode 100644 packages/ui/src/components/Button.tsx create mode 100644 packages/ui/src/components/Drawer.tsx create mode 100644 packages/ui/src/components/EmptyState.tsx create mode 100644 packages/ui/src/components/Input.tsx create mode 100644 packages/ui/src/components/Modal.tsx create mode 100644 packages/ui/src/components/Select.tsx create mode 100644 packages/ui/src/components/Spinner.tsx create mode 100644 packages/ui/src/components/Tabs.tsx create mode 100644 packages/ui/src/components/Toast.tsx create mode 100644 packages/ui/src/components/Tooltip.tsx create mode 100644 packages/ui/src/components/index.ts create mode 100644 packages/ui/src/fonts.ts create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/theme.test.ts create mode 100644 packages/ui/src/theme.ts create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/vitest.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 server/package.json create mode 100644 server/src/augment.d.ts create mode 100644 server/src/auth.ts create mode 100644 server/src/config.ts create mode 100644 server/src/db.ts create mode 100644 server/src/index.ts create mode 100644 server/src/mapping.ts create mode 100644 server/src/migrations/001_initial.sql create mode 100644 server/src/routes.ts create mode 100644 server/src/seed.ts create mode 100644 server/src/server.test.ts create mode 100644 server/src/ws.ts create mode 100644 server/tsconfig.json create mode 100644 server/vitest.config.ts create mode 100644 tsconfig.base.json create mode 100644 vitest.config.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3ade39e --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5cfc0e6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19e90bc --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b06c871 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "always" +} diff --git a/CHUNK#1.md b/CHUNK#1.md index fb0b00d..c69f277 100644 --- a/CHUNK#1.md +++ b/CHUNK#1.md @@ -1,6 +1,6 @@ # 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. diff --git a/CHUNK#2.md b/CHUNK#2.md index 68c69b5..3e35e03 100644 --- a/CHUNK#2.md +++ b/CHUNK#2.md @@ -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. - [ ] 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). -- [ ] 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. ## Definition of done diff --git a/PLAN.md b/PLAN.md index caa71cc..ec0a1f1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. -| # | Chunk | ~Days | Delivers | -|---|---|---|---| -| 1 | **Scaffold & Foundations** | 1 | 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 | -| 3 | **Writing Surface** | 3 | 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 | -| 5 | **Plot & Whiteboard** | 3 | Beat board + templates, timeline, React Flow whiteboard (200-node perf) | -| 6 | **Export Pipeline** | 3 | MD/HTML/EPUB/DOCX/ODT + PDF, export dialog, CI validation | -| 7 | **Spell & Grammar** | 2 | hunspell-WASM spellcheck, LanguageTool grammar, decorations + panel | -| 8 | **AI Provider Layer** | 2 | Provider config + discovery, streaming, thinking params, token budget | -| 9 | **AI Roles & Suggestions** | 3 | 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 | -| 11 | **Packaging, Parity & Polish** | 3 | Docker compose, Tauri bundles (3 OS), onboarding, import/backup, perf/a11y, parity matrix | +| # | Chunk | ~Days | Status | Delivers | +|---|---|---|---|---| +| 1 | **Scaffold & Foundations** | 1 | ✅ Done | Monorepo, strict TS, tooling, app shell, theme system, CI, settings store | +| 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 | ⬜ Not started | TipTap editor, document tree, fonts, focus mode, Markdown I/O, performance | +| 4 | **Characters & World-Building** | 2 | ⬜ Not started | Entity schema, character sheets, world items, search, mentions, backlinks | +| 5 | **Plot & Whiteboard** | 3 | ⬜ Not started | Beat board + templates, timeline, React Flow whiteboard (200-node perf) | +| 6 | **Export Pipeline** | 3 | ⬜ Not started | MD/HTML/EPUB/DOCX/ODT + PDF, export dialog, CI validation | +| 7 | **Spell & Grammar** | 2 | ⬜ Not started | hunspell-WASM spellcheck, LanguageTool grammar, decorations + panel | +| 8 | **AI Provider Layer** | 2 | ⬜ Not started | Provider config + discovery, streaming, thinking params, token budget | +| 9 | **AI Roles & Suggestions** | 3 | ⬜ Not started | Editor/Reader/Publisher/Co-Author, context builder, tools, accept/reject/pin | +| 10 | **Multi-User Admin & Sharing** | 2 | ⬜ Not started | Admin user management, roles, project sharing, audit, security | +| 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. +**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 @@ -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.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..99b29c4 --- /dev/null +++ b/README.md @@ -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.* diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..6558e8f --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,14 @@ + + + + + + + PubScript + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..0a4a138 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..f34f316 --- /dev/null +++ b/apps/web/src/App.tsx @@ -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 ( +
+ +
+ setPaletteOpen(true)} /> +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ + setPaletteOpen(false)} nav={NAV} /> + +
+ ); +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000..60b61fe --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -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(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 ( +
+
e.stopPropagation()} + > + { + setQuery(e.target.value); + setActive(0); + }} + /> +
+ {filtered.length === 0 ? ( +
No matches
+ ) : ( + filtered.map((c, i) => ( + + )) + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/Settings.tsx b/apps/web/src/components/Settings.tsx new file mode 100644 index 0000000..e929b7b --- /dev/null +++ b/apps/web/src/components/Settings.tsx @@ -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 ( +
+
+

Appearance

+ +
+
+
Theme
+
Four themes ship out of the box.
+
+
+ {THEME_IDS.map((id) => ( + + ))} +
+
+ +
+
+
Density
+
Spacing between UI elements.
+
+ set({ fontSize: Number(e.target.value) })} + options={[13, 14, 15, 16, 17, 18].map((n) => ({ value: String(n), label: `${n}px` }))} + /> +
+
+ +
+

Writing

+ +
+
+
Word count goal
+
Target length for your manuscript.
+
+ set({ showLineNumbers: e.target.checked })} + /> + +
+ +
+
+
Auto-save
+
Persist changes automatically.
+
+ +
+
+ +
+
+
Reset to defaults
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..0ad9836 --- /dev/null +++ b/apps/web/src/components/Sidebar.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/Topbar.tsx b/apps/web/src/components/Topbar.tsx new file mode 100644 index 0000000..83f9bf5 --- /dev/null +++ b/apps/web/src/components/Topbar.tsx @@ -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 = { + "/": "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 ( +
+

{title}

+ + {autoSave && ✓ autosave on} + +
+ + + + +
+ + + +
+ +
+

Privacy

+
    +
  • Documents are never uploaded without explicit consent.
  • +
  • Context is chunked to stay within the token budget.
  • +
  • Session history is stored locally and deletable.
  • +
+
+
+ ); +} diff --git a/apps/web/src/routes/Admin.tsx b/apps/web/src/routes/Admin.tsx new file mode 100644 index 0000000..64ad3ef --- /dev/null +++ b/apps/web/src/routes/Admin.tsx @@ -0,0 +1,39 @@ +import { Badge, Button } from "@pubscript/ui"; + +export function Admin() { + return ( +
+
+

Admin

+ Beta +
+

+ Local administration: manage the device token, inspect the store, and reset persisted state. +

+ +
+
+
+
Device token
+
Unique per-device identifier for the storage adapter.
+
+ +
+
+
+
Store diagnostics
+
Inspect the active storage backend and its health.
+
+ +
+
+
+
Clear local data
+
Delete all cached documents and settings.
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/routes/Characters.tsx b/apps/web/src/routes/Characters.tsx new file mode 100644 index 0000000..90c153a --- /dev/null +++ b/apps/web/src/routes/Characters.tsx @@ -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 ( +
+
+

Characters

+ +
+ + + {CHARACTERS.map((c) => ( +
+
+
{c.name}
+
{c.role}
+
+ {c.kind} +
+ ))} +
+ ), + }, + { + id: "locations", + label: "Locations", + content: , + }, + { + id: "factions", + label: "Factions", + content: , + }, + ]} + /> + + ); +} diff --git a/apps/web/src/routes/Plot.tsx b/apps/web/src/routes/Plot.tsx new file mode 100644 index 0000000..8cf720a --- /dev/null +++ b/apps/web/src/routes/Plot.tsx @@ -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 ( +
+

Plot

+ {ACTS.map((a) => ( +
+

+ {a.act} {String(a.beats.length)} +

+
    + {a.beats.map((b) => ( +
  • {b}
  • + ))} +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/routes/Projects.tsx b/apps/web/src/routes/Projects.tsx new file mode 100644 index 0000000..ddb96f2 --- /dev/null +++ b/apps/web/src/routes/Projects.tsx @@ -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 ( +
+
+

Projects

+ +
+ +
+ {PROJECTS.map((p) => ( +
+
+
{p.title}
+
{p.words.toLocaleString()} words
+
+ {p.status} +
+ ))} +
+ + +
+ ); +} diff --git a/apps/web/src/routes/Settings.tsx b/apps/web/src/routes/Settings.tsx new file mode 100644 index 0000000..806b9d9 --- /dev/null +++ b/apps/web/src/routes/Settings.tsx @@ -0,0 +1,5 @@ +import { Settings as SettingsPanel } from "../components/Settings.js"; + +export function Settings() { + return ; +} diff --git a/apps/web/src/routes/Whiteboard.tsx b/apps/web/src/routes/Whiteboard.tsx new file mode 100644 index 0000000..b822fea --- /dev/null +++ b/apps/web/src/routes/Whiteboard.tsx @@ -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 ( +
+
+

Whiteboard

+ +
+
+ {CARDS.map((c) => ( +
+
+

{c.title}

+

{c.note}

+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/routes/Write.tsx b/apps/web/src/routes/Write.tsx new file mode 100644 index 0000000..dcbdf32 --- /dev/null +++ b/apps/web/src/routes/Write.tsx @@ -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 ( +
+ + + {showLineNumbers && ( +
+ {lines.map((_, i) => ( + {i + 1} + ))} +
+ )} + +