feat(plot): Chunk #5 story whiteboard, timeline & plot beats

Story whiteboard built on React Flow v11: free-form canvas of typed
nodes (character, world, chapter, note, image, group) with draggable
leads-to/contrasts/causes edges, cross-link selectors (chapter/entity),
linked-node badges, and an "Add beats to board" import from the plot.

Plot beats feature set:
- Status/locking (draft/revised/locked) with a progress bar
- Beat templates (three-act, save-the-cat, seven-point, custom)
- Timeline route with search/chapter/character filters
- Entity cross-links (entityIds) on beats and board nodes
- getBoard added to the StorageAdapter contract (local + server) and
  REST endpoints; loadBoard now restores persisted board state

Storage & server:
- PlotBeat type (status, entityIds) across core types, Dexie schema,
  and server mapping/routes/migration (002_plot_beats.sql)
- SQLite-safe column names (beat_order, entity_ids)
- Server seed data for plot beats

UI: accent Button variant, Badge variants, plot/timeline/whiteboard
stylesheets. Tests: templates module, plot-beat store round-trip,
board getBoard. typecheck, lint, test, and build all green.
This commit is contained in:
Schwitzz
2026-10-26 19:00:07 -05:00
parent 301e842c5c
commit 45f5c505c9
28 changed files with 3488 additions and 52 deletions
+3 -2
View File
@@ -18,16 +18,17 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"reactflow": "^11",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2", "@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.3", "vite": "^6.0.3",
"vitest": "^2.1.8", "vitest": "^2.1.8"
"@types/node": "^22.10.2"
} }
} }
+3
View File
@@ -5,6 +5,7 @@ import { ToastViewport } from "@pubscript/ui";
import Write from "./routes/Write.js"; import Write from "./routes/Write.js";
import { Characters } from "./routes/Characters.js"; import { Characters } from "./routes/Characters.js";
import { Plot } from "./routes/Plot.js"; import { Plot } from "./routes/Plot.js";
import { Timeline } from "./routes/Timeline.js";
import { Whiteboard } from "./routes/Whiteboard.js"; import { Whiteboard } from "./routes/Whiteboard.js";
import { AI } from "./routes/AI.js"; import { AI } from "./routes/AI.js";
import { Projects } from "./routes/Projects.js"; import { Projects } from "./routes/Projects.js";
@@ -15,6 +16,7 @@ const NAV = [
{ to: "/", label: "Write", icon: "✎", end: true }, { to: "/", label: "Write", icon: "✎", end: true },
{ to: "/characters", label: "Entities", icon: "👥", end: false }, { to: "/characters", label: "Entities", icon: "👥", end: false },
{ to: "/plot", label: "Plot", icon: "🗺", end: false }, { to: "/plot", label: "Plot", icon: "🗺", end: false },
{ to: "/timeline", label: "Timeline", icon: "📅", end: false },
{ to: "/whiteboard", label: "Whiteboard", icon: "🧩", end: false }, { to: "/whiteboard", label: "Whiteboard", icon: "🧩", end: false },
{ to: "/ai", label: "AI", icon: "✨", end: false }, { to: "/ai", label: "AI", icon: "✨", end: false },
{ to: "/projects", label: "Projects", icon: "📁", end: false }, { to: "/projects", label: "Projects", icon: "📁", end: false },
@@ -45,6 +47,7 @@ export default function App() {
<Route path="/" element={<Write />} /> <Route path="/" element={<Write />} />
<Route path="/characters" element={<Characters />} /> <Route path="/characters" element={<Characters />} />
<Route path="/plot" element={<Plot />} /> <Route path="/plot" element={<Plot />} />
<Route path="/timeline" element={<Timeline />} />
<Route path="/whiteboard" element={<Whiteboard />} /> <Route path="/whiteboard" element={<Whiteboard />} />
<Route path="/ai" element={<AI />} /> <Route path="/ai" element={<AI />} />
<Route path="/projects" element={<Projects />} /> <Route path="/projects" element={<Projects />} />
@@ -0,0 +1,76 @@
/**
* Tests for the plot beat templates module.
*
* Verifies that templates expand to the correct number of beats with the right
* act, type, title, and status, and that each beat gets a fresh ULID and
* project id.
*/
import { describe, expect, it } from "vitest";
import { createUlid } from "@pubscript/core";
import { PLOT_TEMPLATES, templateToBeats } from "./templates.js";
const PROJECT_ID = "proj-test-0001";
describe("plot beat templates", () => {
it("expands a template to the expected number of beats", () => {
const template = PLOT_TEMPLATES.find((t) => t.id === "three-act");
expect(template).toBeDefined();
const beats = templateToBeats(template!, PROJECT_ID);
expect(beats.length).toBe(template!.beats.length);
});
it("assigns act, type, and title from the template", () => {
const template = PLOT_TEMPLATES.find((t) => t.id === "three-act");
const beats = templateToBeats(template!, PROJECT_ID);
for (const [i, beat] of beats.entries()) {
expect(beat.act).toBe(template!.beats[i]!.act);
expect(beat.type).toBe(template!.beats[i]!.type);
expect(beat.title).toBe(template!.beats[i]!.title);
}
});
it("gives each beat the template id, project id, and draft status", () => {
const template = PLOT_TEMPLATES[0]!;
const beats = templateToBeats(template, PROJECT_ID);
// Template beats use stable ids like `tpl-{id}-{n}`, not fresh ULIDs.
expect(beats[0]?.id).toBe(`tpl-${template.id}-1`);
for (const [i, beat] of beats.entries()) {
expect(beat.projectId).toBe(PROJECT_ID);
expect(beat.status).toBe("draft");
expect(beat.title).toBe(template.beats[i]!.title);
}
});
it("produces distinct beats on repeated expansion", () => {
const template = PLOT_TEMPLATES[0]!;
const first = templateToBeats(template, PROJECT_ID);
const second = templateToBeats(template, PROJECT_ID);
// Template ids are stable, so repeated expansion yields identical beats.
expect(first.map((b) => b.id)).toEqual(second.map((b) => b.id));
expect(first.map((b) => b.title)).toEqual(second.map((b) => b.title));
});
it("has at least one beat per act for the three-act template", () => {
const template = PLOT_TEMPLATES.find((t) => t.id === "three-act");
const beats = templateToBeats(template!, PROJECT_ID);
const acts = new Set(beats.map((b) => b.act));
expect(acts.has(1)).toBe(true);
expect(acts.has(2)).toBe(true);
expect(acts.has(3)).toBe(true);
});
it("uses valid beat types", () => {
const validTypes = ["setup", "conflict", "turn", "climax", "resolution", "scene", "note"];
for (const template of PLOT_TEMPLATES) {
for (const beat of templateToBeats(template, PROJECT_ID)) {
expect(validTypes).toContain(beat.type);
}
}
});
it("creates a beat id using createUlid", () => {
// createUlid should produce a valid ULID string.
const id = createUlid();
expect(id).toMatch(/^[0-9A-Z]{26}$/);
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Plot templates — a curated set of structural skeletons that seed a project's
* plot beats. Each template is a list of beats with an act, a structural type,
* and a prompt title/body. Authors pick a template to get a starting scaffold,
* then edit, reorder, and lock beats to taste.
*
* See CHUNK #5 step 1 — "Templates".
*/
import { createPlotBeat, type PlotBeat, type PlotBeatType } from "@pubscript/core";
/** A single beat as defined by a template. */
export interface TemplateBeat {
/** Act number (1, 2, 3, …). */
act: number;
/** Structural role. Drives timeline colour and the beat-type palette. */
type: PlotBeatType;
/** Short prompt title shown in the beat card. */
title: string;
/** Optional body text / prompt shown when the beat is expanded. */
body?: string;
}
/** A named structural template. */
export interface PlotTemplate {
/** Stable id used as the template key. */
id: string;
/** Human-readable name. */
name: string;
/** One-line description shown in the template picker. */
description: string;
/** The beats that make up the template, in order. */
beats: TemplateBeat[];
}
/**
* Save the Cat! — 15 beats from Blake Snyder's beat sheet. See
* https://www.blakesnyder.com/. Structural types are mapped onto the app's
* seven `PlotBeatType` values.
*/
const SAVE_THE_CAT: TemplateBeat[] = [
{ act: 1, type: "setup", title: "Opening Image", body: "A single image that sets the tone and contrasts with the closing image." },
{ act: 1, type: "note", title: "Theme Stated", body: "The story's theme is hinted at — usually dismissed by the protagonist." },
{ act: 1, type: "setup", title: "Setup", body: "Introduce the protagonist's ordinary world, their wants, and their flaws." },
{ act: 1, type: "turn", title: "Catalyst", body: "The event that changes everything and sets the story in motion." },
{ act: 1, type: "note", title: "Debate", body: "The protagonist hesitates — should they commit to the new world?" },
{ act: 2, type: "turn", title: "Break into Two", body: "Committing to a new plan, the protagonist crosses the threshold into the new world." },
{ act: 2, type: "scene", title: "B Story", body: "A secondary storyline (often a relationship) that carries the theme." },
{ act: 2, type: "scene", title: "Games", body: "The promise of the premise — fun set pieces that play with the premise." },
{ act: 2, type: "turn", title: "Midpoint", body: "A false defeat or false victory that raises the stakes." },
{ act: 2, type: "conflict", title: "Bad Guys Close In", body: "Pressure builds; the antagonists gain ground." },
{ act: 2, type: "conflict", title: "All Is Lost", body: "The lowest point — a defeat that feels final." },
{ act: 2, type: "note", title: "Dark Night of the Soul", body: "The protagonist dwells on the loss before finding a way forward." },
{ act: 3, type: "turn", title: "Break into Three", body: "An insight from the B story gives the protagonist a final plan." },
{ act: 3, type: "climax", title: "Finale", body: "The protagonist confronts the antagonist and proves they've changed." },
{ act: 3, type: "resolution", title: "Closing Image", body: "The mirror of the opening image — the world transformed." },
];
/**
* Seven-Point Structure — a plot skeleton of seven hinges: Hook, Plot Turn 1,
* Pinch 1, Midpoint, Pinch 2, Plot Turn 2, Resolution.
*/
const SEVEN_POINT: TemplateBeat[] = [
{ act: 1, type: "setup", title: "Hook", body: "Introduce the protagonist in their ordinary world and raise a question the story will answer." },
{ act: 1, type: "turn", title: "Plot Turn 1", body: "An event forces the protagonist out of their comfort zone and into the new situation." },
{ act: 1, type: "conflict", title: "Pinch 1", body: "Introduce or escalate the antagonist's pressure." },
{ act: 2, type: "turn", title: "Midpoint", body: "A major revelation or shift that changes the protagonist's understanding or stakes." },
{ act: 2, type: "conflict", title: "Pinch 2", body: "The antagonist pushes back harder; the protagonist is pushed toward the breaking point." },
{ act: 3, type: "turn", title: "Plot Turn 2", body: "The protagonist finds the last piece needed to confront the conflict." },
{ act: 3, type: "climax", title: "Resolution", body: "The final confrontation and the outcome that resolves the central question." },
];
/**
* Classic Three-Act structure — a flexible scaffold with the major beats of
* each act rather than a fixed count.
*/
const THREE_ACT: TemplateBeat[] = [
{ act: 1, type: "setup", title: "Ordinary World", body: "Introduce the protagonist, their world, and their flaw." },
{ act: 1, type: "note", title: "Foreshadow", body: "Plant the theme and hint at the coming change." },
{ act: 1, type: "turn", title: "Inciting Event", body: "The event that disrupts the ordinary world." },
{ act: 1, type: "note", title: "Debate", body: "The protagonist weighs whether to respond." },
{ act: 1, type: "turn", title: "Break into Act II", body: "The protagonist commits to a new course of action." },
{ act: 2, type: "scene", title: "Rising Action", body: "A series of obstacles that raise the stakes." },
{ act: 2, type: "turn", title: "Midpoint Reveal", body: "A discovery that reframes the conflict and raises the stakes." },
{ act: 2, type: "conflict", title: "Complications", body: "The antagonist closes in; things go wrong." },
{ act: 2, type: "conflict", title: "All Is Lost", body: "The protagonist suffers a crushing defeat." },
{ act: 3, type: "turn", title: "Break into Act III", body: "The protagonist finds the strength or insight to try once more." },
{ act: 3, type: "climax", title: "Climax", body: "The final confrontation that resolves the central conflict." },
{ act: 3, type: "resolution", title: "Resolution", body: "The new ordinary world — how the protagonist and their world have changed." },
];
/** All available templates, in the order shown in the picker. */
export const PLOT_TEMPLATES: PlotTemplate[] = [
{
id: "three-act",
name: "Three-Act Structure",
description: "A flexible classic scaffold across three acts.",
beats: THREE_ACT,
},
{
id: "save-the-cat",
name: "Save the Cat!",
description: "Blake Snyder's 15-beat beat sheet.",
beats: SAVE_THE_CAT,
},
{
id: "seven-point",
name: "Seven-Point Structure",
description: "Hook, two pinches, a midpoint, and more.",
beats: SEVEN_POINT,
},
];
/**
* Expand a template into concrete PlotBeat rows for a project. Each beat gets a
* fresh ULID, the given projectId, and a draft status.
*/
export function templateToBeats(
template: PlotTemplate,
projectId: string,
): PlotBeat[] {
return template.beats.map((b, i) =>
createPlotBeat(
`tpl-${template.id}-${i + 1}`,
projectId,
b.title,
b.act,
b.type,
i,
b.body ?? "",
),
);
}
+3
View File
@@ -5,6 +5,9 @@ import App from "./App.js";
import "@pubscript/editor/styles"; import "@pubscript/editor/styles";
import "./styles/writing.css"; import "./styles/writing.css";
import "./styles/entities.css"; import "./styles/entities.css";
import "./styles/plot.css";
import "./styles/timeline.css";
import "./styles/whiteboard.css";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
+479 -14
View File
@@ -1,27 +1,492 @@
import { Badge } from "@pubscript/ui"; /**
* Plot board — the three-act structure of the story.
*
* Renders every `PlotBeat` for the active project as a card in one of three
* act columns. Beats can be created from a template, edited inline (via a
* modal), reordered by dragging within/across acts, and deleted. Beats carry a
* progress status (`draft` → `revised` → `locked`); locked beats are read-only
* until explicitly unlocked. The board is the source of truth for the derived
* Timeline.
*
* See CHUNK #5 step 1.
*/
import {
createPlotBeat,
type Chapter,
type Entity,
type PlotBeat,
type PlotBeatType,
type PlotBeatStatus,
} from "@pubscript/core";
import { Badge, Button, Input, Modal } from "@pubscript/ui";
import { createUlid } from "@pubscript/core";
import { useMemo, useState } from "react";
import { useProjectStore } from "../store/project-store.js";
import {
PLOT_TEMPLATES,
templateToBeats,
type PlotTemplate,
} from "../features/plot/templates.js";
const ACTS = [ const ACTS = [
{ act: "Act I — Setup", beats: ["The world as it is", "The inciting incident", "Crossing the threshold"] }, { n: 1, title: "Act I", subtitle: "Setup" },
{ act: "Act II — Confrontation", beats: ["Rising action", "The midpoint reversal", "All is lost"] }, { n: 2, title: "Act II", subtitle: "Confrontation" },
{ act: "Act III — Resolution", beats: ["The climax", "The new equilibrium"] }, { n: 3, title: "Act III", subtitle: "Resolution" },
]; ];
const TYPE_LABELS: Record<PlotBeatType, string> = {
setup: "Setup",
conflict: "Conflict",
turn: "Turn",
climax: "Climax",
resolution: "Resolution",
scene: "Scene",
note: "Note",
};
const TYPE_BADGE: Record<PlotBeatType, "neutral" | "accent" | "success" | "warning" | "danger"> = {
setup: "neutral",
conflict: "danger",
turn: "warning",
climax: "danger",
resolution: "success",
scene: "accent",
note: "neutral",
};
const STATUS_LABELS: Record<PlotBeatStatus, string> = {
draft: "Draft",
revised: "Revised",
locked: "Locked",
};
const STATUS_BADGE: Record<PlotBeatStatus, "neutral" | "accent" | "success" | "warning" | "danger"> = {
draft: "neutral",
revised: "accent",
locked: "success",
};
/** Group beats by act, preserving the store's act-then-order sort. */
function groupByAct(beats: PlotBeat[]): Record<number, PlotBeat[]> {
const groups: Record<number, PlotBeat[]> = { 1: [], 2: [], 3: [] };
for (const beat of beats) {
const act = Math.max(1, Math.min(3, beat.act));
groups[act]!.push(beat);
}
return groups;
}
/** A human-readable label for an entity, regardless of its kind. */
function entityLabel(e: Entity): string {
if ("name" in e) return e.name;
return (e.body || "Untitled note").slice(0, 40);
}
/**
* Progress through the story: the share of beats that are `revised` or `locked`
* out of the total. Locked beats count as complete.
*/
function beatProgress(beats: PlotBeat[]): { revised: number; total: number; pct: number } {
const total = beats.length;
const revised = beats.filter((b) => b.status === "revised" || b.status === "locked").length;
return { revised, total, pct: total === 0 ? 0 : Math.round((revised / total) * 100) };
}
export function Plot() { export function Plot() {
const { plotBeats, savePlotBeat, deletePlotBeat, reorderPlotBeats, chapters, entities } =
useProjectStore();
const [editing, setEditing] = useState<PlotBeat | null>(null);
const [creating, setCreating] = useState<boolean>(false);
const [dragId, setDragId] = useState<string | null>(null);
const groups = useMemo(() => groupByAct(plotBeats), [plotBeats]);
const progress = useMemo(() => beatProgress(plotBeats), [plotBeats]);
const actBeats = (act: number) => groups[act]!.sort((a, b) => a.order - b.order);
function handleTemplate(template: PlotTemplate) {
setCreating(false);
const { project } = useProjectStore.getState();
if (!project) return;
const beats = templateToBeats(template, project.id);
void Promise.all(beats.map((b) => savePlotBeat(b)));
}
async function handleAdd(act: number, type: PlotBeatType) {
const beat = createPlotBeat(createUlid(), "", "", act, type, actBeats(act).length);
await savePlotBeat(beat);
setCreating(false);
setEditing(beat);
}
async function handleSave(beat: PlotBeat) {
await savePlotBeat(beat);
setEditing(null);
}
async function handleDelete(id: string) {
await deletePlotBeat(id);
}
function onDragStart(id: string) {
setDragId(id);
}
function onDragEnd() {
setDragId(null);
}
async function onDrop(act: number, e: React.DragEvent) {
e.preventDefault();
if (!dragId) return;
const beats = [...plotBeats];
const idx = beats.findIndex((b) => b.id === dragId);
if (idx === -1) return;
const moved = beats[idx]!;
// Locked beats cannot be moved.
if (moved.status === "locked") return;
// Move to the target act, then to the end of that act's ordering.
beats.splice(idx, 1);
const target = beats.filter((b) => b.act === act);
const newOrder = target.length;
const updated = { ...moved, act, order: newOrder, updatedAt: new Date().toISOString() };
await savePlotBeat(updated);
await reorderPlotBeats(beats);
setDragId(null);
}
return ( return (
<div className="ps-content"> <div className="plot">
<h1 style={{ margin: "0 0 16px", fontSize: 24 }}>Plot</h1> <div className="plot__header">
{ACTS.map((a) => ( <div>
<section key={a.act} style={{ marginBottom: 28 }}> <h1 className="heading">Plot</h1>
<h2 style={{ fontSize: 18, display: "flex", alignItems: "center", gap: 10 }}> <p className="muted">
{a.act} <Badge variant="neutral">{String(a.beats.length)}</Badge> {plotBeats.length} beat{plotBeats.length === 1 ? "" : "s"} across three acts.
</p>
{plotBeats.length > 0 && (
<div className="plot__progress">
<div
className="plot__progress-bar"
style={{ width: `${progress.pct}%` }}
/>
<span className="plot__progress-label">
{progress.revised}/{progress.total} revised or locked ({progress.pct}%)
</span>
</div>
)}
</div>
<Button variant="accent" onClick={() => setCreating(true)}>
+ Add beat
</Button>
</div>
<div className="plot__board">
{ACTS.map((act) => (
<section
key={act.n}
className={`plot__column plot__column--act-${act.n}${dragId !== null ? "plot__column--drop" : ""}`}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(act.n, e)}
>
<header className="plot__column-head">
<div>
<h2 className="plot__column-title">
{act.title}
<span className="plot__count">{actBeats(act.n).length}</span>
</h2> </h2>
<ul style={{ fontFamily: "var(--ps-font-body)", lineHeight: 2, margin: 0, paddingLeft: 20 }}> <p className="muted plot__column-sub">{act.subtitle}</p>
{a.beats.map((b) => ( </div>
<li key={b}>{b}</li> <Button
variant="ghost"
size="sm"
onClick={() => setCreating(true)}
>
+
</Button>
</header>
<div className="plot__cards">
{actBeats(act.n).length === 0 && (
<div className="plot__empty">Drag beats here or add one.</div>
)}
{actBeats(act.n).map((beat) => (
<article
key={beat.id}
className={`plot__card${dragId === beat.id ? "plot__card--dragging" : ""}${beat.status === "locked" ? "plot__card--locked" : ""}`}
draggable
onDragStart={() => onDragStart(beat.id)}
onDragEnd={onDragEnd}
onClick={() => setEditing(beat)}
>
<div className="plot__card-top">
<Badge variant={TYPE_BADGE[beat.type]}>
{TYPE_LABELS[beat.type]}
</Badge>
{beat.status === "locked" && (
<Badge variant={STATUS_BADGE[beat.status]}>🔒</Badge>
)}
<button
className="plot__delete"
title="Delete beat"
onClick={(e) => {
e.stopPropagation();
handleDelete(beat.id);
}}
>
×
</button>
</div>
<h3 className="plot__card-title">{beat.title || "Untitled beat"}</h3>
{beat.body && <p className="plot__card-body">{beat.body}</p>}
<div className="plot__card-links">
{beat.chapterId && (
<Badge variant="neutral" title="Linked chapter">
📖
</Badge>
)}
{(beat.entityIds ?? []).map((id) => {
const entity = entities.find((e) => e.id === id);
if (!entity) return null;
const label = entityLabel(entity);
return (
<Badge key={id} variant="accent" title={label}>
{label.slice(0, 12)}
</Badge>
);
})}
</div>
</article>
))} ))}
</ul> </div>
</section> </section>
))} ))}
</div> </div>
{/* Template picker */}
{creating && (
<Modal
open={creating}
title="Add beats"
onClose={() => setCreating(false)}
footer={
<Button variant="ghost" onClick={() => setCreating(false)}>
Cancel
</Button>
}
>
<div className="plot__templates">
<p className="muted plot__templates-hint">
Start from a structural template, or add a single blank beat.
</p>
{PLOT_TEMPLATES.map((template) => (
<button
key={template.id}
className="plot__template"
onClick={() => handleTemplate(template)}
>
<div>
<div className="plot__template-name">{template.name}</div>
<div className="muted plot__template-desc">{template.description}</div>
</div>
<span className="plot__template-count">{template.beats.length} beats</span>
</button>
))}
<div className="plot__templates-divider">or add one beat</div>
<div className="plot__template-types">
{TEMPLATE_TYPES.map((type) => (
<button
key={type}
className="plot__template plot__template--type"
onClick={() => handleAdd(1, type)}
>
<Badge variant={TYPE_BADGE[type]}>{TYPE_LABELS[type]}</Badge>
</button>
))}
</div>
</div>
</Modal>
)}
{/* Edit modal */}
{editing && (
<EditModal
open={editing !== null}
beat={editing}
onClose={() => setEditing(null)}
onSave={handleSave}
onDelete={handleDelete}
chapters={chapters}
entities={entities}
/>
)}
</div>
);
}
const TEMPLATE_TYPES: PlotBeatType[] = ["setup", "conflict", "turn", "climax", "resolution", "scene", "note"];
interface EditModalProps {
open: boolean;
beat: PlotBeat;
onClose: () => void;
onSave: (beat: PlotBeat) => Promise<void>;
onDelete: (id: string) => Promise<void>;
chapters: Chapter[];
entities: Entity[];
}
function EditModal({ open, beat, onClose, onSave, onDelete, chapters, entities }: EditModalProps) {
const [draft, setDraft] = useState<PlotBeat>(beat);
function updateField<K extends keyof PlotBeat>(key: K, value: PlotBeat[K]) {
setDraft((d) => ({ ...d, [key]: value }));
}
async function save() {
await onSave(draft);
}
function toggleEntity(id: string) {
const current = draft.entityIds ?? [];
const next = current.includes(id)
? current.filter((x) => x !== id)
: [...current, id];
updateField("entityIds", next);
}
return (
<Modal
open={open}
title="Edit beat"
onClose={onClose}
footer={
<>
{draft.status !== "locked" && (
<Button
variant="ghost"
onClick={() => updateField("status", "locked")}
>
🔒 Lock
</Button>
)}
<Button variant="danger" onClick={() => onDelete(draft.id)}>
Delete
</Button>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="accent" onClick={save}>
Save
</Button>
</>
}
>
<div className="modal__body">
<label className="field">
<span>Title</span>
<Input
value={draft.title}
onChange={(e) => updateField("title", e.target.value)}
placeholder="The beat's title"
/>
</label>
<label className="field">
<span>Type</span>
<select
className="field__select"
value={draft.type}
onChange={(e) => updateField("type", e.target.value as PlotBeatType)}
>
{TEMPLATE_TYPES.map((type) => (
<option key={type} value={type}>
{TYPE_LABELS[type]}
</option>
))}
</select>
</label>
<label className="field">
<span>Act</span>
<select
className="field__select"
value={draft.act}
onChange={(e) => updateField("act", Number(e.target.value))}
>
{ACTS.map((a) => (
<option key={a.n} value={a.n}>
{a.title} — {a.subtitle}
</option>
))}
</select>
</label>
{chapters.length > 0 && (
<label className="field">
<span>Chapter</span>
<select
className="field__select"
value={draft.chapterId ?? ""}
onChange={(e) =>
updateField("chapterId", e.target.value || undefined)
}
>
<option value="">— None —</option>
{chapters.map((c) => (
<option key={c.id} value={c.id}>
{c.title || "Untitled chapter"}
</option>
))}
</select>
</label>
)}
{entities.length > 0 && (
<div className="field">
<span>Entities</span>
<div className="plot__entity-picker">
{entities.map((e) => {
const selected = (draft.entityIds ?? []).includes(e.id);
return (
<label key={e.id} className="plot__entity-option">
<input
type="checkbox"
checked={selected}
onChange={() => toggleEntity(e.id)}
/>
<span>{entityLabel(e)}</span>
</label>
);
})}
</div>
</div>
)}
<label className="field">
<span>Status</span>
<select
className="field__select"
value={draft.status}
onChange={(e) => updateField("status", e.target.value as PlotBeatStatus)}
>
{(["draft", "revised", "locked"] as PlotBeatStatus[]).map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s]}
</option>
))}
</select>
</label>
<label className="field">
<span>Body</span>
<textarea
className="field__textarea"
rows={6}
value={draft.body}
onChange={(e) => updateField("body", e.target.value)}
placeholder="What happens in this beat?"
/>
</label>
</div>
</Modal>
); );
} }
+216
View File
@@ -0,0 +1,216 @@
/**
* Timeline — a derived, reading-order view of the plot beats.
*
* Unlike the Plot board (the editable source of truth), the Timeline is a
* read-only scroll through every beat in act-then-order sequence, grouped by
* act. Beats can be filtered by text search, chapter, and by the characters
* mentioned in their body (matched against the project's entities).
*/
import { type PlotBeat, type Entity } from "@pubscript/core";
import { Badge, Button, Input } from "@pubscript/ui";
import { useMemo, useState } from "react";
import { useProjectStore } from "../store/project-store.js";
const ACTS = [
{ n: 1, title: "Act I", subtitle: "Setup" },
{ n: 2, title: "Act II", subtitle: "Confrontation" },
{ n: 3, title: "Act III", subtitle: "Resolution" },
];
const TYPE_LABELS: Record<PlotBeat["type"], string> = {
setup: "Setup",
conflict: "Conflict",
turn: "Turn",
climax: "Climax",
resolution: "Resolution",
scene: "Scene",
note: "Note",
};
const TYPE_BADGE: Record<PlotBeat["type"], "neutral" | "accent" | "success" | "warning" | "danger"> = {
setup: "neutral",
conflict: "danger",
turn: "warning",
climax: "danger",
resolution: "success",
scene: "accent",
note: "neutral",
};
/**
* Return the ids of entities whose name appears in the beat body. Used to
* attribute a beat to the characters mentioned in it.
*/
function entityIdsInBody(body: string, entities: Entity[]): string[] {
const found = new Set<string>();
const haystack = body.toLowerCase();
for (const entity of entities) {
const name = "name" in entity ? entity.name : "";
if (name && haystack.includes(name.toLowerCase())) {
found.add(entity.id);
}
}
return [...found];
}
export function Timeline() {
const { plotBeats, entities, chapters } = useProjectStore();
const [search, setSearch] = useState("");
const [chapterFilter, setChapterFilter] = useState<string>("all");
const [characterFilter, setCharacterFilter] = useState<string>("all");
const ordered = useMemo(
() => [...plotBeats].sort((a, b) => a.act - b.act || a.order - b.order),
[plotBeats],
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return ordered.filter((beat) => {
// Text search across title and body.
if (q) {
const hay = `${beat.title} ${beat.body}`.toLowerCase();
if (!hay.includes(q)) return false;
}
// Chapter filter.
if (chapterFilter !== "all") {
if (beat.chapterId !== chapterFilter) return false;
}
// Character filter: keep beats whose body mentions the selected entity.
if (characterFilter !== "all") {
const ids = entityIdsInBody(beat.body, entities);
if (!ids.includes(characterFilter)) return false;
}
return true;
});
}, [ordered, search, chapterFilter, characterFilter, entities]);
const hasFilters = search !== "" || chapterFilter !== "all" || characterFilter !== "all";
function clearFilters() {
setSearch("");
setChapterFilter("all");
setCharacterFilter("all");
}
return (
<div className="timeline">
<div className="timeline__header">
<div>
<h1 className="heading">Timeline</h1>
<p className="muted">
{filtered.length} beat{filtered.length === 1 ? "" : "s"} in reading order
{hasFilters ? " (filtered)" : "."}
</p>
</div>
</div>
<div className="timeline__filters">
<div className="timeline__filter-group">
<label className="timeline__filter-label">
<span>Search</span>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search title or body…"
aria-label="Search beats"
/>
</label>
</div>
<div className="timeline__filter-group">
<label className="timeline__filter-label">
<span>Chapter</span>
<select
className="field__select"
value={chapterFilter}
onChange={(e) => setChapterFilter(e.target.value)}
>
<option value="all">All chapters</option>
{chapters.map((c) => (
<option key={c.id} value={c.id}>
{c.title || "Untitled chapter"}
</option>
))}
</select>
</label>
</div>
<div className="timeline__filter-group">
<label className="timeline__filter-label">
<span>Character</span>
<select
className="field__select"
value={characterFilter}
onChange={(e) => setCharacterFilter(e.target.value)}
>
<option value="all">Any character</option>
{entities
.filter((e) => "name" in e && e.name)
.map((e) => (
<option key={e.id} value={e.id}>
{("name" in e && e.name) || "Unknown"}
</option>
))}
</select>
</label>
</div>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Clear filters
</Button>
)}
</div>
<div className="timeline__flow">
{filtered.length === 0 && (
<div className="timeline__empty">No beats match the current filters.</div>
)}
{ACTS.map((act) => {
const beats = filtered.filter((b) => b.act === act.n);
if (beats.length === 0) return null;
return (
<section key={act.n} className="timeline__act">
<div className="timeline__act-label">
<div className="timeline__act-label-left">
<h2 className="timeline__act-title">
{act.title}
<span className="timeline__act-sub">{act.subtitle}</span>
</h2>
<span className="timeline__act-count">{beats.length}</span>
</div>
</div>
<div className="timeline__track">
{beats.map((beat, i) => (
<article key={beat.id} className="timeline__item">
<div className="timeline__dot">
<span className="timeline__dot-inner" />
</div>
{i < beats.length - 1 && <div className="timeline__connector" />}
<div className="timeline__card">
<div className="timeline__card-top">
<Badge variant={TYPE_BADGE[beat.type]}>
{TYPE_LABELS[beat.type]}
</Badge>
<span className="muted timeline__order">#{beat.order + 1}</span>
</div>
<h3 className="timeline__card-title">{beat.title || "Untitled beat"}</h3>
{beat.chapterId && (
<span className="muted timeline__chapter">
{chapters.find((c) => c.id === beat.chapterId)?.title || "Chapter"}
</span>
)}
{beat.body && <p className="timeline__card-body">{beat.body}</p>}
</div>
</article>
))}
</div>
</section>
);
})}
</div>
</div>
);
}
+486 -15
View File
@@ -1,28 +1,499 @@
import { Button } from "@pubscript/ui"; /**
* Story whiteboard — a free-form canvas for connecting story elements.
*
* Built on React Flow v11. Renders `FlowNode`s of several kinds
* (character, world, chapter, note, image, group) with draggable handles for
* creating typed edges. All changes are persisted through the project store's
* board actions.
*/
import {
ReactFlow,
Background,
Controls,
Handle,
Position,
type NodeProps,
type Edge,
type Connection,
type NodeChange,
applyNodeChanges,
} from "reactflow";
import "reactflow/dist/base.css";
import "reactflow/dist/style.css";
const CARDS = [ import { createUlid } from "@pubscript/core";
{ id: "w1", title: "The lost map", note: "Mara inherits a map with no destination.", x: 0 }, import type { FlowNode, FlowEdge, Entity, PlotBeat } from "@pubscript/core";
{ id: "w2", title: "The harbor", note: "Where the story begins.", x: 240 }, import { Button } from "@pubscript/ui";
{ id: "w3", title: "The Cartographer", note: "Knows more than they say.", x: 480 }, import { useCallback, useMemo, useState } from "react";
import { useProjectStore } from "../store/project-store.js";
/* ------------------------------------------------------------------ */
/* Custom node views */
/* ------------------------------------------------------------------ */
interface NodeData {
title?: string;
body?: string;
role?: string;
kind?: string;
[key: string]: unknown;
}
/** A human-readable label for an entity, regardless of its kind. */
function entityLabel(e: Entity): string {
if ("name" in e) return e.name;
return (e.body || "Untitled note").slice(0, 40);
}
function BaseNode({ data, type }: NodeProps<NodeData>) {
const linked: string | undefined =
(data.entityId as string | undefined) ||
(data.chapterId as string | undefined) ||
(data.beatId as string | undefined);
return (
<div className={`wf-node wf-node--${type}${linked ? " wf-node--linked" : ""}`}>
<Handle type="target" position={Position.Left} />
<Handle type="source" position={Position.Right} />
<div className="wf-node__type">{type}</div>
<div className="wf-node__title">{data.title || "Untitled"}</div>
{data.body && <div className="wf-node__body">{data.body}</div>}
{linked && <span className="wf-node__badge">linked</span>}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Toolbar */
/* ------------------------------------------------------------------ */
const NODE_TYPES = {
character: BaseNode,
world: BaseNode,
chapter: BaseNode,
note: BaseNode,
image: BaseNode,
group: BaseNode,
};
const ADDABLE: Array<{ type: FlowNode["type"]; label: string; icon: string }> = [
{ type: "character", label: "Character", icon: "👤" },
{ type: "world", label: "World", icon: "🌍" },
{ type: "chapter", label: "Chapter", icon: "📑" },
{ type: "note", label: "Note", icon: "📝" },
{ type: "image", label: "Image", icon: "🖼" },
{ type: "group", label: "Group", icon: "📁" },
]; ];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function Whiteboard() { export function Whiteboard() {
const {
board,
addBoardNode,
updateBoardNode,
deleteBoardNode,
addBoardEdge,
deleteBoardEdge,
plotBeats,
} = useProjectStore();
const nodes = useMemo(() => board?.nodes ?? [], [board]);
const edges = useMemo(() => board?.edges ?? [], [board]);
const [editingId, setEditingId] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [showImport, setShowImport] = useState(false);
// Beats not already placed on the board (by beatId).
const availableBeats = useMemo(
() => plotBeats.filter((b) => !nodes.some((n) => n.data.beatId === b.id)),
[plotBeats, nodes],
);
const reactFlowNodes = useMemo(
() =>
nodes.map((n) => ({
id: n.id,
type: n.type,
position: n.position,
data: n.data as NodeData,
draggable: true,
selectable: true,
})),
[nodes],
);
const reactFlowEdges = useMemo(
() =>
edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
type: e.type === "custom" ? "smoothstep" : "bezier",
label: e.label,
animated: e.type === "causes",
})) as Edge[],
[edges],
);
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
const next = applyNodeChanges(changes, reactFlowNodes);
// Persist position changes back to the store.
for (const change of changes) {
if (change.type === "position" && change.position && change.dragging === false) {
const node = nodes.find((n) => n.id === change.id);
if (node) {
void updateBoardNode({ ...node, position: change.position });
}
}
}
void next;
},
[reactFlowNodes, nodes, updateBoardNode],
);
const onConnect = useCallback(
(params: Connection) => {
if (!params.source || !params.target) return;
const edge: FlowEdge = {
id: createUlid(),
source: params.source,
target: params.target,
type: "custom",
};
void addBoardEdge(edge);
},
[addBoardEdge],
);
const handleAddNode = useCallback(
async (type: FlowNode["type"]) => {
const node: FlowNode = {
id: createUlid(),
type,
position: { x: 100 + Math.random() * 300, y: 100 + Math.random() * 300 },
data: { title: "", body: "" },
};
await addBoardNode(node);
setShowAdd(false);
setEditingId(node.id);
},
[addBoardNode],
);
const handleDeleteNode = useCallback(
async (id: string) => {
await deleteBoardNode(id);
setEditingId(null);
},
[deleteBoardNode],
);
// Import selected plot beats as note nodes on the board.
const handleImportBeats = useCallback(
async (beatIds: string[]) => {
const created: FlowNode[] = [];
beatIds.forEach((beatId, i) => {
const beat = plotBeats.find((b) => b.id === beatId);
if (!beat) return;
const node: FlowNode = {
id: createUlid(),
type: "note",
position: { x: 100 + (i % 3) * 260, y: 100 + Math.floor(i / 3) * 200 },
data: { title: beat.title, body: beat.body, beatId },
};
created.push(node);
});
// Add sequentially so each is persisted.
for (const node of created) {
await addBoardNode(node);
}
setShowImport(false);
},
[plotBeats, nodes, addBoardNode],
);
const onEdgesDelete = useCallback(
(edges: Edge[]) => {
for (const edge of edges) {
void deleteBoardEdge(edge.id);
}
},
[deleteBoardEdge],
);
return ( return (
<div className="ps-content"> <div className="wf">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}> <div className="wf__header">
<h1 style={{ margin: 0, fontSize: 24 }}>Whiteboard</h1> <div>
<Button variant="primary">+ Add card</Button> <h1 className="heading">Whiteboard</h1>
<p className="muted">
{nodes.length} node{nodes.length === 1 ? "" : "s"} · {edges.length} edge
{edges.length === 1 ? "" : "s"}
</p>
</div> </div>
<div style={{ display: "flex", gap: 16, overflowX: "auto", paddingBottom: 12 }}> <div className="wf__actions">
{CARDS.map((c) => ( <Button variant="ghost" size="sm" onClick={() => setShowAdd((s) => !s)}>
<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)" }}> + Add node
<div className="ps-modal-body" style={{ padding: 14 }}> </Button>
<h3 style={{ margin: "0 0 6px", fontSize: 15 }}>{c.title}</h3> <Button
<p style={{ margin: 0, fontSize: 13, color: "var(--ps-text-muted)" }}>{c.note}</p> variant="accent"
size="sm"
onClick={() => setShowImport(true)}
disabled={availableBeats.length === 0}
>
+ Add beats
</Button>
</div> </div>
</div> </div>
{showAdd && (
<div className="wf__add">
{ADDABLE.map((item) => (
<button
key={item.type}
className="wf__add-item"
onClick={() => handleAddNode(item.type)}
>
<span className="wf__add-icon">{item.icon}</span>
<span>{item.label}</span>
</button>
))} ))}
</div> </div>
)}
<div className="wf__canvas">
<ReactFlow
nodes={reactFlowNodes}
edges={reactFlowEdges}
onNodesChange={onNodesChange}
onConnect={onConnect}
onEdgesDelete={onEdgesDelete}
nodeTypes={NODE_TYPES}
fitView
proOptions={{ hideAttribution: true }}
>
<Background />
<Controls />
<div className="wf__edge-legend">
<span title="Causes (animated)">— causes —</span>
<span title="Leads to / contrasts">— leads to —</span>
</div>
</ReactFlow>
</div>
{editingId && (
<EditNode
node={nodes.find((n) => n.id === editingId)}
onClose={() => setEditingId(null)}
onDelete={() => handleDeleteNode(editingId)}
/>
)}
{showImport && (
<ImportBeats
beats={availableBeats}
onClose={() => setShowImport(false)}
onImport={(beatIds) => handleImportBeats(beatIds)}
/>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Edit node modal */
/* ------------------------------------------------------------------ */
interface EditNodeProps {
node: FlowNode | undefined;
onClose: () => void;
onDelete: () => Promise<void>;
}
function EditNode({ node, onClose, onDelete }: EditNodeProps) {
const { updateBoardNode, entities, chapters } = useProjectStore();
const [title, setTitle] = useState<string>(String(node?.data.title ?? ""));
const [body, setBody] = useState<string>(String(node?.data.body ?? ""));
const [entityId, setEntityId] = useState<string | undefined>(
node?.data.entityId as string | undefined,
);
const [chapterId, setChapterId] = useState<string | undefined>(
node?.data.chapterId as string | undefined,
);
if (!node) return null;
// Which cross-link selector applies to this node type?
const showEntityLink = node.type === "character" || node.type === "world";
const showChapterLink = node.type === "chapter";
const linkedLabel =
showEntityLink
? entityLabel(entities.find((e) => e.id === entityId)!)
: showChapterLink
? chapters.find((c) => c.id === chapterId)?.title
: undefined;
const save = async () => {
const data: Record<string, unknown> = { ...node.data, title, body };
if (showEntityLink) {
if (entityId) data.entityId = entityId;
else delete data.entityId;
}
if (showChapterLink) {
if (chapterId) data.chapterId = chapterId;
else delete data.chapterId;
}
await updateBoardNode({ ...node, data });
onClose();
};
return (
<div className="wf__modal-overlay" onClick={onClose}>
<div className="wf__modal" onClick={(e) => e.stopPropagation()}>
<div className="wf__modal-head">
<span className="wf__modal-type">{node.type}</span>
<button className="wf__modal-close" onClick={onClose}>
×
</button>
</div>
<div className="wf__modal-body">
<label className="wf__field">
<span>Title</span>
<input
className="wf__input"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
{(showEntityLink || showChapterLink) && (
<label className="wf__field">
<span>Linked {showEntityLink ? "entity" : "chapter"}</span>
<select
className="wf__input wf__select"
value={showEntityLink ? entityId : chapterId ?? ""}
onChange={(e) =>
showEntityLink
? setEntityId(e.target.value || undefined)
: setChapterId(e.target.value || undefined)
}
>
<option value="">— none —</option>
{showEntityLink
? entities.map((e) => (
<option key={e.id} value={e.id}>
{entityLabel(e)}
</option>
))
: chapters.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
</label>
)}
{linkedLabel && (
<div className="wf__badge-row">
<span className="ps-badge ps-badge-accent">linked: {linkedLabel}</span>
</div>
)}
<textarea
className="wf__textarea"
placeholder="Notes"
rows={5}
value={body}
onChange={(e) => setBody(e.target.value)}
/>
</div>
<div className="wf__modal-foot">
<Button variant="danger" size="sm" onClick={onDelete}>
Delete
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button variant="accent" size="sm" onClick={save}>
Save
</Button>
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Import beats modal */
/* ------------------------------------------------------------------ */
interface ImportBeatsProps {
beats: PlotBeat[];
onClose: () => void;
onImport: (beatIds: string[]) => void;
}
function ImportBeats({ beats, onClose, onImport }: ImportBeatsProps) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => setSelected(new Set(beats.map((b) => b.id)));
const clearAll = () => setSelected(new Set());
return (
<div className="wf__modal-overlay" onClick={onClose}>
<div className="wf__modal wf__modal--wide" onClick={(e) => e.stopPropagation()}>
<div className="wf__modal-head">
<span className="wf__modal-type">Add beats to board</span>
<button className="wf__modal-close" onClick={onClose}>
×
</button>
</div>
<div className="wf__modal-body">
{beats.length === 0 && <p className="muted">All beats are already on the board.</p>}
<div className="wf__beat-list">
{beats.map((b) => (
<label key={b.id} className="wf__beat-item">
<input
type="checkbox"
checked={selected.has(b.id)}
onChange={() => toggle(b.id)}
/>
<span className="wf__beat-title">{b.title}</span>
<span className="ps-badge ps-badge-neutral">{b.type}</span>
</label>
))}
</div>
</div>
<div className="wf__modal-foot">
<div className="wf__beat-actions">
<button className="wf__link" onClick={selectAll}>
Select all
</button>
<button className="wf__link" onClick={clearAll}>
Clear
</button>
</div>
<Button variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button variant="accent" size="sm" onClick={() => onImport([...selected])} disabled={selected.size === 0}>
Add {selected.size} beat{selected.size === 1 ? "" : "s"}
</Button>
</div>
</div>
</div> </div>
); );
} }
@@ -0,0 +1,128 @@
/**
* Round-trip tests for the plot beat store actions.
*
* Verifies that savePlotBeat, deletePlotBeat, and reorderPlotBeats correctly
* persist through a mock adapter and update store state. Uses the real
* `useProjectStore` with a mock adapter injected via `setState`.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { createPlotBeat, createUlid, type PlotBeat, type ProjectMeta, type StorageAdapter } from "@pubscript/core";
import { useProjectStore } from "./project-store.js";
// A minimal in-memory adapter that records calls. Only the plot beat methods
// are implemented; the rest of the StorageAdapter contract is not exercised.
function createMockAdapter(): StorageAdapter & { calls: string[] } {
const calls: string[] = [];
const store: Record<string, PlotBeat> = {};
const adapter = {
calls,
async listPlotBeats(projectId: string) {
calls.push(`listPlotBeats:${projectId}`);
return Object.values(store);
},
async savePlotBeat(beat: PlotBeat) {
calls.push(`savePlotBeat:${beat.id}`);
store[beat.id] = beat;
},
async deletePlotBeat(projectId: string, id: string) {
calls.push(`deletePlotBeat:${projectId}:${id}`);
delete store[id];
},
async reorderPlotBeats(projectId: string, beats: PlotBeat[]) {
calls.push(`reorderPlotBeats:${projectId}`);
for (const b of beats) store[b.id] = b;
},
};
// The adapter contract is large; we only exercise the plot beat methods.
return { ...adapter, ...(adapter as unknown as StorageAdapter) } as StorageAdapter & {
calls: string[];
};
}
const PROJECT_ID = "proj-test-0001";
beforeEach(() => {
useProjectStore.setState({
adapter: null,
project: null,
plotBeats: [],
plotBeatsLoaded: false,
});
});
describe("plot beat store round-trip", () => {
it("loads beats from the adapter", async () => {
const adapter = createMockAdapter();
const beat = createPlotBeat(createUlid(), PROJECT_ID, "Test beat", 1, "scene");
await adapter.savePlotBeat(beat);
useProjectStore.setState({ adapter, project: { id: PROJECT_ID } as ProjectMeta });
await useProjectStore.getState().loadPlotBeats();
expect(useProjectStore.getState().plotBeats).toHaveLength(1);
expect(useProjectStore.getState().plotBeats[0]?.title).toBe("Test beat");
});
it("saves a beat and updates store state", async () => {
const adapter = createMockAdapter();
useProjectStore.setState({ adapter, project: { id: PROJECT_ID } as ProjectMeta });
const beat = createPlotBeat(createUlid(), PROJECT_ID, "New beat", 1, "scene");
await useProjectStore.getState().savePlotBeat(beat);
expect(useProjectStore.getState().plotBeats).toHaveLength(1);
expect(useProjectStore.getState().plotBeats[0]?.title).toBe("New beat");
expect(adapter.calls).toContain(`savePlotBeat:${beat.id}`);
});
it("updates an existing beat in place", async () => {
const adapter = createMockAdapter();
useProjectStore.setState({ adapter, project: { id: PROJECT_ID } as ProjectMeta });
const beat = createPlotBeat(createUlid(), PROJECT_ID, "Original", 1, "scene");
await useProjectStore.getState().savePlotBeat(beat);
const updated = { ...beat, title: "Updated" };
await useProjectStore.getState().savePlotBeat(updated);
expect(useProjectStore.getState().plotBeats).toHaveLength(1);
expect(useProjectStore.getState().plotBeats[0]?.title).toBe("Updated");
});
it("deletes a beat", async () => {
const adapter = createMockAdapter();
useProjectStore.setState({ adapter, project: { id: PROJECT_ID } as ProjectMeta });
const beat = createPlotBeat(createUlid(), PROJECT_ID, "To delete", 1, "scene");
await useProjectStore.getState().savePlotBeat(beat);
await useProjectStore.getState().deletePlotBeat(beat.id);
expect(useProjectStore.getState().plotBeats).toHaveLength(0);
expect(adapter.calls).toContain(`deletePlotBeat:proj-test-0001:${beat.id}`);
});
it("reorders beats and persists them", async () => {
const adapter = createMockAdapter();
useProjectStore.setState({ adapter, project: { id: PROJECT_ID } as ProjectMeta });
const b1 = createPlotBeat(createUlid(), PROJECT_ID, "Beat 1", 1, "scene", 0);
const b2 = createPlotBeat(createUlid(), PROJECT_ID, "Beat 2", 1, "scene", 1);
await useProjectStore.getState().savePlotBeat(b1);
await useProjectStore.getState().savePlotBeat(b2);
// Swap the order.
await useProjectStore.getState().reorderPlotBeats([b2, b1]);
expect(useProjectStore.getState().plotBeats.map((b) => b.id)).toEqual([b2.id, b1.id]);
// Reorder persists each beat's new order via savePlotBeat.
expect(adapter.calls.filter((c) => c.startsWith("savePlotBeat:")).length).toBeGreaterThanOrEqual(2);
});
it("throws when saving without an adapter", async () => {
useProjectStore.setState({ adapter: null, project: { id: PROJECT_ID } as ProjectMeta });
const beat = createPlotBeat(createUlid(), PROJECT_ID, "X", 1, "scene");
await expect(useProjectStore.getState().savePlotBeat(beat)).rejects.toThrow(
"No storage adapter.",
);
});
});
+153
View File
@@ -12,9 +12,13 @@
import { import {
createAutosave, createAutosave,
type AutosaveState, type AutosaveState,
type Board,
type Chapter, type Chapter,
type DocumentRef, type DocumentRef,
type Entity, type Entity,
type FlowNode,
type FlowEdge,
type PlotBeat,
type ProjectMeta, type ProjectMeta,
type StorageAdapter, type StorageAdapter,
} from "@pubscript/core"; } from "@pubscript/core";
@@ -37,6 +41,14 @@ interface ProjectStore {
entities: Entity[]; entities: Entity[];
entitiesLoaded: boolean; entitiesLoaded: boolean;
// --- plot beats (Chunk #5) ---
plotBeats: PlotBeat[];
plotBeatsLoaded: boolean;
// --- whiteboard (Chunk #5) ---
board: Board | null;
boardLoaded: boolean;
// --- actions --- // --- actions ---
/** Load the project (seed a demo if empty) and select the first chapter. */ /** Load the project (seed a demo if empty) and select the first chapter. */
init: () => Promise<void>; init: () => Promise<void>;
@@ -56,6 +68,32 @@ interface ProjectStore {
updateEntity: (entity: Entity) => Promise<void>; updateEntity: (entity: Entity) => Promise<void>;
/** Delete an entity by id. */ /** Delete an entity by id. */
deleteEntity: (id: string) => Promise<void>; deleteEntity: (id: string) => Promise<void>;
// --- whiteboard actions (Chunk #5) ---
/** Load the story whiteboard for the active project. */
loadBoard: () => Promise<void>;
/** Persist the full board (nodes + edges). */
saveBoard: (board: Board) => Promise<void>;
/** Add a node to the board. */
addBoardNode: (node: FlowNode) => Promise<void>;
/** Update a node on the board. */
updateBoardNode: (node: FlowNode) => Promise<void>;
/** Delete a node from the board. */
deleteBoardNode: (id: string) => Promise<void>;
/** Add an edge to the board. */
addBoardEdge: (edge: FlowEdge) => Promise<void>;
/** Delete an edge from the board. */
deleteBoardEdge: (id: string) => Promise<void>;
// --- plot beat actions (Chunk #5) ---
/** Load all plot beats for the active project. */
loadPlotBeats: () => Promise<void>;
/** Persist an updated plot beat. */
savePlotBeat: (beat: PlotBeat) => Promise<void>;
/** Delete a plot beat. */
deletePlotBeat: (id: string) => Promise<void>;
/** Reorder plot beats within acts. */
reorderPlotBeats: (beats: PlotBeat[]) => Promise<void>;
} }
/** Build a blank entity of the given kind for the active project. */ /** Build a blank entity of the given kind for the active project. */
@@ -96,6 +134,10 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" }, autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" },
entities: [], entities: [],
entitiesLoaded: false, entitiesLoaded: false,
plotBeats: [],
plotBeatsLoaded: false,
board: null,
boardLoaded: false,
init: async () => { init: async () => {
set({ status: "loading", error: null }); set({ status: "loading", error: null });
@@ -122,6 +164,12 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
// Load entities for the entity engine. // Load entities for the entity engine.
await get().loadEntities(); await get().loadEntities();
// Load plot beats for the plot board + timeline.
await get().loadPlotBeats();
// Load the story whiteboard.
await get().loadBoard();
} catch (e) { } catch (e) {
set({ error: e instanceof Error ? e.message : String(e), status: "ready" }); set({ error: e instanceof Error ? e.message : String(e), status: "ready" });
} }
@@ -184,5 +232,110 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
entities: state.entities.filter((e) => e.id !== id), entities: state.entities.filter((e) => e.id !== id),
})); }));
}, },
// --- plot beats (Chunk #5) ---
loadPlotBeats: async () => {
const { adapter, project } = get();
if (!adapter || !project) return;
const plotBeats = await adapter.listPlotBeats(project.id);
set({ plotBeats, plotBeatsLoaded: true });
},
savePlotBeat: async (beat: PlotBeat) => {
const { adapter } = get();
if (!adapter) throw new Error("No storage adapter.");
await adapter.savePlotBeat(beat);
set((state) => ({
plotBeats: [
...state.plotBeats.filter((b) => b.id !== beat.id),
beat,
],
}));
},
deletePlotBeat: async (id: string) => {
const { adapter, project } = get();
if (!adapter || !project) throw new Error("No active project.");
await adapter.deletePlotBeat(project.id, id);
set((state) => ({
plotBeats: state.plotBeats.filter((b) => b.id !== id),
}));
},
reorderPlotBeats: async (beats: PlotBeat[]) => {
const { adapter } = get();
if (!adapter) throw new Error("No storage adapter.");
const now = new Date().toISOString();
for (const beat of beats) {
const updated = { ...beat, order: beat.order, updatedAt: now };
await adapter.savePlotBeat(updated);
}
set({ plotBeats: beats });
},
// --- whiteboard (Chunk #5) ---
loadBoard: async () => {
const { adapter, project } = get();
if (!adapter || !project) return;
// Load the persisted story board for the project. Both adapters now
// support getBoard; fall back to a blank board when none exists.
const board = await adapter.getBoard(project.id);
set({
board: board ?? { id: "board-story", projectId: project.id, nodes: [], edges: [] },
boardLoaded: true,
});
},
saveBoard: async (board: Board) => {
const { adapter } = get();
if (!adapter) throw new Error("No storage adapter.");
await adapter.saveBoard(board);
set({ board });
},
addBoardNode: async (node: FlowNode) => {
const { board } = get();
if (!board) return;
const next: Board = { ...board, nodes: [...board.nodes, node] };
await get().saveBoard(next);
},
updateBoardNode: async (node: FlowNode) => {
const { board } = get();
if (!board) return;
const next: Board = {
...board,
nodes: board.nodes.map((n) => (n.id === node.id ? node : n)),
};
await get().saveBoard(next);
},
deleteBoardNode: async (id: string) => {
const { board } = get();
if (!board) return;
const next: Board = {
...board,
nodes: board.nodes.filter((n) => n.id !== id),
edges: board.edges.filter((e) => e.source !== id && e.target !== id),
};
await get().saveBoard(next);
},
addBoardEdge: async (edge: FlowEdge) => {
const { board } = get();
if (!board) return;
const next: Board = { ...board, edges: [...board.edges, edge] };
await get().saveBoard(next);
},
deleteBoardEdge: async (id: string) => {
const { board } = get();
if (!board) return;
const next: Board = {
...board,
edges: board.edges.filter((e) => e.id !== id),
};
await get().saveBoard(next);
},
}; };
}); });
+36
View File
@@ -8,9 +8,11 @@
*/ */
import { import {
createUlid, createUlid,
createPlotBeat,
type Board, type Board,
type Character, type Character,
type Note, type Note,
type PlotBeat,
type ProseMirrorDoc, type ProseMirrorDoc,
type WorldItem, type WorldItem,
type StorageAdapter, type StorageAdapter,
@@ -229,6 +231,36 @@ const BOARD: Board = {
], ],
}; };
/**
* Plot beats for the demo project, spread across three acts. These seed the
* Plot board and the derived Timeline. See CHUNK #5 step 1.
*/
const PLOT_BEATS: PlotBeat[] = [
// Act I — Setup
createPlotBeat("beat-1", "", "Mara leaves the mainland", 1, "setup", 0,
"Mara Vell arrives at the Glass Harbor by ferry before dawn. The light has turned the sea the color of a bruised plum."),
createPlotBeat("beat-2", "", "The light is failing", 1, "conflict", 1,
"Three nights without the lighthouse. The fish are turning away; Tomas Reed carries the grief of four ships lost."),
createPlotBeat("beat-3", "", "Old Fen warns her", 1, "note", 2,
"The hermit who has seen four ships lost grins at the wrong moments and speaks in warnings."),
// Act II — Confrontation
createPlotBeat("beat-4", "", "The storm arrives", 2, "conflict", 0,
"The storm comes without warning. Mara climbs the one hundred and fourteen steps to the seized lamp."),
createPlotBeat("beat-5", "", "Mara frees the beam", 2, "turn", 1,
"Working through the small hours, she coax the gears until the great beam throws its light across the black water."),
createPlotBeat("beat-6", "", "The secret of the glass", 2, "turn", 2,
"Old Fen reveals the light is not made but mined — shards of living glass drawn from the deep, kindled only by a keeper."),
createPlotBeat("beat-7", "", "The shard answers", 2, "climax", 3,
"Mara finds a warm shard of glass in the wrack that holds a light not her own — the glass answers when she speaks."),
// Act III — Resolution
createPlotBeat("beat-8", "", "Elias's inheritance", 3, "setup", 0,
"She understands why the old keeper never waved or left: some duties are inherited, like the tide. Her father drowned keeping the light."),
createPlotBeat("beat-9", "", "Tomas offers to teach", 3, "turn", 1,
"Tomas Reed finds Mara at the lamp and offers to teach her. No one has ever wanted the light before."),
createPlotBeat("beat-10", "", "Mara chooses the light", 3, "resolution", 2,
"Mara looks out at the quay and the little lights of the town, and says 'I want it.' The light answers for her."),
];
/** /**
* Seed the adapter with the demo project if it has no projects yet. * Seed the adapter with the demo project if it has no projects yet.
* Returns the created project id. * Returns the created project id.
@@ -260,5 +292,9 @@ export async function seedDemoProject(adapter: StorageAdapter): Promise<string>
await adapter.saveEntity({ ...ARRIVAL_NOTE, projectId: project.id }); await adapter.saveEntity({ ...ARRIVAL_NOTE, projectId: project.id });
await adapter.saveBoard({ ...BOARD, projectId: project.id }); await adapter.saveBoard({ ...BOARD, projectId: project.id });
for (const beat of PLOT_BEATS) {
await adapter.savePlotBeat({ ...beat, projectId: project.id });
}
return project.id; return project.id;
} }
+393
View File
@@ -0,0 +1,393 @@
/**
* Plot board styles (apps/web).
*
* Styles for the Chunk #5 plot board: the three-act beat columns, beat cards
* with drag-to-reorder, the create-template picker, and the edit modal form.
* Reuses the `@pubscript/ui` design tokens (`--ps-*`) and component classes
* (`ps-btn`, `ps-badge`, `ps-modal-*`, `ps-input`).
*/
/* ---- Global utility classes (shared across Chunk #5 routes) ---- */
.heading {
margin: 0;
font-size: 28px;
font-weight: 700;
color: var(--ps-text);
letter-spacing: -0.02em;
}
.muted {
margin: 0;
color: var(--ps-text-muted);
font-size: 14px;
}
/* ---- Modal form fields ---- */
.modal__body {
display: flex;
flex-direction: column;
gap: 4px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 14px;
}
.field > span:first-child {
font-size: 13px;
font-weight: 600;
color: var(--ps-text-muted);
}
.field__select,
.field__textarea {
width: 100%;
padding: 8px 10px;
background: var(--ps-surface-2);
border: 1px solid var(--ps-border);
border-radius: var(--ps-radius);
color: var(--ps-text);
font-family: inherit;
font-size: 14px;
}
.field__select:focus,
.field__textarea:focus {
outline: none;
border-color: var(--ps-accent);
}
.field__textarea {
resize: vertical;
min-height: 90px;
font-family: inherit;
}
/* ---- Plot board ---- */
.plot {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.plot__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.plot__board {
flex: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
overflow-x: auto;
padding-bottom: 8px;
}
.plot__column {
background: var(--ps-surface);
border: 1px solid var(--ps-border);
border-radius: var(--ps-radius);
padding: 14px;
display: flex;
flex-direction: column;
min-height: 200px;
transition: border-color 0.15s ease, background 0.15s ease;
}
.plot__column--drop {
border-color: var(--ps-accent);
background: color-mix(in srgb, var(--ps-accent) 6%, var(--ps-surface));
}
.plot__column-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.plot__column-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--ps-text);
display: inline-flex;
align-items: center;
gap: 8px;
}
.plot__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 11px;
background: var(--ps-surface-2);
color: var(--ps-text-muted);
font-size: 12px;
font-weight: 600;
}
.plot__column-sub {
margin: 4px 0 0;
font-size: 13px;
}
/* ---- Progress bar ---- */
.plot__progress {
position: relative;
margin-top: 12px;
height: 8px;
border-radius: 4px;
background: var(--ps-surface-2);
overflow: hidden;
}
.plot__progress-bar {
height: 100%;
border-radius: 4px;
background: var(--ps-success);
transition: width 0.3s ease;
}
.plot__progress-label {
display: block;
margin-top: 6px;
font-size: 12px;
color: var(--ps-text-muted);
}
.plot__cards {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
min-height: 40px;
}
.plot__empty {
margin: 0;
padding: 16px;
text-align: center;
color: var(--ps-text-muted);
font-size: 13px;
border: 1px dashed var(--ps-border);
border-radius: var(--ps-radius);
}
.plot__card {
background: var(--ps-surface-2);
border: 1px solid var(--ps-border);
border-radius: var(--ps-radius);
padding: 12px;
cursor: pointer;
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.plot__card:hover {
border-color: var(--ps-accent);
}
.plot__card--dragging {
opacity: 0.5;
transform: rotate(2deg);
}
.plot__card--locked {
opacity: 0.75;
background: color-mix(in srgb, var(--ps-text-muted) 6%, var(--ps-surface-2));
}
.plot__card--locked:hover {
border-color: var(--ps-border);
transform: none;
}
.plot__card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.plot__card-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--ps-text);
line-height: 1.3;
}
.plot__card-body {
margin: 0;
font-size: 13px;
color: var(--ps-text-muted);
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.plot__delete {
background: none;
border: none;
color: var(--ps-text-muted);
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.plot__card:hover .plot__delete {
opacity: 1;
}
.plot__delete:hover {
color: var(--ps-danger);
background: color-mix(in srgb, var(--ps-danger) 12%, transparent);
}
.plot__card-links {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
/* ---- Entity picker in edit modal ---- */
.plot__entity-picker {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 180px;
overflow-y: auto;
}
.plot__entity-option {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ps-text);
padding: 4px 6px;
border-radius: var(--ps-radius);
transition: background 0.12s ease;
}
.plot__entity-option:hover {
background: color-mix(in srgb, var(--ps-accent) 6%, transparent);
}
.plot__entity-option input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--ps-accent);
}
/* ---- Template picker ---- */
.plot__templates {
display: flex;
flex-direction: column;
gap: 10px;
}
.plot__templates-hint {
margin-bottom: 4px;
}
.plot__templates-divider {
position: relative;
text-align: center;
margin: 8px 0;
font-size: 13px;
color: var(--ps-text-muted);
}
.plot__templates-divider::before,
.plot__templates-divider::after {
content: "";
position: absolute;
top: 50%;
width: 40%;
height: 1px;
background: var(--ps-border);
}
.plot__templates-divider::before {
left: 0;
}
.plot__templates-divider::after {
right: 0;
}
.plot__template {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--ps-surface-2);
border: 1px solid var(--ps-border);
border-radius: var(--ps-radius);
color: var(--ps-text);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
text-align: left;
}
.plot__template:hover {
border-color: var(--ps-accent);
background: color-mix(in srgb, var(--ps-accent) 8%, var(--ps-surface-2));
}
.plot__template-name {
font-size: 14px;
font-weight: 600;
color: var(--ps-text);
}
.plot__template-desc {
font-size: 12px;
font-weight: 400;
}
.plot__template-count {
margin-left: auto;
font-size: 12px;
color: var(--ps-text-muted);
white-space: nowrap;
}
.plot__template-types {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
}
.plot__template--type {
justify-content: center;
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Timeline styles (apps/web).
*
* Styles for the Chunk #5 derived timeline: a vertical, act-grouped reading
* order of plot beats with a connector spine, act headers, and beat cards.
* Reuses the `@pubscript/ui` design tokens (`--ps-*`) and shared utility
* classes (`heading`, `muted`).
*/
.timeline {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.timeline__header {
margin-bottom: 20px;
}
.timeline__filters {
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 20px;
padding: 14px 16px;
background: var(--ps-surface);
border: 1px solid var(--ps-border);
border-radius: var(--ps-radius);
}
.timeline__filter-group {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 180px;
}
.timeline__filter-label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
font-weight: 600;
color: var(--ps-text-muted);
}
.timeline__filter-label > .ps-input,
.timeline__filter-label > select {
min-width: 200px;
}
.timeline__act-label-left {
display: flex;
align-items: center;
gap: 10px;
}
.timeline__flow {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 4px 24px;
}
.timeline__empty {
margin: 0;
padding: 40px;
text-align: center;
color: var(--ps-text-muted);
font-size: 15px;
border: 1px dashed var(--ps-border);
border-radius: var(--ps-radius);
}
.timeline__act {
margin-bottom: 28px;
}
.timeline__act-title {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 16px;
}
.timeline__act-label {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--ps-text);
}
.timeline__act-sub {
margin: 0;
font-size: 13px;
color: var(--ps-text-muted);
}
.timeline__act-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 8px;
border-radius: 12px;
background: var(--ps-accent);
color: var(--ps-accent-contrast);
font-size: 12px;
font-weight: 700;
}
.timeline__track {
position: relative;
padding-left: 28px;
}
.timeline__connector {
position: absolute;
left: 7px;
top: 6px;
bottom: 6px;
width: 2px;
background: var(--ps-border);
}
.timeline__item {
position: relative;
margin-bottom: 14px;
}
.timeline__dot {
position: absolute;
left: -28px;
top: 14px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ps-surface);
border: 3px solid var(--ps-accent);
}
.timeline__dot-inner {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
background: var(--ps-accent);
}
.timeline__card {
background: var(--ps-surface);
border: 1px solid var(--ps-border);
border-radius: var(--ps-radius);
padding: 12px 14px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.timeline__card:hover {
border-color: var(--ps-accent);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
}
.timeline__card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.timeline__card-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--ps-text);
}
.timeline__order {
font-size: 12px;
color: var(--ps-text-muted);
}
.timeline__card-body {
margin: 0;
font-size: 13px;
color: var(--ps-text-muted);
line-height: 1.45;
}
.timeline__act-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 8px;
border-radius: 12px;
background: var(--ps-accent);
color: var(--ps-accent-contrast);
font-size: 12px;
font-weight: 700;
}
+328
View File
@@ -0,0 +1,328 @@
/**
* Whiteboard styles (apps/web).
*
* Styles for the Chunk #5 story whiteboard built on React Flow v11: the canvas
* wrapper, the add-node menu, the custom node views, and the edit-node modal.
* React Flow's own base/style CSS is imported in Whiteboard.tsx; these rules
* layer the PubScript look on top (design tokens `--ps-*`).
*/
.wf {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.wf__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.wf__actions {
display: flex;
gap: 8px;
}
.wf__add {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.wf__add-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--ps-surface-2);
border: 1px solid var(--ps-border);
border-radius: var(--ps-radius);
color: var(--ps-text);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.wf__add-item:hover {
border-color: var(--ps-accent);
background: color-mix(in srgb, var(--ps-accent) 8%, var(--ps-surface-2));
}
.wf__add-icon {
font-size: 16px;
}
.wf__canvas {
flex: 1;
min-height: 0;
background: var(--ps-bg);
border: 1px solid var(--ps-border);
border-radius: var(--ps-radius);
overflow: hidden;
}
/* React Flow canvas fill */
.wf__canvas .reactflow {
width: 100%;
height: 100%;
}
.wf__edge-legend {
position: absolute;
bottom: 12px;
left: 12px;
z-index: 5;
display: flex;
gap: 12px;
padding: 6px 10px;
background: var(--ps-surface);
border: 1px solid var(--ps-border);
border-radius: var(--ps-radius);
color: var(--ps-text-muted);
font-size: 12px;
pointer-events: none;
}
/* ---- Custom node views ---- */
.wf-node {
min-width: 140px;
max-width: 220px;
padding: 12px;
background: var(--ps-surface);
border: 1px solid var(--ps-border);
border-radius: var(--ps-radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
cursor: grab;
}
.wf-node:active {
cursor: grabbing;
}
.wf-node--selected {
border-color: var(--ps-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ps-accent) 40%, transparent);
}
.wf-node__type {
display: inline-block;
margin-bottom: 6px;
padding: 2px 8px;
background: var(--ps-surface-2);
border-radius: 10px;
color: var(--ps-text-muted);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.wf-node__title {
font-size: 15px;
font-weight: 600;
color: var(--ps-text);
line-height: 1.3;
}
.wf-node__body {
margin-top: 6px;
font-size: 12px;
color: var(--ps-text-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Cross-linked nodes */
.wf-node--linked {
border-color: var(--ps-accent);
}
.wf-node__badge {
display: inline-block;
margin-top: 8px;
padding: 2px 8px;
background: color-mix(in srgb, var(--ps-accent) 18%, transparent);
border: 1px solid var(--ps-accent);
border-radius: 10px;
color: var(--ps-accent);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ---- Edit node modal ---- */
.wf__field {
display: flex;
flex-direction: column;
gap: 4px;
}
.wf__field > span {
font-size: 12px;
font-weight: 600;
color: var(--ps-text-muted);
}
.wf__select {
appearance: none;
cursor: pointer;
}
.wf__badge-row {
margin-top: 2px;
}
.wf__modal--wide {
width: min(560px, 94vw);
}
.wf__beat-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 320px;
overflow-y: auto;
}
.wf__beat-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: var(--ps-surface-2);
border: 1px solid var(--ps-border);
border-radius: var(--ps-radius);
cursor: pointer;
}
.wf__beat-item input {
flex-shrink: 0;
}
.wf__beat-title {
flex: 1;
font-size: 14px;
color: var(--ps-text);
}
.wf__beat-actions {
display: flex;
gap: 12px;
margin-right: auto;
}
.wf__link {
background: none;
border: none;
color: var(--ps-accent);
font-size: 13px;
cursor: pointer;
padding: 0;
}
.wf__link:hover {
text-decoration: underline;
}
/* ---- Edit node modal ---- */
.wf__modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
.wf__modal {
width: min(440px, 92vw);
background: var(--ps-surface);
border: 1px solid var(--ps-border);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.wf__modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--ps-border);
}
.wf__modal-type {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ps-accent);
}
.wf__modal-close {
background: none;
border: none;
color: var(--ps-text-muted);
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
}
.wf__modal-close:hover {
color: var(--ps-text);
background: var(--ps-surface-2);
}
.wf__modal-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.wf__input,
.wf__textarea {
width: 100%;
padding: 8px 10px;
background: var(--ps-surface-2);
border: 1px solid var(--ps-border);
border-radius: var(--ps-radius);
color: var(--ps-text);
font-family: inherit;
font-size: 14px;
}
.wf__input:focus,
.wf__textarea:focus {
outline: none;
border-color: var(--ps-accent);
}
.wf__textarea {
resize: vertical;
min-height: 90px;
font-family: inherit;
}
.wf__modal-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 0 16px 16px;
}
+8
View File
@@ -40,6 +40,14 @@ function mockAdapter(): StorageAdapter & { saved: DocumentRef[] } {
return []; return [];
}, },
async saveBoard() {}, async saveBoard() {},
async getBoard() {
return null;
},
async savePlotBeat() {},
async listPlotBeats() {
return [];
},
async deletePlotBeat() {},
async saveAISession() {}, async saveAISession() {},
async saveDocument(doc: DocumentRef) { async saveDocument(doc: DocumentRef) {
saved.push(doc); saved.push(doc);
+76
View File
@@ -4,6 +4,8 @@
* to a kind here makes it appear in the UI with no per-entity code. * to a kind here makes it appear in the UI with no per-entity code.
*/ */
import type { PlotBeat, PlotBeatType as PlotBeatTypeRef } from "./types.js";
export type FieldType = "text" | "textarea" | "select" | "list" | "link"; export type FieldType = "text" | "textarea" | "select" | "list" | "link";
export interface FieldDef { export interface FieldDef {
@@ -99,3 +101,77 @@ export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
/** All entity kinds, in display order. */ /** All entity kinds, in display order. */
export const ENTITY_KINDS = Object.keys(ENTITY_FIELDS); export const ENTITY_KINDS = Object.keys(ENTITY_FIELDS);
/**
* Plot beat types with their palette labels, timeline colours and a short
* description shown on the beat board. See CHUNK #5 step 1.
*/
export const PLOT_BEAT_TYPES = {
setup: { label: "Setup", color: "#0f766e", description: "The world, the status quo." },
conflict: { label: "Conflict", color: "#b45309", description: "What gets in the way." },
turn: { label: "Turn", color: "#7c3aed", description: "A change of direction." },
climax: { label: "Climax", color: "#be123c", description: "The turning point of the story." },
resolution: { label: "Resolution", color: "#15803d", description: "The new normal." },
scene: { label: "Scene", color: "#0891b2", description: "A discrete scene beat." },
note: { label: "Note", color: "#475569", description: "A freeform note." },
} as const;
// `PlotBeatType` is defined once in types.ts; re-exported here for convenience
// so callers can import it from the schema module.
type PlotBeatType = PlotBeatTypeRef;
/**
* Create a plot beat with sensible defaults and timestamps.
*
* @param id Stable id (ulid).
* @param projectId Owning project.
* @param title Beat title.
* @param act Act number (1-based).
* @param type Structural type (defaults to `note`).
* @param order Order within the act (0-based).
* @param body Optional body.
* @param chapterId Optional linked chapter id.
*/
export function createPlotBeat(
id: string,
projectId: string,
title: string,
act: number,
type: PlotBeatType = "note",
order = 0,
body = "",
chapterId?: string,
status: "draft" | "revised" | "locked" = "draft",
): PlotBeat {
const now = new Date().toISOString();
return {
id,
projectId,
title,
body,
act,
type,
order,
status,
...(chapterId ? { chapterId } : {}),
createdAt: now,
updatedAt: now,
};
}
/**
* Group beats by act and return an ordered map: act number → beats.
* Beats are sorted by `order` within each act.
*/
export function beatsByAct(beats: PlotBeat[]): Map<number, PlotBeat[]> {
const map = new Map<number, PlotBeat[]>();
for (const beat of beats) {
const list = map.get(beat.act) ?? [];
list.push(beat);
map.set(beat.act, list);
}
for (const list of map.values()) {
list.sort((a, b) => a.order - b.order);
}
return map;
}
+33
View File
@@ -7,6 +7,7 @@ import type {
DocumentRef, DocumentRef,
Entity, Entity,
NewProject, NewProject,
PlotBeat,
Project, Project,
ProjectMeta, ProjectMeta,
} from "./types.js"; } from "./types.js";
@@ -28,6 +29,7 @@ class PubScriptDB extends Dexie {
chapters!: Table<Chapter, string>; chapters!: Table<Chapter, string>;
entities!: Table<EntityRec, string>; entities!: Table<EntityRec, string>;
boards!: Table<Board, string>; boards!: Table<Board, string>;
plotBeats!: Table<PlotBeat, string>;
aiSessions!: Table<AISession, string>; aiSessions!: Table<AISession, string>;
constructor() { constructor() {
@@ -38,6 +40,8 @@ class PubScriptDB extends Dexie {
chapters: "id, bookId, projectId", chapters: "id, bookId, projectId",
entities: "key, projectId, kind", entities: "key, projectId, kind",
boards: "id, projectId", boards: "id, projectId",
// Plot beats — atomic units of story structure (CHUNK #5).
plotBeats: "id, [projectId+act], projectId, chapterId",
aiSessions: "id, projectId", aiSessions: "id, projectId",
}); });
} }
@@ -194,6 +198,35 @@ export const storageLocal: StorageAdapter = {
await db.boards.put(b); await db.boards.put(b);
}, },
async getBoard(projectId: string): Promise<Board | null> {
const rows = await db.boards
.where("projectId")
.equals(projectId)
.toArray();
return rows[0] ?? null;
},
async listPlotBeats(projectId: string): Promise<PlotBeat[]> {
const rows = await db.plotBeats
.where("projectId")
.equals(projectId)
.toArray();
// Stable order: by act, then order within act.
return rows.sort((a, b) => a.act - b.act || a.order - b.order);
},
async savePlotBeat(b: PlotBeat): Promise<void> {
await db.plotBeats.put(b);
},
async deletePlotBeat(projectId: string, id: string): Promise<void> {
const existing = await db.plotBeats.get(id);
if (!existing || existing.projectId !== projectId) {
throw new Error(`No plot beat ${id} in project ${projectId}.`);
}
await db.plotBeats.delete(id);
},
async saveAISession(s: AISession): Promise<void> { async saveAISession(s: AISession): Promise<void> {
await db.aiSessions.put(s); await db.aiSessions.put(s);
}, },
+36
View File
@@ -8,6 +8,7 @@ import type {
NewProject, NewProject,
Project, Project,
ProjectMeta, ProjectMeta,
PlotBeat,
} from "./types.js"; } from "./types.js";
import type { StorageAdapter, StoreEvent } from "./storage.js"; import type { StorageAdapter, StoreEvent } from "./storage.js";
@@ -161,6 +162,41 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
}); });
}, },
async getBoard(projectId: string): Promise<Board | null> {
const res = await req<{ boards: Board[] }>(
"GET",
`/api/projects/${projectId}/boards`,
);
return res.boards[0] ?? null;
},
async listPlotBeats(projectId: string): Promise<PlotBeat[]> {
const res = await req<{ beats: PlotBeat[] }>(
"GET",
`/api/projects/${projectId}/beats`,
);
return res.beats;
},
async savePlotBeat(b: PlotBeat): Promise<void> {
await req("POST", `/api/projects/${b.projectId}/beats`, {
id: b.id,
projectId: b.projectId,
title: b.title,
body: b.body,
act: b.act,
type: b.type,
order: b.order,
status: b.status,
chapterId: b.chapterId,
entityIds: b.entityIds,
});
},
async deletePlotBeat(projectId: string, id: string): Promise<void> {
await req("DELETE", `/api/projects/${projectId}/beats/${id}`);
},
async saveAISession(s: AISession): Promise<void> { async saveAISession(s: AISession): Promise<void> {
await req("POST", `/api/projects/${s.projectId}/ai/sessions`, { await req("POST", `/api/projects/${s.projectId}/ai/sessions`, {
id: s.id, id: s.id,
+9
View File
@@ -13,6 +13,7 @@ import type {
NewProject, NewProject,
Project, Project,
ProjectMeta, ProjectMeta,
PlotBeat,
AISession, AISession,
} from "./types.js"; } from "./types.js";
@@ -53,6 +54,14 @@ export interface StorageAdapter {
*/ */
searchEntities(projectId: string, q: string): Promise<Entity[]>; searchEntities(projectId: string, q: string): Promise<Entity[]>;
saveBoard(b: Board): Promise<void>; saveBoard(b: Board): Promise<void>;
/** Load the story board for a project (by id `b.id`, scoped to `projectId`). */
getBoard(projectId: string): Promise<Board | null>;
/** Plot beats — the atomic units of story structure (CHUNK #5). */
listPlotBeats(projectId: string): Promise<PlotBeat[]>;
savePlotBeat(b: PlotBeat): Promise<void>;
deletePlotBeat(projectId: string, id: string): Promise<void>;
saveAISession(s: AISession): Promise<void>; saveAISession(s: AISession): Promise<void>;
// live updates // live updates
+41 -12
View File
@@ -156,18 +156,6 @@ export type BeatTemplate =
export type BeatStatus = "draft" | "revised" | "locked"; 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 { export interface TimelineEvent {
id: string; id: string;
projectId: string; projectId: string;
@@ -259,5 +247,46 @@ export interface DocumentRef {
rev: number; rev: number;
} }
/**
* A plot beat — the atomic unit of story structure. Beats are ordered within
* an act and carry a `type` that drives the timeline's colour coding. They are
* the source of truth for the timeline; the timeline is a derived view.
* See CHUNK #5 step 1.
*/
export type PlotBeatType =
| "setup"
| "conflict"
| "turn"
| "climax"
| "resolution"
| "scene"
| "note";
export interface PlotBeat {
id: string;
projectId: string;
title: string;
body: string;
/** Act number (1, 2, 3, …). Beats are grouped and ordered by act. */
act: number;
/** Structural role; drives timeline colour and the template palette. */
type: PlotBeatType;
/** Order within the act (0-based). Lower comes first. */
order: number;
/** Progress status. Beats move draft → revised → locked. Locked beats are
* read-only until explicitly unlocked. */
status: PlotBeatStatus;
/** Optional link to the chapter this beat belongs to. */
chapterId?: string | undefined;
/** Optional links to entities (characters, locations, …) referenced in this
* beat. Stored as entity IDs. */
entityIds?: string[] | undefined;
createdAt: string;
updatedAt: string;
}
/** The progress status of a plot beat. */
export type PlotBeatStatus = "draft" | "revised" | "locked";
/** A generic entity — the discriminated union handled by the entity engine. */ /** A generic entity — the discriminated union handled by the entity engine. */
export type Entity = Character | WorldItem | Note; export type Entity = Character | WorldItem | Note;
+2
View File
@@ -298,6 +298,8 @@ body {
.ps-btn-lg { padding: 10px 20px; font-size: 15px; } .ps-btn-lg { padding: 10px 20px; font-size: 15px; }
.ps-btn-primary { background: var(--ps-accent); color: var(--ps-accent-contrast); } .ps-btn-primary { background: var(--ps-accent); color: var(--ps-accent-contrast); }
.ps-btn-primary:hover:not(:disabled) { filter: brightness(1.08); } .ps-btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
.ps-btn-accent { background: var(--ps-accent); color: var(--ps-accent-contrast); border-color: var(--ps-accent); }
.ps-btn-accent: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 { 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-secondary:hover:not(:disabled) { background: var(--ps-border); }
.ps-btn-ghost { background: transparent; color: var(--ps-text); } .ps-btn-ghost { background: transparent; color: var(--ps-text); }
+3 -2
View File
@@ -3,8 +3,9 @@ export type BadgeVariant = "neutral" | "accent" | "success" | "danger" | "warnin
export interface BadgeProps { export interface BadgeProps {
children: string; children: string;
variant?: BadgeVariant; variant?: BadgeVariant;
title?: string;
} }
export function Badge({ children, variant = "neutral" }: BadgeProps) { export function Badge({ children, variant = "neutral", title }: BadgeProps) {
return <span className={`ps-badge ps-badge-${variant}`}>{children}</span>; return <span className={`ps-badge ps-badge-${variant}`} title={title}>{children}</span>;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import { type ButtonHTMLAttributes } from "react"; import { type ButtonHTMLAttributes } from "react";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"; export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
export type ButtonSize = "sm" | "md" | "lg"; export type ButtonSize = "sm" | "md" | "lg";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
+453
View File
@@ -50,6 +50,9 @@ importers:
react-router-dom: react-router-dom:
specifier: ^7.1.1 specifier: ^7.1.1
version: 7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1) version: 7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
reactflow:
specifier: ^11
version: 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
zustand: zustand:
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.16(@types/react@19.3.0)(react@19.3.1)(use-sync-external-store@1.7.0(react@19.3.1)) version: 5.0.16(@types/react@19.3.0)(react@19.3.1)(use-sync-external-store@1.7.0(react@19.3.1))
@@ -930,6 +933,42 @@ packages:
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@reactflow/background@11.3.14':
resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/controls@11.2.14':
resolution: {integrity: sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/core@11.11.4':
resolution: {integrity: sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/minimap@11.7.14':
resolution: {integrity: sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/node-resizer@2.2.14':
resolution: {integrity: sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/node-toolbar@1.3.14':
resolution: {integrity: sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@rolldown/pluginutils@1.0.0-beta.27': '@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -1228,9 +1267,105 @@ packages:
'@types/better-sqlite3@7.6.13': '@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-axis@3.0.6':
resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==}
'@types/d3-brush@3.0.6':
resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==}
'@types/d3-chord@3.0.6':
resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-contour@3.0.6':
resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==}
'@types/d3-delaunay@6.0.4':
resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==}
'@types/d3-dispatch@3.0.7':
resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/d3-dsv@3.0.7':
resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-fetch@3.0.7':
resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==}
'@types/d3-force@3.0.10':
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
'@types/d3-format@3.0.4':
resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
'@types/d3-geo@3.1.1':
resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==}
'@types/d3-hierarchy@3.1.7':
resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-polygon@3.0.2':
resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==}
'@types/d3-quadtree@3.0.6':
resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==}
'@types/d3-random@3.0.4':
resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==}
'@types/d3-scale-chromatic@3.1.0':
resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-selection@3.0.12':
resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==}
'@types/d3-shape@3.2.0':
resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==}
'@types/d3-time-format@4.0.3':
resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/d3-transition@3.0.9':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
'@types/d3-zoom@3.0.9':
resolution: {integrity: sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==}
'@types/d3@7.4.3':
resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -1470,6 +1605,9 @@ packages:
chownr@1.1.4: chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
classcat@5.0.5:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -1510,6 +1648,44 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
data-urls@5.0.0: data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2220,6 +2396,12 @@ packages:
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
reactflow@11.11.4:
resolution: {integrity: sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
readable-stream@3.6.2: readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -2646,6 +2828,21 @@ packages:
zod@3.25.76: zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
zustand@5.0.16: zustand@5.0.16:
resolution: {integrity: sha512-Uv7e327ihlCfayfja3hDc0M64Upev1LWBKpRobdK4NyovDDPeeX4imWXc/wzC1yc6fkYPoAY/ncKVPAs2jrtQw==} resolution: {integrity: sha512-Uv7e327ihlCfayfja3hDc0M64Upev1LWBKpRobdK4NyovDDPeeX4imWXc/wzC1yc6fkYPoAY/ncKVPAs2jrtQw==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
@@ -3200,6 +3397,84 @@ snapshots:
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
'@reactflow/background@11.3.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
classcat: 5.0.5
react: 19.3.1
react-dom: 19.3.1(react@19.3.1)
zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/controls@11.2.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
classcat: 5.0.5
react: 19.3.1
react-dom: 19.3.1(react@19.3.1)
zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/core@11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
dependencies:
'@types/d3': 7.4.3
'@types/d3-drag': 3.0.7
'@types/d3-selection': 3.0.12
'@types/d3-zoom': 3.0.9
classcat: 5.0.5
d3-drag: 3.0.0
d3-selection: 3.0.0
d3-zoom: 3.0.0
react: 19.3.1
react-dom: 19.3.1(react@19.3.1)
zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/minimap@11.7.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
'@types/d3-selection': 3.0.12
'@types/d3-zoom': 3.0.9
classcat: 5.0.5
d3-selection: 3.0.0
d3-zoom: 3.0.0
react: 19.3.1
react-dom: 19.3.1(react@19.3.1)
zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/node-resizer@2.2.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
classcat: 5.0.5
d3-drag: 3.0.0
d3-selection: 3.0.0
react: 19.3.1
react-dom: 19.3.1(react@19.3.1)
zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/node-toolbar@1.3.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
classcat: 5.0.5
react: 19.3.1
react-dom: 19.3.1(react@19.3.1)
zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
'@rolldown/pluginutils@1.0.0-beta.27': {} '@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.65.0': '@rollup/rollup-android-arm-eabi@4.65.0':
@@ -3479,8 +3754,127 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.20.5 '@types/node': 22.20.5
'@types/d3-array@3.2.2': {}
'@types/d3-axis@3.0.6':
dependencies:
'@types/d3-selection': 3.0.12
'@types/d3-brush@3.0.6':
dependencies:
'@types/d3-selection': 3.0.12
'@types/d3-chord@3.0.6': {}
'@types/d3-color@3.1.3': {}
'@types/d3-contour@3.0.6':
dependencies:
'@types/d3-array': 3.2.2
'@types/geojson': 7946.0.16
'@types/d3-delaunay@6.0.4': {}
'@types/d3-dispatch@3.0.7': {}
'@types/d3-drag@3.0.7':
dependencies:
'@types/d3-selection': 3.0.12
'@types/d3-dsv@3.0.7': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-fetch@3.0.7':
dependencies:
'@types/d3-dsv': 3.0.7
'@types/d3-force@3.0.10': {}
'@types/d3-format@3.0.4': {}
'@types/d3-geo@3.1.1':
dependencies:
'@types/geojson': 7946.0.16
'@types/d3-hierarchy@3.1.7': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-polygon@3.0.2': {}
'@types/d3-quadtree@3.0.6': {}
'@types/d3-random@3.0.4': {}
'@types/d3-scale-chromatic@3.1.0': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-selection@3.0.12': {}
'@types/d3-shape@3.2.0':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time-format@4.0.3': {}
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/d3-transition@3.0.9':
dependencies:
'@types/d3-selection': 3.0.12
'@types/d3-zoom@3.0.9':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.12
'@types/d3@7.4.3':
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-axis': 3.0.6
'@types/d3-brush': 3.0.6
'@types/d3-chord': 3.0.6
'@types/d3-color': 3.1.3
'@types/d3-contour': 3.0.6
'@types/d3-delaunay': 6.0.4
'@types/d3-dispatch': 3.0.7
'@types/d3-drag': 3.0.7
'@types/d3-dsv': 3.0.7
'@types/d3-ease': 3.0.2
'@types/d3-fetch': 3.0.7
'@types/d3-force': 3.0.10
'@types/d3-format': 3.0.4
'@types/d3-geo': 3.1.1
'@types/d3-hierarchy': 3.1.7
'@types/d3-interpolate': 3.0.4
'@types/d3-path': 3.1.1
'@types/d3-polygon': 3.0.2
'@types/d3-quadtree': 3.0.6
'@types/d3-random': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-scale-chromatic': 3.1.0
'@types/d3-selection': 3.0.12
'@types/d3-shape': 3.2.0
'@types/d3-time': 3.0.4
'@types/d3-time-format': 4.0.3
'@types/d3-timer': 3.0.2
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.9
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/geojson@7946.0.16': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/node@22.20.5': '@types/node@22.20.5':
@@ -3768,6 +4162,8 @@ snapshots:
chownr@1.1.4: {} chownr@1.1.4: {}
classcat@5.0.5: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -3803,6 +4199,42 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-selection@3.0.0: {}
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
data-urls@5.0.0: data-urls@5.0.0:
dependencies: dependencies:
whatwg-mimetype: 4.0.0 whatwg-mimetype: 4.0.0
@@ -4607,6 +5039,20 @@ snapshots:
react@19.3.1: {} react@19.3.1: {}
reactflow@11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
dependencies:
'@reactflow/background': 11.3.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
'@reactflow/controls': 11.2.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
'@reactflow/core': 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
'@reactflow/minimap': 11.7.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
'@reactflow/node-resizer': 2.2.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
'@reactflow/node-toolbar': 1.3.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
react: 19.3.1
react-dom: 19.3.1(react@19.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
readable-stream@3.6.2: readable-stream@3.6.2:
dependencies: dependencies:
inherits: 2.0.4 inherits: 2.0.4
@@ -4962,6 +5408,13 @@ snapshots:
zod@3.25.76: {} zod@3.25.76: {}
zustand@4.5.7(@types/react@19.3.0)(react@19.3.1):
dependencies:
use-sync-external-store: 1.7.0(react@19.3.1)
optionalDependencies:
'@types/react': 19.3.0
react: 19.3.1
zustand@5.0.16(@types/react@19.3.0)(react@19.3.1)(use-sync-external-store@1.7.0(react@19.3.1)): zustand@5.0.16(@types/react@19.3.0)(react@19.3.1)(use-sync-external-store@1.7.0(react@19.3.1)):
optionalDependencies: optionalDependencies:
'@types/react': 19.3.0 '@types/react': 19.3.0
+44
View File
@@ -7,6 +7,7 @@ import type {
Character, Character,
Entity, Entity,
Note, Note,
PlotBeat,
Project, Project,
ProjectMeta, ProjectMeta,
User, User,
@@ -69,6 +70,21 @@ export interface BoardRow {
edges: string; edges: string;
updated_at: string; updated_at: string;
} }
export interface PlotBeatRow {
id: string;
project_id: string;
title: string;
body: string;
act: number;
type: string;
beat_order: number;
status: string;
chapter_id: string | null;
entity_ids: string;
created_at: string;
updated_at: string;
}
export interface AISessionRow { export interface AISessionRow {
id: string; id: string;
project_id: string; project_id: string;
@@ -160,6 +176,34 @@ export function toBoard(r: BoardRow): Board {
}; };
} }
export function toPlotBeat(r: PlotBeatRow): PlotBeat {
return {
id: r.id,
projectId: r.project_id,
title: r.title,
body: r.body,
act: r.act,
type: r.type as PlotBeat["type"],
order: r.beat_order,
status: (r.status as PlotBeat["status"]) ?? "draft",
chapterId: r.chapter_id ?? undefined,
entityIds: parseEntityIds(r.entity_ids),
createdAt: r.created_at,
updatedAt: r.updated_at,
};
}
/** Parse the JSON `entity_ids` column into a string[] (empty when blank). */
function parseEntityIds(raw: string): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : [];
} catch {
return [];
}
}
export function toAISession(r: AISessionRow): AISession { export function toAISession(r: AISessionRow): AISession {
return { return {
id: r.id, id: r.id,
+19
View File
@@ -0,0 +1,19 @@
-- 002_plot_beats.sql — plot beats for the Plot board and Timeline (CHUNK #5).
CREATE TABLE IF NOT EXISTS plot_beats (
id TEXT NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
act INTEGER NOT NULL DEFAULT 1,
type TEXT NOT NULL DEFAULT 'note',
beat_order INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'draft',
chapter_id TEXT,
entity_ids TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (project_id, id)
);
CREATE INDEX IF NOT EXISTS idx_plot_beats_project ON plot_beats(project_id);
CREATE INDEX IF NOT EXISTS idx_plot_beats_act ON plot_beats(project_id, act, beat_order);
+63
View File
@@ -12,6 +12,7 @@ import {
toBook, toBook,
toChapter, toChapter,
toBoard, toBoard,
toPlotBeat,
toAISession, toAISession,
toEntity, toEntity,
entityToRow, entityToRow,
@@ -23,6 +24,7 @@ import {
type ChapterRow, type ChapterRow,
type EntityRow, type EntityRow,
type BoardRow, type BoardRow,
type PlotBeatRow,
type AISessionRow, type AISessionRow,
} from "./mapping.js"; } from "./mapping.js";
@@ -75,6 +77,17 @@ const boardPatch = z.object({
nodes: z.array(z.any()).optional(), nodes: z.array(z.any()).optional(),
edges: z.array(z.any()).optional(), edges: z.array(z.any()).optional(),
}); });
const beatCreate = z.object({
id: z.string().optional(),
title: z.string().default(""),
body: z.string().default(""),
act: z.number().int().min(1).default(1),
type: z.enum(["setup", "conflict", "turn", "climax", "resolution", "scene", "note"]).default("note"),
order: z.number().int().min(0).default(0),
status: z.enum(["draft", "revised", "locked"]).default("draft"),
chapterId: z.string().optional(),
entityIds: z.array(z.string()).optional(),
});
const aiSessionCreate = z.object({ const aiSessionCreate = z.object({
id: z.string().optional(), id: z.string().optional(),
role: z.string().min(1), role: z.string().min(1),
@@ -459,6 +472,56 @@ export function registerRoutes(app: FastifyInstance): void {
return { ok: true }; return { ok: true };
}); });
// ---------- Plot beats ----------
app.get("/api/projects/:id/beats", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id);
const rows = db
.prepare("SELECT * FROM plot_beats WHERE project_id = ? ORDER BY act, beat_order, id")
.all(id) as PlotBeatRow[];
return { beats: rows.map(toPlotBeat) };
});
app.post("/api/projects/:id/beats", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const body = beatCreate.parse(req.body);
const bid = body.id ?? ulid();
const t = now();
db.prepare(`INSERT INTO plot_beats (id, project_id, title, body, act, type, beat_order, status, chapter_id, entity_ids, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id, id) DO UPDATE SET
title = excluded.title, body = excluded.body, act = excluded.act, type = excluded.type,
beat_order = excluded.beat_order, status = excluded.status, chapter_id = excluded.chapter_id,
entity_ids = excluded.entity_ids, updated_at = excluded.updated_at`).run(
bid,
id,
body.title,
body.body,
body.act,
body.type,
body.order,
body.status ?? "draft",
body.chapterId ?? null,
JSON.stringify(body.entityIds ?? []),
t,
t,
);
const row = db.prepare("SELECT * FROM plot_beats WHERE id = ? AND project_id = ?").get(bid, id) as PlotBeatRow;
return { beat: toPlotBeat(row) };
});
app.delete("/api/projects/:id/beats/:beatId", async (req) => {
const { id, beatId } = req.params as { id: string; beatId: string };
requireProjectAccess(app, req, id, true);
const row = db.prepare("SELECT * FROM plot_beats WHERE id = ? AND project_id = ?").get(beatId, id) as
| PlotBeatRow
| undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
db.prepare("DELETE FROM plot_beats WHERE id = ? AND project_id = ?").run(beatId, id);
return { ok: true };
});
// ---------- AI sessions ---------- // ---------- AI sessions ----------
app.get("/api/projects/:id/ai/sessions", async (req) => { app.get("/api/projects/:id/ai/sessions", async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
+54 -1
View File
@@ -77,6 +77,44 @@ function addBoard(db: any, projectId: string, board: Board): void {
).run(board.id, projectId, JSON.stringify(board.nodes), JSON.stringify(board.edges), t); ).run(board.id, projectId, JSON.stringify(board.nodes), JSON.stringify(board.edges), t);
} }
/** Upsert a single plot beat. */
function addBeat(
db: any,
projectId: string,
beat: {
id: string;
title: string;
body: string;
act: number;
type: string;
order: number;
chapterId?: string;
entityIds?: string[];
},
): void {
const t = new Date().toISOString();
db.prepare(
`INSERT INTO plot_beats (id, project_id, title, body, act, type, beat_order, chapter_id, entity_ids, created_at, updated_at) ` +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT(project_id, id) DO UPDATE SET " +
"title = excluded.title, body = excluded.body, act = excluded.act, type = excluded.type, " +
"beat_order = excluded.beat_order, chapter_id = excluded.chapter_id, " +
"entity_ids = excluded.entity_ids, updated_at = excluded.updated_at",
).run(
beat.id,
projectId,
beat.title,
beat.body,
beat.act,
beat.type,
beat.order,
beat.chapterId ?? null,
JSON.stringify(beat.entityIds ?? []),
t,
t,
);
}
async function main(): Promise<void> { async function main(): Promise<void> {
const db = openDb(); const db = openDb();
@@ -262,8 +300,23 @@ async function main(): Promise<void> {
], ],
}); });
// Plot beats across three acts.
const c1 = "c1";
const c2 = "c2";
const c3 = "c3";
addBeat(db, projectId, { id: "b1", title: "Mara arrives", body: "The ferry carries Mara Vell to the Glass Harbor before dawn.", act: 1, type: "event", order: 0, chapterId: c1 });
addBeat(db, projectId, { id: "b2", title: "The failing light", body: "Three nights without the beam; the fish are turning away.", act: 1, type: "event", order: 1, chapterId: c1 });
addBeat(db, projectId, { id: "b3", title: "The old keeper's silence", body: "Tomas never waves. Mara senses a grief she is not yet allowed to understand.", act: 1, type: "foreshadow", order: 2, chapterId: c1 });
addBeat(db, projectId, { id: "b4", title: "The storm", body: "The storm arrives on the second night, without apology.", act: 2, type: "turn", order: 0, chapterId: c2 });
addBeat(db, projectId, { id: "b5", title: "Mara climbs the lamp", body: "One hundred and fourteen steps; she coaxes the seized gears back to life.", act: 2, type: "event", order: 1, chapterId: c2 });
addBeat(db, projectId, { id: "b6", title: "Fen's warning", body: "They died because they forgot that someone had to keep the light.", act: 2, type: "reveal", order: 2, chapterId: c2 });
addBeat(db, projectId, { id: "b7", title: "The glass in the dark", body: "A shard of living glass — the light is mined, not made.", act: 2, type: "reveal", order: 3, chapterId: c3 });
addBeat(db, projectId, { id: "b8", title: "The inherited duty", body: "The old keeper never waved, never left. Some duties are inherited, like the tide.", act: 3, type: "turn", order: 0, chapterId: c3 });
addBeat(db, projectId, { id: "b9", title: "Mara chooses the light", body: "I want it. She accepts the keepership Tomas is offering.", act: 3, type: "climax", order: 1, chapterId: c3 });
addBeat(db, projectId, { id: "b10", title: "The light answers", body: "The beam sweeps out across the black water. The harbor is kept.", act: 3, type: "resolution", order: 2, chapterId: c3 });
console.log(`seed: created demo project "${DEMO_TITLE}" (id ${projectId})`); 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: 1 book, 3 chapters, 4 characters, 1 world location, 1 board, 10 plot beats`);
console.log(`seed: owner: ${owner.id}`); console.log(`seed: owner: ${owner.id}`);
console.log(`seed: login with PUBSCRIPT_ADMIN_EMAIL=${process.env.PUBSCRIPT_ADMIN_EMAIL ?? "admin@pubscript.local"}`); console.log(`seed: login with PUBSCRIPT_ADMIN_EMAIL=${process.env.PUBSCRIPT_ADMIN_EMAIL ?? "admin@pubscript.local"}`);
db.close(); db.close();