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
+8
View File
@@ -40,6 +40,14 @@ function mockAdapter(): StorageAdapter & { saved: DocumentRef[] } {
return [];
},
async saveBoard() {},
async getBoard() {
return null;
},
async savePlotBeat() {},
async listPlotBeats() {
return [];
},
async deletePlotBeat() {},
async saveAISession() {},
async saveDocument(doc: DocumentRef) {
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.
*/
import type { PlotBeat, PlotBeatType as PlotBeatTypeRef } from "./types.js";
export type FieldType = "text" | "textarea" | "select" | "list" | "link";
export interface FieldDef {
@@ -99,3 +101,77 @@ export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
/** All entity kinds, in display order. */
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,
Entity,
NewProject,
PlotBeat,
Project,
ProjectMeta,
} from "./types.js";
@@ -28,6 +29,7 @@ class PubScriptDB extends Dexie {
chapters!: Table<Chapter, string>;
entities!: Table<EntityRec, string>;
boards!: Table<Board, string>;
plotBeats!: Table<PlotBeat, string>;
aiSessions!: Table<AISession, string>;
constructor() {
@@ -38,6 +40,8 @@ class PubScriptDB extends Dexie {
chapters: "id, bookId, projectId",
entities: "key, projectId, kind",
boards: "id, projectId",
// Plot beats — atomic units of story structure (CHUNK #5).
plotBeats: "id, [projectId+act], projectId, chapterId",
aiSessions: "id, projectId",
});
}
@@ -194,6 +198,35 @@ export const storageLocal: StorageAdapter = {
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> {
await db.aiSessions.put(s);
},
+36
View File
@@ -8,6 +8,7 @@ import type {
NewProject,
Project,
ProjectMeta,
PlotBeat,
} from "./types.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> {
await req("POST", `/api/projects/${s.projectId}/ai/sessions`, {
id: s.id,
+9
View File
@@ -13,6 +13,7 @@ import type {
NewProject,
Project,
ProjectMeta,
PlotBeat,
AISession,
} from "./types.js";
@@ -53,6 +54,14 @@ export interface StorageAdapter {
*/
searchEntities(projectId: string, q: string): Promise<Entity[]>;
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>;
// live updates
+41 -12
View File
@@ -156,18 +156,6 @@ export type BeatTemplate =
export type BeatStatus = "draft" | "revised" | "locked";
export interface PlotBeat {
id: string;
projectId: string;
template: BeatTemplate;
title: string;
description: string;
status: BeatStatus;
chapterId?: string;
order: number;
notes: string;
}
export interface TimelineEvent {
id: string;
projectId: string;
@@ -259,5 +247,46 @@ export interface DocumentRef {
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. */
export type Entity = Character | WorldItem | Note;