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:
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? "",
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user