From 45f5c505c9283f9ee4132d4364468dbbde99c52e Mon Sep 17 00:00:00 2001 From: Schwitzz Date: Mon, 26 Oct 2026 19:00:07 -0500 Subject: [PATCH] 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. --- apps/web/package.json | 5 +- apps/web/src/App.tsx | 3 + apps/web/src/features/plot/templates.test.ts | 76 +++ apps/web/src/features/plot/templates.ts | 132 +++++ apps/web/src/main.tsx | 3 + apps/web/src/routes/Plot.tsx | 499 ++++++++++++++++- apps/web/src/routes/Timeline.tsx | 216 ++++++++ apps/web/src/routes/Whiteboard.tsx | 505 +++++++++++++++++- .../store/project-store.plot-beats.test.ts | 128 +++++ apps/web/src/store/project-store.ts | 153 ++++++ apps/web/src/store/seed.ts | 36 ++ apps/web/src/styles/plot.css | 393 ++++++++++++++ apps/web/src/styles/timeline.css | 205 +++++++ apps/web/src/styles/whiteboard.css | 328 ++++++++++++ packages/core/src/autosave.test.ts | 8 + packages/core/src/schema.ts | 76 +++ packages/core/src/storage-local.ts | 33 ++ packages/core/src/storage-server.ts | 36 ++ packages/core/src/storage.ts | 9 + packages/core/src/types.ts | 53 +- packages/ui/src/AppShell.css | 2 + packages/ui/src/components/Badge.tsx | 5 +- packages/ui/src/components/Button.tsx | 2 +- pnpm-lock.yaml | 453 ++++++++++++++++ server/src/mapping.ts | 44 ++ server/src/migrations/002_plot_beats.sql | 19 + server/src/routes.ts | 63 +++ server/src/seed.ts | 55 +- 28 files changed, 3488 insertions(+), 52 deletions(-) create mode 100644 apps/web/src/features/plot/templates.test.ts create mode 100644 apps/web/src/features/plot/templates.ts create mode 100644 apps/web/src/routes/Timeline.tsx create mode 100644 apps/web/src/store/project-store.plot-beats.test.ts create mode 100644 apps/web/src/styles/plot.css create mode 100644 apps/web/src/styles/timeline.css create mode 100644 apps/web/src/styles/whiteboard.css create mode 100644 server/src/migrations/002_plot_beats.sql diff --git a/apps/web/package.json b/apps/web/package.json index cddcf21..8d29eef 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,16 +18,17 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1", + "reactflow": "^11", "zustand": "^5.0.2" }, "devDependencies": { + "@types/node": "^22.10.2", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "jsdom": "^25.0.1", "typescript": "^5.7.2", "vite": "^6.0.3", - "vitest": "^2.1.8", - "@types/node": "^22.10.2" + "vitest": "^2.1.8" } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a60f616..da36317 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,6 +5,7 @@ import { ToastViewport } from "@pubscript/ui"; import Write from "./routes/Write.js"; import { Characters } from "./routes/Characters.js"; import { Plot } from "./routes/Plot.js"; +import { Timeline } from "./routes/Timeline.js"; import { Whiteboard } from "./routes/Whiteboard.js"; import { AI } from "./routes/AI.js"; import { Projects } from "./routes/Projects.js"; @@ -15,6 +16,7 @@ const NAV = [ { to: "/", label: "Write", icon: "โœŽ", end: true }, { to: "/characters", label: "Entities", 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: "/ai", label: "AI", icon: "โœจ", end: false }, { to: "/projects", label: "Projects", icon: "๐Ÿ“", end: false }, @@ -45,6 +47,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/features/plot/templates.test.ts b/apps/web/src/features/plot/templates.test.ts new file mode 100644 index 0000000..3845c01 --- /dev/null +++ b/apps/web/src/features/plot/templates.test.ts @@ -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}$/); + }); +}); diff --git a/apps/web/src/features/plot/templates.ts b/apps/web/src/features/plot/templates.ts new file mode 100644 index 0000000..ae0380c --- /dev/null +++ b/apps/web/src/features/plot/templates.ts @@ -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 ?? "", + ), + ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 50cc417..6294c79 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,6 +5,9 @@ import App from "./App.js"; import "@pubscript/editor/styles"; import "./styles/writing.css"; import "./styles/entities.css"; +import "./styles/plot.css"; +import "./styles/timeline.css"; +import "./styles/whiteboard.css"; createRoot(document.getElementById("root")!).render( diff --git a/apps/web/src/routes/Plot.tsx b/apps/web/src/routes/Plot.tsx index 8cf720a..f9ad5eb 100644 --- a/apps/web/src/routes/Plot.tsx +++ b/apps/web/src/routes/Plot.tsx @@ -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 = [ - { act: "Act I โ€” Setup", beats: ["The world as it is", "The inciting incident", "Crossing the threshold"] }, - { act: "Act II โ€” Confrontation", beats: ["Rising action", "The midpoint reversal", "All is lost"] }, - { act: "Act III โ€” Resolution", beats: ["The climax", "The new equilibrium"] }, + { 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 = { + setup: "Setup", + conflict: "Conflict", + turn: "Turn", + climax: "Climax", + resolution: "Resolution", + scene: "Scene", + note: "Note", +}; + +const TYPE_BADGE: Record = { + setup: "neutral", + conflict: "danger", + turn: "warning", + climax: "danger", + resolution: "success", + scene: "accent", + note: "neutral", +}; + +const STATUS_LABELS: Record = { + draft: "Draft", + revised: "Revised", + locked: "Locked", +}; + +const STATUS_BADGE: Record = { + draft: "neutral", + revised: "accent", + locked: "success", +}; + +/** Group beats by act, preserving the store's act-then-order sort. */ +function groupByAct(beats: PlotBeat[]): Record { + const groups: Record = { 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() { + const { plotBeats, savePlotBeat, deletePlotBeat, reorderPlotBeats, chapters, entities } = + useProjectStore(); + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + const [dragId, setDragId] = useState(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 ( -
-

Plot

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

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

-
    - {a.beats.map((b) => ( -
  • {b}
  • +
    +
    +
    +

    Plot

    +

    + {plotBeats.length} beat{plotBeats.length === 1 ? "" : "s"} across three acts. +

    + {plotBeats.length > 0 && ( +
    +
    + + {progress.revised}/{progress.total} revised or locked ({progress.pct}%) + +
    + )} +
    + +
    + +
    + {ACTS.map((act) => ( +
    e.preventDefault()} + onDrop={(e) => onDrop(act.n, e)} + > +
    +
    +

    + {act.title} + {actBeats(act.n).length} +

    +

    {act.subtitle}

    +
    + +
    + +
    + {actBeats(act.n).length === 0 && ( +
    Drag beats here or add one.
    + )} + {actBeats(act.n).map((beat) => ( +
    onDragStart(beat.id)} + onDragEnd={onDragEnd} + onClick={() => setEditing(beat)} + > +
    + + {TYPE_LABELS[beat.type]} + + {beat.status === "locked" && ( + ๐Ÿ”’ + )} + +
    +

    {beat.title || "Untitled beat"}

    + {beat.body &&

    {beat.body}

    } +
    + {beat.chapterId && ( + + ๐Ÿ“– + + )} + {(beat.entityIds ?? []).map((id) => { + const entity = entities.find((e) => e.id === id); + if (!entity) return null; + const label = entityLabel(entity); + return ( + + {label.slice(0, 12)} + + ); + })} +
    +
    + ))} +
    +
    + ))} +
    + + {/* Template picker */} + {creating && ( + setCreating(false)} + footer={ + + } + > +
    +

    + Start from a structural template, or add a single blank beat. +

    + {PLOT_TEMPLATES.map((template) => ( + ))} -
-
- ))} +
or add one beat
+
+ {TEMPLATE_TYPES.map((type) => ( + + ))} +
+
+ + )} + + {/* Edit modal */} + {editing && ( + setEditing(null)} + onSave={handleSave} + onDelete={handleDelete} + chapters={chapters} + entities={entities} + /> + )} ); } + +const TEMPLATE_TYPES: PlotBeatType[] = ["setup", "conflict", "turn", "climax", "resolution", "scene", "note"]; + +interface EditModalProps { + open: boolean; + beat: PlotBeat; + onClose: () => void; + onSave: (beat: PlotBeat) => Promise; + onDelete: (id: string) => Promise; + chapters: Chapter[]; + entities: Entity[]; +} + +function EditModal({ open, beat, onClose, onSave, onDelete, chapters, entities }: EditModalProps) { + const [draft, setDraft] = useState(beat); + + function updateField(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 ( + + {draft.status !== "locked" && ( + + )} + + + + + } + > +
+ + + + + + + {chapters.length > 0 && ( + + )} + + {entities.length > 0 && ( +
+ Entities +
+ {entities.map((e) => { + const selected = (draft.entityIds ?? []).includes(e.id); + return ( + + ); + })} +
+
+ )} + + + +