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
+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,
},
});