plotcoder-board 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,375 @@
1
+ // The paginator (R23, slice c1).
2
+ //
3
+ // A scene's Fountain text becomes screenplay elements, the elements become
4
+ // lines at Courier 12 widths, and the lines become pages of fifty-five with
5
+ // the four rules that do most of the work:
6
+ // - a heading keeps at least two lines of its scene with it, or moves;
7
+ // - dialogue breaks between sentences, with (MORE) under and NAME (CONT'D) over;
8
+ // - dual dialogue (^) sets two speakers side by side;
9
+ // - scene numbers follow wall order and print in both margins.
10
+ // Nothing here is stored: the card holds Fountain, and every break, (MORE),
11
+ // (CONT'D) and number is computed each time. Pure and DOM-free, so the panel,
12
+ // the print, the strip, read the wall and the MCP server all count the same.
13
+
14
+ export const LINES_PER_PAGE = 55;
15
+
16
+ // Columns at ten characters to the inch, US Letter, the industry's margins.
17
+ export const WIDTH = {
18
+ heading: 60, // 1.5" to 7.5"
19
+ action: 60,
20
+ character: 38, // cue at 3.7"
21
+ parenthetical: 25, // 3.1" to 5.6"
22
+ dialogue: 35, // 2.5" to 6.0"
23
+ transition: 60,
24
+ centered: 60,
25
+ dual: 28, // each column of a pair
26
+ };
27
+
28
+ export const TRANSITION = /^(?:[A-Z][A-Z .]*TO:|FADE (?:IN|OUT)[.:]?|CUT TO BLACK[.:]?|SMASH CUT[.:]?|DISSOLVE[.:]?)$/;
29
+
30
+ /** Wrap words to a width; a word longer than the width stands alone. */
31
+ export function wrap(text, width) {
32
+ const lines = [];
33
+ for (const raw of text.split("\n")) {
34
+ const words = raw.trim().split(/\s+/).filter(Boolean);
35
+ if (words.length === 0) {
36
+ lines.push("");
37
+ continue;
38
+ }
39
+ let line = "";
40
+ for (const word of words) {
41
+ if (!line) line = word;
42
+ else if (line.length + 1 + word.length <= width) line = `${line} ${word}`;
43
+ else {
44
+ lines.push(line);
45
+ line = word;
46
+ }
47
+ }
48
+ lines.push(line);
49
+ }
50
+ return lines;
51
+ }
52
+
53
+ /**
54
+ * A scene's Fountain body as elements: action, character, parenthetical,
55
+ * dialogue, transition, centered, page break. Character cues are lines in
56
+ * capitals followed by dialogue; `^` after a cue marks dual dialogue; `!`
57
+ * forces action; `>` forces a transition or, with `<`, centres; notes,
58
+ * synopses, sections and boneyards are not on the page.
59
+ */
60
+ export function parseScene(text) {
61
+ const src = (text ?? "").replace(/\r\n?/g, "\n");
62
+ const lines = src.split("\n");
63
+ const elements = [];
64
+ let inBoneyard = false;
65
+ let i = 0;
66
+ const blankBefore = (index) => index === 0 || lines[index - 1].trim() === "";
67
+ const blankAfter = (index) => index >= lines.length - 1 || lines[index + 1].trim() === "";
68
+ while (i < lines.length) {
69
+ const raw = lines[i];
70
+ const line = raw.trim();
71
+ if (inBoneyard) {
72
+ if (line.includes("*/")) inBoneyard = false;
73
+ i += 1;
74
+ continue;
75
+ }
76
+ if (line.startsWith("/*")) {
77
+ if (!line.includes("*/")) inBoneyard = true;
78
+ i += 1;
79
+ continue;
80
+ }
81
+ if (/^={3,}$/.test(line)) {
82
+ elements.push({ kind: "break", at: i });
83
+ i += 1;
84
+ continue;
85
+ }
86
+ if (!line || line.startsWith("[[") || line.startsWith("=") || line.startsWith("#")) {
87
+ i += 1;
88
+ continue;
89
+ }
90
+ if (/^>.*<$/.test(line)) {
91
+ elements.push({ kind: "centered", text: line.slice(1, -1).trim(), at: i });
92
+ i += 1;
93
+ continue;
94
+ }
95
+ if (line.startsWith(">") || (TRANSITION.test(line) && blankBefore(i) && blankAfter(i))) {
96
+ elements.push({ kind: "transition", text: line.replace(/^>\s*/, ""), at: i });
97
+ i += 1;
98
+ continue;
99
+ }
100
+ if (line.startsWith("!")) {
101
+ elements.push({ kind: "action", text: line.slice(1), at: i });
102
+ i += 1;
103
+ continue;
104
+ }
105
+ // A character cue: capitals (with an optional extension in brackets), a
106
+ // blank line before, something after that is not blank.
107
+ const cue = /^@?([^a-z]+?)(\s*\(.*\))?(\s*\^)?$/.exec(line);
108
+ if (cue && blankBefore(i) && !blankAfter(i) && /[A-Z]/.test(line) && !TRANSITION.test(line)) {
109
+ const dual = Boolean(cue[3]);
110
+ const name = `${cue[1].replace(/^@/, "").trim()}${cue[2] ? ` ${cue[2].trim()}` : ""}`;
111
+ const at = i;
112
+ const speech = [];
113
+ i += 1;
114
+ while (i < lines.length && lines[i].trim() !== "") {
115
+ const part = lines[i].trim();
116
+ if (/^\(.*\)$/.test(part)) speech.push({ kind: "parenthetical", text: part, at: i });
117
+ else speech.push({ kind: "dialogue", text: part, at: i });
118
+ i += 1;
119
+ }
120
+ elements.push({ kind: "speech", name, dual, parts: mergeDialogue(speech), at });
121
+ continue;
122
+ }
123
+ // Action: consecutive non-blank lines are one paragraph, line breaks kept.
124
+ const at = i;
125
+ const paragraph = [raw.replace(/\s+$/, "")];
126
+ i += 1;
127
+ while (i < lines.length && lines[i].trim() !== "") {
128
+ paragraph.push(lines[i].replace(/\s+$/, ""));
129
+ i += 1;
130
+ }
131
+ elements.push({ kind: "action", text: paragraph.join("\n"), at });
132
+ }
133
+ return elements;
134
+ }
135
+
136
+ /**
137
+ * The kind of each source line of a scene, for an editor that keeps the
138
+ * writer's lines as they are and only styles them: the same rules as
139
+ * parseScene, line by line — "blank", "action", "character", "parenthetical",
140
+ * "dialogue", "transition", "centered", "break", "note".
141
+ */
142
+ export function classifyLines(text) {
143
+ const lines = (text ?? "").replace(/\r\n?/g, "\n").split("\n");
144
+ const kinds = lines.map(() => "action");
145
+ const elements = parseScene(text);
146
+ for (let i = 0; i < lines.length; i += 1) {
147
+ const line = lines[i].trim();
148
+ if (!line) kinds[i] = "blank";
149
+ else if (line.startsWith("[[") || line.startsWith("=") || line.startsWith("#") || line.startsWith("/*")) kinds[i] = "note";
150
+ }
151
+ for (const element of elements) {
152
+ if (element.kind === "break") kinds[element.at] = "break";
153
+ else if (element.kind === "centered" || element.kind === "transition") kinds[element.at] = element.kind;
154
+ else if (element.kind === "speech") {
155
+ kinds[element.at] = "character";
156
+ for (const part of element.parts) if (typeof part.at === "number") kinds[part.at] = part.kind;
157
+ // merged dialogue lines: every non-blank line after the cue until a blank
158
+ for (let j = element.at + 1; j < lines.length && lines[j].trim() !== ""; j += 1) {
159
+ if (kinds[j] === "action") kinds[j] = /^\(.*\)$/.test(lines[j].trim()) ? "parenthetical" : "dialogue";
160
+ }
161
+ }
162
+ }
163
+ return kinds;
164
+ }
165
+
166
+ function mergeDialogue(parts) {
167
+ const merged = [];
168
+ for (const part of parts) {
169
+ const last = merged.at(-1);
170
+ if (part.kind === "dialogue" && last && last.kind === "dialogue") last.text = `${last.text} ${part.text}`;
171
+ else merged.push({ ...part });
172
+ }
173
+ return merged;
174
+ }
175
+
176
+ /**
177
+ * Elements as lines with a kind each, blank lines between blocks as the
178
+ * page has them. A speech is one block: cue, then its parts. Dual pairs are
179
+ * folded into one block of paired lines.
180
+ */
181
+ export function layoutScene(elements, heading, sceneNumber) {
182
+ const blocks = [];
183
+ if (heading) blocks.push({ kind: "heading", lines: wrap(heading, WIDTH.heading).map((text) => ({ kind: "heading", text, sceneNumber, src: -1 })) });
184
+ for (let index = 0; index < elements.length; index += 1) {
185
+ const element = elements[index];
186
+ if (element.kind === "break") {
187
+ blocks.push({ kind: "break", lines: [] });
188
+ continue;
189
+ }
190
+ if (element.kind === "action" || element.kind === "transition" || element.kind === "centered") {
191
+ blocks.push({ kind: element.kind, lines: wrap(element.text, WIDTH[element.kind]).map((text) => ({ kind: element.kind, text, src: element.at })) });
192
+ continue;
193
+ }
194
+ if (element.kind === "speech") {
195
+ const next = elements[index + 1];
196
+ if (next && next.kind === "speech" && next.dual) {
197
+ const left = speechLines(element, WIDTH.dual);
198
+ const right = speechLines(next, WIDTH.dual);
199
+ const lines = [];
200
+ for (let row = 0; row < Math.max(left.length, right.length); row += 1) {
201
+ lines.push({ kind: "dual", left: left[row] ?? { kind: "blank", text: "" }, right: right[row] ?? { kind: "blank", text: "" } });
202
+ }
203
+ blocks.push({ kind: "dual", lines });
204
+ index += 1;
205
+ continue;
206
+ }
207
+ blocks.push({
208
+ kind: "speech",
209
+ name: element.name,
210
+ parts: element.parts,
211
+ width: WIDTH.dialogue,
212
+ lines: speechLines(element, WIDTH.dialogue),
213
+ });
214
+ }
215
+ }
216
+ return blocks;
217
+ }
218
+
219
+ function speechLines(speech, dialogueWidth) {
220
+ const lines = [{ kind: "character", text: speech.name.toUpperCase(), src: speech.at }];
221
+ for (const part of speech.parts) {
222
+ const width = part.kind === "parenthetical" ? WIDTH.parenthetical : dialogueWidth;
223
+ for (const text of wrap(part.text, width)) lines.push({ kind: part.kind, text, src: part.at ?? speech.at });
224
+ }
225
+ return lines;
226
+ }
227
+
228
+ /**
229
+ * Break blocks into pages. A block is preceded by one blank line unless it
230
+ * opens a page. A speech that does not fit is split between sentences of its
231
+ * dialogue, with (MORE) closing the page and NAME (CONT'D) opening the next;
232
+ * a speech too short to split moves whole. A heading moves unless two lines
233
+ * of its scene fit under it.
234
+ */
235
+ export function paginateBlocks(scenes) {
236
+ const pages = [];
237
+ let page = { number: 1, lines: [] };
238
+ const placement = new Map(); // noteId -> { page, endPage }
239
+ const flush = () => {
240
+ pages.push(page);
241
+ page = { number: pages.length + 1, lines: [] };
242
+ };
243
+ const room = () => LINES_PER_PAGE - page.lines.length;
244
+ const place = (noteId) => {
245
+ const at = placement.get(noteId);
246
+ if (!at) placement.set(noteId, { page: page.number, endPage: page.number });
247
+ else at.endPage = page.number;
248
+ };
249
+
250
+ for (const scene of scenes) {
251
+ const blocks = scene.blocks;
252
+ for (let b = 0; b < blocks.length; b += 1) {
253
+ const block = blocks[b];
254
+ if (block.kind === "break") {
255
+ if (page.lines.length) flush();
256
+ continue;
257
+ }
258
+ const gap = page.lines.length ? 1 : 0;
259
+ let need = gap + block.lines.length;
260
+ if (block.kind === "heading") {
261
+ // Two lines of the scene must fit under the heading: the gap and two lines of the next block.
262
+ const following = blocks[b + 1];
263
+ const under = following ? 1 + Math.min(2, following.lines.length) : 0;
264
+ need += under;
265
+ if (need > room() && page.lines.length) flush();
266
+ push(page, block.lines, gap && page.lines.length ? 1 : 0, scene.id);
267
+ place(scene.id);
268
+ continue;
269
+ }
270
+ if (need <= room()) {
271
+ push(page, block.lines, gap, scene.id);
272
+ place(scene.id);
273
+ continue;
274
+ }
275
+ if (block.kind === "speech") {
276
+ const split = splitSpeech(block, room() - gap);
277
+ if (split) {
278
+ push(page, split.head, gap, scene.id);
279
+ place(scene.id);
280
+ flush();
281
+ push(page, split.tail, 0, scene.id);
282
+ place(scene.id);
283
+ continue;
284
+ }
285
+ }
286
+ if (block.kind === "action" && block.lines.length > 1 && room() - gap >= 2) {
287
+ const take = room() - gap;
288
+ push(page, block.lines.slice(0, take), gap, scene.id);
289
+ place(scene.id);
290
+ flush();
291
+ push(page, block.lines.slice(take), 0, scene.id);
292
+ place(scene.id);
293
+ continue;
294
+ }
295
+ if (page.lines.length) flush();
296
+ push(page, block.lines.slice(0, LINES_PER_PAGE), 0, scene.id);
297
+ place(scene.id);
298
+ for (let rest = LINES_PER_PAGE; rest < block.lines.length; rest += LINES_PER_PAGE) {
299
+ flush();
300
+ push(page, block.lines.slice(rest, rest + LINES_PER_PAGE), 0, scene.id);
301
+ place(scene.id);
302
+ }
303
+ }
304
+ }
305
+ if (page.lines.length || pages.length === 0) pages.push(page);
306
+ return { pages, placement };
307
+ }
308
+
309
+ function push(page, lines, gap, noteId) {
310
+ if (gap) page.lines.push({ kind: "blank", text: "", noteId });
311
+ for (const line of lines) page.lines.push({ ...line, noteId });
312
+ }
313
+
314
+ /**
315
+ * Split a speech so `head` fits in `space` lines including (MORE), breaking
316
+ * between sentences of its dialogue and re-wrapping each side, never after a
317
+ * bare cue or a parenthetical. Returns null when no clean split leaves at
318
+ * least one sentence on each page.
319
+ */
320
+ export function splitSpeech(block, space) {
321
+ const budget = space - 2; // the cue and the (MORE) line
322
+ if (budget < 1 || !block.parts || !block.parts.length) return null;
323
+ const partLines = (part) => wrap(part.text, part.kind === "parenthetical" ? WIDTH.parenthetical : block.width);
324
+ let best = null;
325
+ let before = 0;
326
+ for (let p = 0; p < block.parts.length; p += 1) {
327
+ const part = block.parts[p];
328
+ if (part.kind === "dialogue") {
329
+ const sentences = part.text.match(/[^.!?…]+[.!?…]+["')]*\s*|[^.!?…]+$/g) ?? [part.text];
330
+ for (let cut = 1; cut < sentences.length; cut += 1) {
331
+ const headText = sentences.slice(0, cut).join("").trim();
332
+ if (before + wrap(headText, block.width).length <= budget) best = { p, cut, sentences };
333
+ }
334
+ }
335
+ before += partLines(part).length;
336
+ if (before > budget) break;
337
+ }
338
+ if (!best) return null;
339
+ const headParts = [...block.parts.slice(0, best.p), { kind: "dialogue", text: best.sentences.slice(0, best.cut).join("").trim() }];
340
+ const tailParts = [{ kind: "dialogue", text: best.sentences.slice(best.cut).join("").trim() }, ...block.parts.slice(best.p + 1)];
341
+ const toLines = (parts) => parts.flatMap((part) => partLines(part).map((text) => ({ kind: part.kind, text })));
342
+ const cue = block.lines[0];
343
+ return {
344
+ head: [cue, ...toLines(headParts), { kind: "more", text: "(MORE)" }],
345
+ tail: [{ kind: "character", text: `${cue.text} (CONT'D)` }, ...toLines(tailParts)],
346
+ };
347
+ }
348
+
349
+ /** Lines a scene's text runs to on the page, cue and gaps included; 0 when unwritten. */
350
+ export function sceneLineCount(text) {
351
+ if (typeof text !== "string" || !text.trim()) return 0;
352
+ const blocks = layoutScene(parseScene(text), null, null);
353
+ let count = 0;
354
+ for (const block of blocks) count += block.lines.length + (count ? 1 : 0);
355
+ return count;
356
+ }
357
+
358
+ /**
359
+ * The whole wall as pages. `scenes` is the wall in reading order as
360
+ * { id, heading, text, change, written }; an unwritten scene sets its change
361
+ * line as action so the document is always the whole story.
362
+ */
363
+ export function paginate(scenes) {
364
+ const laid = scenes.map((scene, index) => ({
365
+ id: scene.id,
366
+ number: scene.number ?? index + 1,
367
+ blocks: layoutScene(parseScene(scene.written ? scene.text : scene.change || ""), scene.heading, scene.number ?? index + 1),
368
+ }));
369
+ const { pages, placement } = paginateBlocks(laid);
370
+ return {
371
+ pages,
372
+ scenes: laid.map((scene) => ({ id: scene.id, number: scene.number, ...(placement.get(scene.id) ?? { page: 1, endPage: 1 }) })),
373
+ pageCount: pages.length,
374
+ };
375
+ }
@@ -0,0 +1,72 @@
1
+ // Type surface for project.js — the project record (R35).
2
+
3
+ import type { BoardNote } from "./reducer";
4
+
5
+ export declare const PROJECT_VERSION: number;
6
+ export declare const DEFAULT_PROJECT_NAME: string;
7
+
8
+ export type BoardMeta = {
9
+ id: string;
10
+ name: string;
11
+ createdAt: string;
12
+ updatedAt: string;
13
+ };
14
+
15
+ /** A project: many boards under one premise. Board states live elsewhere, one per id. */
16
+ export type ProjectRecord = {
17
+ version: number;
18
+ id: string;
19
+ name: string;
20
+ premise: string;
21
+ boards: BoardMeta[];
22
+ activeBoardId: string;
23
+ /** A writer's own structures, saved from a wall's beats (Roadmap 2, item 7). */
24
+ structures?: OwnStructure[];
25
+ createdAt: string;
26
+ updatedAt: string;
27
+ };
28
+
29
+ export type OwnStructure = { id: string; name: string; beats: Array<{ name: string; prompt: string; at: number }> };
30
+ export declare function structureBeats(
31
+ notes: ReadonlyArray<BoardNote>,
32
+ order: ReadonlyArray<string>,
33
+ ): OwnStructure["beats"];
34
+ export declare function addStructure(
35
+ project: ProjectRecord,
36
+ name: string,
37
+ beats: Array<{ name: string; prompt: string; at: number }>,
38
+ now?: string,
39
+ ): { project: ProjectRecord; structure: OwnStructure };
40
+ export declare function removeStructure(project: ProjectRecord, id: string, now?: string): ProjectRecord;
41
+
42
+ export declare function newBoardMeta(name: string, now?: string): BoardMeta;
43
+ export declare function emptyProject(now?: string): ProjectRecord;
44
+ export declare function isProjectRecord(value: unknown): value is ProjectRecord;
45
+ export declare function normalizeProject(value: unknown, now?: string): ProjectRecord;
46
+ export declare function addBoard(
47
+ project: ProjectRecord,
48
+ name: string,
49
+ now?: string,
50
+ ): { project: ProjectRecord; board: BoardMeta };
51
+ export declare function renameBoard(
52
+ project: ProjectRecord,
53
+ id: string,
54
+ name: string,
55
+ now?: string,
56
+ ): ProjectRecord;
57
+ export declare function removeBoard(project: ProjectRecord, id: string, now?: string): ProjectRecord;
58
+ export declare function moveBoard(
59
+ project: ProjectRecord,
60
+ id: string,
61
+ delta: number,
62
+ now?: string,
63
+ ): ProjectRecord;
64
+ export declare function setActiveBoard(project: ProjectRecord, id: string, now?: string): ProjectRecord;
65
+ export declare function renameProject(project: ProjectRecord, name: string, now?: string): ProjectRecord;
66
+ export declare function setPremise(project: ProjectRecord, premise: string, now?: string): ProjectRecord;
67
+ export declare function boardById(project: ProjectRecord, id: string): BoardMeta | null;
68
+ export declare function findBoard(project: ProjectRecord, key: string): BoardMeta | null;
69
+ export declare function reidentifyProject(
70
+ project: ProjectRecord,
71
+ now?: string,
72
+ ): ProjectRecord & { renamed: Record<string, string> };
@@ -0,0 +1,236 @@
1
+ // The project record (R35).
2
+ //
3
+ // A project holds many boards: a writer's several stories, or a season's
4
+ // episodes, with the premise (D17) above them. This is the record — name,
5
+ // premise, an ordered list of boards, which one is open — and the pure
6
+ // operations on it. Board states themselves are BoardState records kept
7
+ // separately, one per board id; this module never touches them.
8
+ //
9
+ // Plain ESM with a sibling .d.ts, like the kernel, so the browser store and the
10
+ // MCP server share one idea of what a project is. Keep it free of `window`.
11
+
12
+ import { newId, noteEighths, nowIso } from "./reducer.js";
13
+
14
+ export const PROJECT_VERSION = 2;
15
+ export const DEFAULT_PROJECT_NAME = "Untitled project";
16
+
17
+ function trimmed(value, fallback) {
18
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
19
+ }
20
+
21
+ export function newBoardMeta(name, now = nowIso()) {
22
+ return { id: newId(), name: trimmed(name, "Board"), createdAt: now, updatedAt: now };
23
+ }
24
+
25
+ export function emptyProject(now = nowIso()) {
26
+ const board = newBoardMeta("Board 1", now);
27
+ return {
28
+ version: PROJECT_VERSION,
29
+ id: newId(),
30
+ name: DEFAULT_PROJECT_NAME,
31
+ premise: "",
32
+ boards: [board],
33
+ activeBoardId: board.id,
34
+ createdAt: now,
35
+ updatedAt: now,
36
+ };
37
+ }
38
+
39
+ function isBoardMeta(value) {
40
+ return Boolean(value) && typeof value.id === "string" && typeof value.name === "string";
41
+ }
42
+
43
+ /** True for a record this module wrote (any version): boards and an id. */
44
+ export function isProjectRecord(value) {
45
+ return (
46
+ Boolean(value) &&
47
+ typeof value === "object" &&
48
+ typeof value.id === "string" &&
49
+ Array.isArray(value.boards)
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Repair a record to the current shape: a name, a premise, at least one board
55
+ * with an id and a name, and an open board that exists. Anything else is left
56
+ * as it was, so a field added later survives a load through an older build.
57
+ */
58
+ export function normalizeProject(value, now = nowIso()) {
59
+ if (!isProjectRecord(value)) return emptyProject(now);
60
+ const boards = value.boards.filter(isBoardMeta).map((board) => ({
61
+ ...board,
62
+ name: trimmed(board.name, "Board"),
63
+ createdAt: typeof board.createdAt === "string" ? board.createdAt : now,
64
+ updatedAt: typeof board.updatedAt === "string" ? board.updatedAt : now,
65
+ }));
66
+ if (boards.length === 0) boards.push(newBoardMeta("Board 1", now));
67
+ const activeBoardId = boards.some((board) => board.id === value.activeBoardId)
68
+ ? value.activeBoardId
69
+ : boards[0].id;
70
+ // A writer's own structures (Roadmap 2, item 7): saved from a wall's beats.
71
+ const structures = Array.isArray(value.structures)
72
+ ? value.structures.filter(
73
+ (item) => item && typeof item.id === "string" && typeof item.name === "string" && Array.isArray(item.beats),
74
+ )
75
+ : [];
76
+ // `renamed` is reidentifyProject's map for the store, never part of the record.
77
+ const { renamed: _renamed, ...rest } = value;
78
+ return {
79
+ ...rest,
80
+ version: PROJECT_VERSION,
81
+ name: trimmed(value.name, DEFAULT_PROJECT_NAME),
82
+ premise: typeof value.premise === "string" ? value.premise.trim() : "",
83
+ boards,
84
+ activeBoardId,
85
+ structures,
86
+ createdAt: typeof value.createdAt === "string" ? value.createdAt : now,
87
+ updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : now,
88
+ };
89
+ }
90
+
91
+ function touch(project, patch, now) {
92
+ return { ...project, ...patch, updatedAt: now };
93
+ }
94
+
95
+ /** Add a board after the others and open it. Returns the project and the new board. */
96
+ export function addBoard(project, name, now = nowIso()) {
97
+ const fallback = `Board ${project.boards.length + 1}`;
98
+ const board = newBoardMeta(trimmed(name, fallback), now);
99
+ return {
100
+ project: touch(project, { boards: [...project.boards, board], activeBoardId: board.id }, now),
101
+ board,
102
+ };
103
+ }
104
+
105
+ export function renameBoard(project, id, name, now = nowIso()) {
106
+ const next = trimmed(name, "");
107
+ if (!next) return project;
108
+ let changed = false;
109
+ const boards = project.boards.map((board) => {
110
+ if (board.id !== id || board.name === next) return board;
111
+ changed = true;
112
+ return { ...board, name: next, updatedAt: now };
113
+ });
114
+ return changed ? touch(project, { boards }, now) : project;
115
+ }
116
+
117
+ /**
118
+ * Remove a board. The last board cannot go — a project always has a wall. If
119
+ * the open board goes, the one before it (or the first) opens.
120
+ */
121
+ export function removeBoard(project, id, now = nowIso()) {
122
+ const index = project.boards.findIndex((board) => board.id === id);
123
+ if (index === -1 || project.boards.length <= 1) return project;
124
+ const boards = project.boards.filter((board) => board.id !== id);
125
+ const activeBoardId =
126
+ project.activeBoardId === id ? boards[Math.max(0, index - 1)].id : project.activeBoardId;
127
+ return touch(project, { boards, activeBoardId }, now);
128
+ }
129
+
130
+ /** Move a board up (-1) or down (+1) the order. Off the ends is a no-op. */
131
+ export function moveBoard(project, id, delta, now = nowIso()) {
132
+ const index = project.boards.findIndex((board) => board.id === id);
133
+ const target = index + delta;
134
+ if (index === -1 || target < 0 || target >= project.boards.length || delta === 0) return project;
135
+ const boards = [...project.boards];
136
+ const [board] = boards.splice(index, 1);
137
+ boards.splice(target, 0, board);
138
+ return touch(project, { boards }, now);
139
+ }
140
+
141
+ export function setActiveBoard(project, id, now = nowIso()) {
142
+ if (project.activeBoardId === id || !project.boards.some((board) => board.id === id)) {
143
+ return project;
144
+ }
145
+ return touch(project, { activeBoardId: id }, now);
146
+ }
147
+
148
+ export function renameProject(project, name, now = nowIso()) {
149
+ const next = trimmed(name, "");
150
+ if (!next || next === project.name) return project;
151
+ return touch(project, { name: next }, now);
152
+ }
153
+
154
+ export function setPremise(project, premise, now = nowIso()) {
155
+ const next = typeof premise === "string" ? premise.trim() : "";
156
+ if (next === project.premise) return project;
157
+ return touch(project, { premise: next }, now);
158
+ }
159
+
160
+ export function boardById(project, id) {
161
+ return project.boards.find((board) => board.id === id) ?? null;
162
+ }
163
+
164
+ /** Find a board by id, or by name (case-insensitive), or by its 1-based number. */
165
+ export function findBoard(project, key) {
166
+ const wanted = String(key ?? "").trim();
167
+ if (!wanted) return null;
168
+ const byId = boardById(project, wanted);
169
+ if (byId) return byId;
170
+ const lower = wanted.toLowerCase();
171
+ const byName = project.boards.find((board) => board.name.trim().toLowerCase() === lower);
172
+ if (byName) return byName;
173
+ const number = Number(wanted);
174
+ if (Number.isInteger(number) && number >= 1 && number <= project.boards.length) {
175
+ return project.boards[number - 1];
176
+ }
177
+ return null;
178
+ }
179
+
180
+ /**
181
+ * The same project under fresh ids — its own and every board's (R40). Used
182
+ * when this device's project is pushed to an account as a new one: the ids it
183
+ * carried may already be someone else's — every page under the dev bridge
184
+ * shares them, and a project file carries its author's — and a project's ids
185
+ * must be its own. The result carries `renamed`, old board id to new, which
186
+ * normalizeProject drops.
187
+ */
188
+ export function reidentifyProject(project, now = nowIso()) {
189
+ const ids = new Map(project.boards.map((board) => [board.id, newId()]));
190
+ return {
191
+ ...project,
192
+ id: newId(),
193
+ boards: project.boards.map((board) => ({ ...board, id: ids.get(board.id), updatedAt: now })),
194
+ activeBoardId: ids.get(project.activeBoardId) ?? project.activeBoardId,
195
+ updatedAt: now,
196
+ /** Old board id → new, so a store can move each board's state along. */
197
+ renamed: Object.fromEntries(ids),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * A structure's beats from a wall (Roadmap 2, item 7): each beat card in
203
+ * reading order, its headline as the beat's name, its change line as the
204
+ * prompt, and where it falls as a share of the wall's length. Empty when the
205
+ * wall has no beats — there is nothing to save then.
206
+ */
207
+ export function structureBeats(notes, order) {
208
+ const byId = new Map(notes.map((note) => [note.id, note]));
209
+ const ordered = order.map((id) => byId.get(id)).filter(Boolean);
210
+ const total = ordered.reduce((sum, note) => sum + noteEighths(note), 0) || 1;
211
+ let cursor = 0;
212
+ const beats = [];
213
+ for (const note of ordered) {
214
+ if (note.rank === "beat") {
215
+ beats.push({
216
+ name: note.headline || "Untitled beat",
217
+ prompt: note.change || "What turns here?",
218
+ at: Math.round((cursor / total) * 100) / 100,
219
+ });
220
+ }
221
+ cursor += noteEighths(note);
222
+ }
223
+ return beats;
224
+ }
225
+
226
+ /** Save a structure on the project: a name and beats { name, prompt, at }. */
227
+ export function addStructure(project, name, beats, now = nowIso()) {
228
+ const structure = { id: newId(), name: trimmed(name, "My structure"), beats: beats.map((beat) => ({ ...beat })) };
229
+ return { project: { ...project, structures: [...(project.structures ?? []), structure], updatedAt: now }, structure };
230
+ }
231
+
232
+ export function removeStructure(project, id, now = nowIso()) {
233
+ const structures = (project.structures ?? []).filter((item) => item.id !== id);
234
+ if (structures.length === (project.structures ?? []).length) return project;
235
+ return { ...project, structures, updatedAt: now };
236
+ }