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
View File
@@ -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() {
<Route path="/" element={<Write />} />
<Route path="/characters" element={<Characters />} />
<Route path="/plot" element={<Plot />} />
<Route path="/timeline" element={<Timeline />} />
<Route path="/whiteboard" element={<Whiteboard />} />
<Route path="/ai" element={<AI />} />
<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 "./styles/writing.css";
import "./styles/entities.css";
import "./styles/plot.css";
import "./styles/timeline.css";
import "./styles/whiteboard.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
+482 -17
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 = [
{ 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<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() {
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 (
<div className="ps-content">
<h1 style={{ margin: "0 0 16px", fontSize: 24 }}>Plot</h1>
{ACTS.map((a) => (
<section key={a.act} style={{ marginBottom: 28 }}>
<h2 style={{ fontSize: 18, display: "flex", alignItems: "center", gap: 10 }}>
{a.act} <Badge variant="neutral">{String(a.beats.length)}</Badge>
</h2>
<ul style={{ fontFamily: "var(--ps-font-body)", lineHeight: 2, margin: 0, paddingLeft: 20 }}>
{a.beats.map((b) => (
<li key={b}>{b}</li>
<div className="plot">
<div className="plot__header">
<div>
<h1 className="heading">Plot</h1>
<p className="muted">
{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>
<p className="muted plot__column-sub">{act.subtitle}</p>
</div>
<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>
))}
</div>
</section>
))}
</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>
))}
</ul>
</section>
))}
<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>
);
}
+488 -17
View File
@@ -1,27 +1,498 @@
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 = [
{ id: "w1", title: "The lost map", note: "Mara inherits a map with no destination.", x: 0 },
{ id: "w2", title: "The harbor", note: "Where the story begins.", x: 240 },
{ id: "w3", title: "The Cartographer", note: "Knows more than they say.", x: 480 },
import { createUlid } from "@pubscript/core";
import type { FlowNode, FlowEdge, Entity, PlotBeat } from "@pubscript/core";
import { Button } from "@pubscript/ui";
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() {
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 (
<div className="ps-content">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<h1 style={{ margin: 0, fontSize: 24 }}>Whiteboard</h1>
<Button variant="primary">+ Add card</Button>
<div className="wf">
<div className="wf__header">
<div>
<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 className="wf__actions">
<Button variant="ghost" size="sm" onClick={() => setShowAdd((s) => !s)}>
+ Add node
</Button>
<Button
variant="accent"
size="sm"
onClick={() => setShowImport(true)}
disabled={availableBeats.length === 0}
>
+ Add beats
</Button>
</div>
</div>
<div style={{ display: "flex", gap: 16, overflowX: "auto", paddingBottom: 12 }}>
{CARDS.map((c) => (
<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)" }}>
<div className="ps-modal-body" style={{ padding: 14 }}>
<h3 style={{ margin: "0 0 6px", fontSize: 15 }}>{c.title}</h3>
<p style={{ margin: 0, fontSize: 13, color: "var(--ps-text-muted)" }}>{c.note}</p>
</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 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>
);
@@ -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 {
createAutosave,
type AutosaveState,
type Board,
type Chapter,
type DocumentRef,
type Entity,
type FlowNode,
type FlowEdge,
type PlotBeat,
type ProjectMeta,
type StorageAdapter,
} from "@pubscript/core";
@@ -37,6 +41,14 @@ interface ProjectStore {
entities: Entity[];
entitiesLoaded: boolean;
// --- plot beats (Chunk #5) ---
plotBeats: PlotBeat[];
plotBeatsLoaded: boolean;
// --- whiteboard (Chunk #5) ---
board: Board | null;
boardLoaded: boolean;
// --- actions ---
/** Load the project (seed a demo if empty) and select the first chapter. */
init: () => Promise<void>;
@@ -56,6 +68,32 @@ interface ProjectStore {
updateEntity: (entity: Entity) => Promise<void>;
/** Delete an entity by id. */
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. */
@@ -96,6 +134,10 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" },
entities: [],
entitiesLoaded: false,
plotBeats: [],
plotBeatsLoaded: false,
board: null,
boardLoaded: false,
init: async () => {
set({ status: "loading", error: null });
@@ -122,6 +164,12 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
// Load entities for the entity engine.
await get().loadEntities();
// Load plot beats for the plot board + timeline.
await get().loadPlotBeats();
// Load the story whiteboard.
await get().loadBoard();
} catch (e) {
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),
}));
},
// --- 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 {
createUlid,
createPlotBeat,
type Board,
type Character,
type Note,
type PlotBeat,
type ProseMirrorDoc,
type WorldItem,
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.
* 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.saveBoard({ ...BOARD, projectId: project.id });
for (const beat of PLOT_BEATS) {
await adapter.savePlotBeat({ ...beat, projectId: 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;
}