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
@@ -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 ?? "",
),
);
}