plotcoder-board 0.1.16 → 0.1.18

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.
@@ -7,7 +7,7 @@
7
7
 
8
8
  export const AGENTS = {
9
9
  lead:
10
- "A storyline wall. Cards are scenes, beats are the big turns, arrows say what follows or pays off what. An agent driven by a person has every tool a person here has; the person directs, the agent operates. Call the tools; never fake a mouse. Already have the plotcoder-board tools in front of you? Skip the doors and go to Call these first.",
10
+ "A storyline wall. Cards are scenes, beats are the big turns, arrows say what follows or pays off what. An agent driven by a person has every tool a person here has; the person directs, the agent operates. Call the tools; never fake a mouse. Already have the plotcoder-board tools in front of you? Then your way in is: read the guide, make the four calls under Call these first, and skip the doors between — they are for wiring a server in.",
11
11
  doors: [
12
12
  {
13
13
  id: "mcp",
@@ -18,7 +18,7 @@ export const AGENTS = {
18
18
  {
19
19
  id: "shell",
20
20
  name: "The shell, for this session",
21
- text: "npx -y plotcoder-board@latest call <tool> '{json}' makes one call, with no MCP and no restart. One server per call, so undo and the project you opened do not carry between calls; npx -y plotcoder-board@latest call --batch < calls.jsonl runs a file of calls — one per line, {\"tool\": \"…\", \"arguments\": {…}} — on one server, so they do. With the account door the sign-in is kept in the folder's .plotcoder between calls (PLOTCODER_SESSION=0 to sign in every time). PLOTCODER_PROJECT names the project for each call. The JSON tail is off on this door; PLOTCODER_JSON=1 keeps it. PLOTCODER_ROOT points the server at the folder whose wall you mean; without it, the folder you run it from.",
21
+ text: "npx -y plotcoder-board@latest call <tool> '{json}' makes one call, with no MCP and no restart. One server per call, so undo and the project you opened do not carry between calls; npx -y plotcoder-board@latest call --batch < calls.jsonl runs a file of calls — one per line, {\"tool\": \"…\", \"arguments\": {…}} — on one server, so they do. With the account door the sign-in is kept in the folder's .plotcoder between calls (PLOTCODER_SESSION=0 to sign in every time). PLOTCODER_PROJECT names the project for each call. The JSON tail is off on every door; PLOTCODER_JSON=1 in the server's environment adds it. PLOTCODER_ROOT points the server at the folder whose wall you mean; without it, the folder you run it from.",
22
22
  },
23
23
  {
24
24
  id: "hosted",
@@ -42,10 +42,10 @@ export const AGENTS = {
42
42
  text: "Skip this when the account is the wall. Without an account, a wall is a folder: any folder, empty is fine — choose one that will outlive your session, never a scratch one. The app run from that folder shows the wall, and the server writes it there (PLOTCODER_ROOT, or the folder it is run from). A fresh folder holds the sample; new_board for the writer's wall, then rename_project. No app running? export_fountain is the wall in order, as text.",
43
43
  },
44
44
  ],
45
- firstNote: "Make these four before anything else; none depends on another, so any order is fine. They are about the wall you will work, so after open_project, open_board, new_project or empty_account, read_wall again. On an account with no project yet, read_wall has nothing to read and says so, and list_reminders gives the house principles every project starts with; new_project, then the four again. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
45
+ firstNote: "Make these four before anything else; none depends on another, so any order is fine. list_words and list_workflows are the app's and read no project; read_wall and list_reminders are about the wall you will work, so after open_project, open_board, new_project or empty_account, make those two again. Every reply's first line names the project it read and how many the account holds; list_projects lists them. On an account with no project yet, read_wall has nothing to read and says so, and list_reminders gives the house principles every project starts with; new_project (name, pages, and board for the first board's name), then read_wall and list_reminders again. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
46
46
  first: [
47
47
  { tool: "list_words", why: "the room's words, the app's meaning." },
48
- { tool: "read_wall", why: "the reading: the beats, the runs, the setups, and what the wall asks. The records — every card, the cast, the places — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and says so; it is not the writer's." },
48
+ { tool: "read_wall", why: "the reading: the beats, the runs, the setups, and what the wall asks. The records — every card, the cast, the places, the rows — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and the reading says so only when it is the sample; it is not the writer's." },
49
49
  { tool: "list_workflows", why: "what a writer can ask you for." },
50
50
  { tool: "list_reminders", why: "the house principles the app starts with, and the writer's own; read them before you change anything. Reminders live on the project and go with it." },
51
51
  ],
package/src/board/fdx.js CHANGED
@@ -11,9 +11,9 @@
11
11
  // tags used, so the MCP server reads the same file the browser does.
12
12
 
13
13
  import { parseScene, TRANSITION } from "./paginate.js";
14
- import { readingOrder } from "./readWall.js";
14
+ import { storyOrder } from "./readWall.js";
15
15
  import { sceneHeading, standInFor } from "./fountain.js";
16
- import { sceneNumbers } from "./numbering.js";
16
+ import { REVISION_HEX, revisionLine, revisionMarks, sceneNumbers } from "./numbering.js";
17
17
 
18
18
  function escapeXml(text) {
19
19
  return String(text)
@@ -33,14 +33,16 @@ function unescapeXml(text) {
33
33
  .replace(/&amp;/g, "&");
34
34
  }
35
35
 
36
- function paragraph(type, text, extra = "") {
37
- return ` <Paragraph Type="${type}"${extra}>\n <Text>${escapeXml(text)}</Text>\n </Paragraph>\n`;
36
+ function paragraph(type, text, extra = "", revised = false) {
37
+ // A revised paragraph's text carries the revision's id, as Final Draft marks one.
38
+ const textAttr = revised ? ' RevisionID="1"' : "";
39
+ return ` <Paragraph Type="${type}"${extra}>\n <Text${textAttr}>${escapeXml(text)}</Text>\n </Paragraph>\n`;
38
40
  }
39
41
 
40
- function speechParagraphs(speech) {
41
- let out = paragraph("Character", speech.name.toUpperCase());
42
+ function speechParagraphs(speech, revisedAt = () => false) {
43
+ let out = paragraph("Character", speech.name.toUpperCase(), "", revisedAt(speech.at));
42
44
  for (const part of speech.parts) {
43
- out += paragraph(part.kind === "parenthetical" ? "Parenthetical" : "Dialogue", part.text);
45
+ out += paragraph(part.kind === "parenthetical" ? "Parenthetical" : "Dialogue", part.text, "", revisedAt(part.at ?? speech.at));
44
46
  }
45
47
  return out;
46
48
  }
@@ -51,29 +53,34 @@ function speechParagraphs(speech) {
51
53
  * when written and the change line as action when not, and a title page.
52
54
  */
53
55
  export function toFdx(state, options = {}) {
54
- const order = readingOrder(state.notes);
56
+ const order = storyOrder(state);
55
57
  const numbers = sceneNumbers(order, state.lock);
58
+ const marks = revisionMarks(state);
56
59
  let content = "";
57
60
  order.forEach((note, index) => {
58
61
  const number = numbers.get(note.id) ?? index + 1;
59
- content += paragraph("Scene Heading", sceneHeading(note).slice(1), ` Number="${number}"`).replace(
60
- "<Text>",
61
- `<SceneProperties Length="" Page="" Title="${escapeXml(note.headline)}" />\n <Text>`,
62
+ const mark = marks.get(note.id);
63
+ // A changed card with no changed text line (its headline, change or place moved) marks its heading.
64
+ const headingRevised = Boolean(mark?.revised && mark.lines.size === 0);
65
+ const revisedAt = (at) => Boolean(mark && typeof at === "number" && mark.lines.has(at));
66
+ content += paragraph("Scene Heading", sceneHeading(note).slice(1), ` Number="${number}"`, headingRevised).replace(
67
+ /<Text( RevisionID="1")?>/,
68
+ (_m, attr) => `<SceneProperties Length="" Page="" Title="${escapeXml(note.headline)}" />\n <Text${attr ?? ""}>`,
62
69
  );
63
70
  // Unwritten: the change line stands in as action, marked as every export marks it.
64
71
  const elements = parseScene(note.text && note.text.trim() ? note.text : standInFor(note));
65
72
  for (let i = 0; i < elements.length; i += 1) {
66
73
  const element = elements[i];
67
- if (element.kind === "action") content += paragraph("Action", element.text);
68
- else if (element.kind === "transition") content += paragraph("Transition", element.text);
69
- else if (element.kind === "centered") content += paragraph("General", element.text, ' Alignment="Center"');
74
+ if (element.kind === "action") content += paragraph("Action", element.text, "", revisedAt(element.at));
75
+ else if (element.kind === "transition") content += paragraph("Transition", element.text, "", revisedAt(element.at));
76
+ else if (element.kind === "centered") content += paragraph("General", element.text, ' Alignment="Center"', revisedAt(element.at));
70
77
  else if (element.kind === "speech") {
71
78
  const next = elements[i + 1];
72
79
  if (next && next.kind === "speech" && next.dual) {
73
- content += ` <Paragraph>\n <DualDialogue>\n${speechParagraphs(element)}${speechParagraphs(next)} </DualDialogue>\n </Paragraph>\n`;
80
+ content += ` <Paragraph>\n <DualDialogue>\n${speechParagraphs(element, revisedAt)}${speechParagraphs(next, revisedAt)} </DualDialogue>\n </Paragraph>\n`;
74
81
  i += 1;
75
82
  } else {
76
- content += speechParagraphs(element);
83
+ content += speechParagraphs(element, revisedAt);
77
84
  }
78
85
  }
79
86
  }
@@ -85,12 +92,18 @@ export function toFdx(state, options = {}) {
85
92
  if (options.author) title.push(paragraph("General", `Written by ${options.author}`, ' Alignment="Center"'));
86
93
  if (options.draftDate) title.push(paragraph("General", options.draftDate.slice(0, 10)));
87
94
  title.push(paragraph("General", state.lock ? `Scene numbers locked ${String(state.lock.at).slice(0, 10)}.` : "Scene numbers follow the wall's order and are not locked."));
95
+ if (state.revision) title.push(paragraph("General", `${revisionLine(state)}; changed paragraphs are marked.`));
96
+ // The revision set Final Draft shows its marks from (round fourteen, entry 45).
97
+ const revisions = state.revision
98
+ ? ` <Revisions ActiveSet="1" Location="7.50" RevisionMode="No" RevisionsShown="Active" ShowAllMarks="No" ShowAllSets="No">\n <Revision Color="${REVISION_HEX[state.revision.color] ?? "#000000"}" FullRevision="No" ID="1" Mark="*" Name="${escapeXml(state.revision.name)}" Style="" />\n </Revisions>\n`
99
+ : "";
88
100
 
89
101
  return (
90
102
  `<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n` +
91
103
  `<FinalDraft DocumentType="Script" Template="No" Version="5">\n` +
92
104
  ` <Content>\n${content} </Content>\n` +
93
105
  ` <TitlePage>\n <Content>\n${title.join("")} </Content>\n </TitlePage>\n` +
106
+ revisions +
94
107
  `</FinalDraft>\n`
95
108
  );
96
109
  }
@@ -3,6 +3,8 @@
3
3
  import type { BoardNote, BoardState } from "./reducer";
4
4
 
5
5
  export declare function sceneHeading(note: BoardNote): string;
6
+ /** A heading split back into its place and its when. */
7
+ export declare function splitHeading(heading: string): { place: string; when: string };
6
8
  /** The mark every export sets before an unwritten scene's change line. */
7
9
  export declare const UNWRITTEN_MARK: string;
8
10
  export declare function standInFor(note: Pick<BoardNote, "change">): string;
@@ -13,17 +13,29 @@
13
13
  // except the heading and the action.
14
14
 
15
15
  import { formatPages, boardEighths } from "./reducer.js";
16
- import { readingOrder } from "./readWall.js";
16
+ import { storyOrder } from "./readWall.js";
17
+ import { revisionLine, revisionMarks } from "./numbering.js";
17
18
 
18
19
  function upper(text) {
19
20
  return text.trim().replace(/\s+/g, " ").toUpperCase();
20
21
  }
21
22
 
22
- /** A forced scene heading: the place, or the headline when the card has none. */
23
+ /**
24
+ * A forced scene heading: the place, or the headline when the card has none,
25
+ * and the card's when after a dash — THE PIER AT FENIT - NIGHT (R55).
26
+ */
23
27
  export function sceneHeading(note) {
24
28
  const place = typeof note.location === "string" ? note.location.trim() : "";
29
+ const when = typeof note.when === "string" ? note.when.trim() : "";
25
30
  const words = place || note.headline || "UNTITLED";
26
- return `.${upper(words)}`;
31
+ return `.${upper(words)}${when ? ` - ${upper(when)}` : ""}`;
32
+ }
33
+
34
+ /** A heading split back into its place and its when: "THE PIER AT FENIT - NIGHT" → both. */
35
+ export function splitHeading(heading) {
36
+ const match = /^(.*?)\s+-\s+([^-]+)$/.exec(heading ?? "");
37
+ if (!match) return { place: (heading ?? "").trim(), when: "" };
38
+ return { place: match[1].trim(), when: match[2].trim() };
27
39
  }
28
40
 
29
41
  /**
@@ -71,12 +83,14 @@ export function titlePage({ title, credit, author, draftDate, notes }) {
71
83
  */
72
84
  export function toFountain(state, options = {}) {
73
85
  const nameOf = new Map((state.characters ?? []).map((character) => [character.id, character.name]));
74
- const order = readingOrder(state.notes);
86
+ const order = storyOrder(state);
75
87
  const beats = order.filter((note) => note.rank === "beat").length;
88
+ const revisionOf = revisionMarks(state);
76
89
 
77
90
  const notes = [];
78
91
  if (options.premise) notes.push(`Premise: ${options.premise}`);
79
92
  if (state.logline) notes.push(`Logline: ${state.logline}`);
93
+ if (state.revision) notes.push(`Revision: ${revisionLine(state)}; a changed scene carries a [[changed in the revision]] note.`);
80
94
  notes.push(
81
95
  `From the wall: ${order.length} card${order.length === 1 ? "" : "s"}, ${beats} beat${beats === 1 ? "" : "s"}, about ${formatPages(boardEighths(state))} of ${formatPages(state.targetEighths)} pages.`,
82
96
  );
@@ -104,9 +118,11 @@ export function toFountain(state, options = {}) {
104
118
  body.push("");
105
119
  }
106
120
  const marks = [];
121
+ const revisionMarksFor = (_list, card) => Boolean(revisionOf.get(card.id)?.revised);
107
122
  const cast = (note.characterIds ?? []).map((id) => nameOf.get(id)).filter(Boolean);
108
123
  if (cast.length) marks.push(`with ${cast.join(", ")}`);
109
124
  if (note.plants) marks.push("plants something to pay off later");
125
+ if (revisionMarksFor(marks, note)) marks.push(`changed in the ${state.revision.color} revision`);
110
126
  if (marks.length) {
111
127
  body.push(`[[${marks.join(" · ")}]]`);
112
128
  body.push("");
@@ -212,7 +228,7 @@ function sameWords(a, b) {
212
228
  * its text. Returns commands for the kernel, so every door applies the same.
213
229
  */
214
230
  export function mergeFountain(state, parsed) {
215
- const order = readingOrder(state.notes);
231
+ const order = storyOrder(state);
216
232
  const used = new Set();
217
233
  const commands = [];
218
234
  let cursor = 0; // where in the wall's order the last match was
@@ -243,6 +259,7 @@ export function mergeFountain(state, parsed) {
243
259
  const anchor = order[cursor - 1] ?? order.at(-1);
244
260
  const headline = scene.synopsis || titleCase(scene.heading);
245
261
  const isPlace = scene.forced && Boolean(scene.synopsis);
262
+ const parts = splitHeading(scene.heading);
246
263
  const id = `scene-${Math.random().toString(36).slice(2, 8)}`;
247
264
  // A marked body is an unwritten scene: its words are the change line, not a page.
248
265
  const body = unmark(scene.text);
@@ -251,7 +268,8 @@ export function mergeFountain(state, parsed) {
251
268
  id,
252
269
  headline,
253
270
  change: body.marked ? body.text || "What changes?" : scene.text ? firstSentence(scene.text) : "What changes?",
254
- location: isPlace ? titleCase(scene.heading) : "",
271
+ location: isPlace ? titleCase(parts.place) : "",
272
+ when: isPlace ? parts.when.toLowerCase() : "",
255
273
  text: body.marked ? "" : scene.text,
256
274
  x: anchor ? anchor.x + 40 : 140,
257
275
  y: anchor ? anchor.y + 40 : 140,
@@ -12,4 +12,5 @@ export declare function toPlainText(state: BoardState, options?: TakeOptions): s
12
12
 
13
13
  export declare const GUTTER: number;
14
14
  export declare const COLUMN: Record<"character" | "more" | "parenthetical" | "dialogue", number>;
15
- export declare function setLine(line: Line): string;
15
+ /** A printed line set with spaces; `star` adds a revision's star in the right margin. */
16
+ export declare function setLine(line: Line, star?: boolean): string;
@@ -17,10 +17,10 @@
17
17
  // It pastes into anything and reads as a script wherever the font is
18
18
  // monospaced.
19
19
 
20
- import { readingOrder } from "./readWall.js";
21
- import { sceneHeading, standInFor } from "./fountain.js";
20
+ import { storyOrder } from "./readWall.js";
21
+ import { sceneHeading, standInFor, UNWRITTEN_MARK } from "./fountain.js";
22
22
  import { paginate, parseScene, WIDTH } from "./paginate.js";
23
- import { sceneNumbers } from "./numbering.js";
23
+ import { revisionLine, revisionMarks, sceneNumbers } from "./numbering.js";
24
24
 
25
25
  function upper(text) {
26
26
  return text.trim().replace(/\s+/g, " ").toUpperCase();
@@ -64,11 +64,13 @@ function sceneMarkdown(text) {
64
64
  * when the project has several boards; options.premise
65
65
  */
66
66
  export function toMarkdown(state, options = {}) {
67
- const order = readingOrder(state.notes);
67
+ const order = storyOrder(state);
68
68
  const numbers = sceneNumbers(order, state.lock);
69
+ const marks = revisionMarks(state);
69
70
  const out = [`# ${documentTitle(options)}`, ""];
70
71
  if (options.premise) out.push(`*${options.premise}*`, "");
71
72
  if (state.logline) out.push(`**${state.logline}**`, "");
73
+ if (state.revision) out.push(`*${revisionLine(state)} · a scene changed since it began has \\* after its heading*`, "");
72
74
  let beat = 0;
73
75
  for (const note of order) {
74
76
  if (note.rank === "beat") {
@@ -76,12 +78,14 @@ export function toMarkdown(state, options = {}) {
76
78
  out.push(`## ${beat}. ${note.headline || "Untitled beat"}`, "");
77
79
  }
78
80
  const heading = sceneHeading(note).slice(1);
79
- out.push(`### ${numbers.get(note.id) ?? ""} · ${heading}`.replace(/^### · /, "### "), "");
80
- if (note.headline && upper(note.headline) !== heading) out.push(`*${note.headline.trim()}*`, "");
81
+ const star = marks.get(note.id)?.revised ? " \\*" : "";
82
+ out.push(`### ${numbers.get(note.id) ?? ""} · ${heading}${star}`.replace(/^### · /, "### "), "");
83
+ // The headline as a synopsis line — not under a beat, whose heading is the headline already.
84
+ if (note.rank !== "beat" && note.headline && upper(note.headline) !== heading) out.push(`*${note.headline.trim()}*`, "");
81
85
  if (note.text && note.text.trim()) out.push(...sceneMarkdown(note.text));
82
- // Unwritten: the change line stands in, marked and in italics, so a reader
83
- // in Docs can tell the one written scene from sixteen placeholders.
84
- else out.push(`*${standInFor(note)}*`, "");
86
+ // Unwritten: the change line stands in after the mark in bold, a plain
87
+ // paragraph so it never reads as a second synopsis line (round fourteen, 30).
88
+ else out.push(`**${UNWRITTEN_MARK}**${standInFor(note).slice(UNWRITTEN_MARK.length)}`, "");
85
89
  }
86
90
  return `${out.join("\n").trimEnd()}\n`;
87
91
  }
@@ -112,8 +116,15 @@ function columnLine(line) {
112
116
  return text;
113
117
  }
114
118
 
115
- /** A printed line set with spaces. Exported for the test; the file is `toPlainText`. */
116
- export function setLine(line) {
119
+ /** A printed line set with spaces, a revision's star in the right margin when `star`. Exported for the test; the file is `toPlainText`. */
120
+ export function setLine(line, star = false) {
121
+ const set = setLineBare(line);
122
+ if (!star) return set;
123
+ if (line.kind === "heading" && line.sceneNumber !== null && line.sceneNumber !== undefined) return `${set} *`;
124
+ return `${set.padEnd(GUTTER + WIDTH.action + 1)} *`;
125
+ }
126
+
127
+ function setLineBare(line) {
117
128
  const text = line.text ?? "";
118
129
  switch (line.kind) {
119
130
  case "blank":
@@ -151,8 +162,9 @@ export function setLine(line) {
151
162
  * when the project has several boards
152
163
  */
153
164
  export function toPlainText(state, options = {}) {
154
- const order = readingOrder(state.notes);
165
+ const order = storyOrder(state);
155
166
  const numbers = sceneNumbers(order, state.lock);
167
+ const marks = revisionMarks(state);
156
168
  const result = paginate(
157
169
  order.map((note) => ({
158
170
  id: note.id,
@@ -170,10 +182,16 @@ export function toPlainText(state, options = {}) {
170
182
  } else {
171
183
  out.push(centred(upper(title)));
172
184
  }
185
+ if (state.revision) out.push("", centred(revisionLine(state).toUpperCase()));
173
186
  out.push("", "");
187
+ // Pages run on with no gap: a page turn inside a paragraph is not a blank
188
+ // line in a text file (round fourteen, entry 33).
174
189
  for (const page of result.pages) {
175
- if (page.number > 1) out.push("");
176
- for (const line of page.lines) out.push(setLine(line));
190
+ for (const line of page.lines) {
191
+ const mark = marks.get(line.noteId);
192
+ const star = Boolean(mark) && (line.kind === "heading" ? mark.revised && mark.lines.size === 0 : typeof line.src === "number" && mark.lines.has(line.src));
193
+ out.push(setLine(line, star));
194
+ }
177
195
  }
178
196
  return `${out.join("\n").replace(/\n{4,}/g, "\n\n\n").trimEnd()}\n`;
179
197
  }
@@ -1,6 +1,6 @@
1
1
  // Type surface for numbering.js — locked scene numbers and revision marks (Roadmap 2, item 8).
2
2
 
3
- import type { BoardNote } from "./reducer";
3
+ import type { BoardNote, BoardState } from "./reducer";
4
4
 
5
5
  export type Lock = { at: string; numbers: Record<string, string> };
6
6
  export type Snapshot = { headline: string; change: string; location: string; text: string };
@@ -11,3 +11,10 @@ export declare function lockFrom(order: ReadonlyArray<Pick<BoardNote, "id">>, ex
11
11
  export declare function revisedLines(text: string | undefined, snapshotText: string | null | undefined): number[];
12
12
  export declare function isRevised(note: BoardNote, snapshot: Snapshot | undefined): boolean;
13
13
  export declare const REVISION_COLORS: readonly string[];
14
+
15
+ /** The industry's revision colours as Final Draft writes them. */
16
+ export declare const REVISION_HEX: Record<string, string>;
17
+ /** Card id → whether it changed since the revision's snapshot, and which source lines of its text did. Empty when no revision. */
18
+ export declare function revisionMarks(state: BoardState): Map<string, { revised: boolean; lines: Set<number> }>;
19
+ /** "Blue revision · 2026-09-17", or "" when none. */
20
+ export declare function revisionLine(state: BoardState): string;
@@ -3,9 +3,12 @@
3
3
  // Until a draft goes out, scene numbers follow the wall's order. Lock them
4
4
  // and they stop moving: every scene keeps the number it had, a scene added
5
5
  // between 14 and 15 is 14A (then 14B), one added before the first is A1, and
6
- // moving cards never renumbers what is locked. A revision is a name and a
7
- // colour over a snapshot of the scenes; a line that differs from the snapshot
8
- // is marked, so the changed lines print in the revision's colour with a star.
6
+ // moving cards never renumbers what is locked. A scene added after the lock
7
+ // has no number of its own: its letter is its place between two locked
8
+ // numbers, so it follows the scene if the scene moves (round fourteen, 39).
9
+ // A revision is a name and a colour over a snapshot of the scenes; a line
10
+ // that differs from the snapshot is marked, so the changed lines print in the
11
+ // revision's colour with a star — on the page, in plain text, in Final Draft.
9
12
  // All pure: the kernel holds the lock and the snapshot, this module reads them.
10
13
 
11
14
  const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
@@ -87,3 +90,33 @@ export function isRevised(note, snapshot) {
87
90
  }
88
91
 
89
92
  export const REVISION_COLORS = ["white", "blue", "pink", "yellow", "green", "goldenrod", "buff", "salmon", "cherry"];
93
+
94
+ /** The industry's revision colours as Final Draft writes them. */
95
+ export const REVISION_HEX = { white: "#FFFFFF", blue: "#5B8DEF", pink: "#E879A5", yellow: "#D9B400", green: "#4CAF6A", goldenrod: "#C99A1B", buff: "#C9A978", salmon: "#E28466", cherry: "#C0392B" };
96
+
97
+ /**
98
+ * What the revision marks on each card, for every export (round fourteen,
99
+ * entry 45: the marks lived only on the page). Card id → { revised, lines }:
100
+ * `revised` when anything on the card differs from the snapshot, `lines` the
101
+ * source lines of its text that do. Empty when no revision is in progress.
102
+ */
103
+ export function revisionMarks(state) {
104
+ const marks = new Map();
105
+ const revision = state.revision;
106
+ if (!revision) return marks;
107
+ for (const note of state.notes) {
108
+ const snapshot = revision.snapshot?.[note.id];
109
+ const revised = isRevised(note, snapshot);
110
+ marks.set(note.id, { revised, lines: new Set(revised ? revisedLines(note.text, snapshot?.text ?? null) : []) });
111
+ }
112
+ return marks;
113
+ }
114
+
115
+ /** One line naming the revision for the head of a document: "Blue revision · 2026-09-17". */
116
+ export function revisionLine(state) {
117
+ const revision = state.revision;
118
+ if (!revision) return "";
119
+ const colour = `${revision.color.charAt(0).toUpperCase()}${revision.color.slice(1)}`;
120
+ const name = revision.name && revision.name.toLowerCase() !== revision.color ? ` "${revision.name}"` : "";
121
+ return `${colour} revision${name} · ${String(revision.since).slice(0, 10)}`;
122
+ }
@@ -16,7 +16,7 @@
16
16
  // Pure and DOM-free like the kernel: the app and the MCP server both call it,
17
17
  // and it returns poses for apply_poses rather than touching anything.
18
18
 
19
- import { readingOrder } from "./readWall.js";
19
+ import { readingOrder, storyOrder } from "./readWall.js";
20
20
  import { NOTE_HEIGHT, NOTE_WIDTH } from "./reducer.js";
21
21
 
22
22
  export const ROW_CARDS = 5;
@@ -36,35 +36,8 @@ const STEP_Y = NOTE_HEIGHT + GAP;
36
36
  * given (or every card) take part; arrows to cards outside are ignored.
37
37
  */
38
38
  export function arrowOrder(state, ids) {
39
- const scope = ids ? new Set(ids) : null;
40
- const notes = state.notes.filter((note) => !scope || scope.has(note.id));
41
- const reading = readingOrder(notes).map((note) => note.id);
42
- const rank = new Map(reading.map((id, index) => [id, index]));
43
- const preds = new Map(reading.map((id) => [id, []]));
44
- for (const arrow of state.arrows) {
45
- if (arrow.kind === "setup") continue;
46
- if (!rank.has(arrow.from) || !rank.has(arrow.to)) continue;
47
- preds.get(arrow.to).push(arrow.from);
48
- }
49
- for (const list of preds.values()) list.sort((a, b) => rank.get(a) - rank.get(b));
50
-
51
- const placed = new Set();
52
- const visiting = new Set();
53
- const order = [];
54
- function visit(id) {
55
- if (placed.has(id) || visiting.has(id)) return;
56
- visiting.add(id);
57
- for (const from of preds.get(id)) {
58
- // A pair pointing both ways is a tie: reading order keeps it.
59
- if (preds.get(from).includes(id)) continue;
60
- visit(from);
61
- }
62
- visiting.delete(id);
63
- placed.add(id);
64
- order.push(id);
65
- }
66
- for (const id of reading) visit(id);
67
- return order;
39
+ // One story order for the whole app: the reading's (R56).
40
+ return storyOrder(state, ids).map((note) => note.id);
68
41
  }
69
42
 
70
43
  /** Pull each group's members up to its first member, keeping their order. */
@@ -66,11 +66,13 @@ export type WallReading = {
66
66
  /** The questions the wall asks now. A left one (R53) is not here while its words hold. */
67
67
  findings: Finding[];
68
68
  /** Questions the writer has left, for now: the same question, with when it was left. */
69
- left: Array<Finding & { since: string }>;
69
+ left: Array<Finding & { since: string; why?: string }>;
70
70
  };
71
71
 
72
72
  /** Rows top to bottom, cards left to right within a row. */
73
73
  export declare function readingOrder(notes: BoardNote[]): BoardNote[];
74
+ /** Story order: reading order with each follows arrow pulling its source in front of its target (R56). */
75
+ export declare function storyOrder(state: Pick<BoardState, "notes" | "arrows">, ids?: string[]): BoardNote[];
74
76
  export declare function readWall(
75
77
  state: BoardState,
76
78
  options?: {
@@ -9,10 +9,13 @@
9
9
  // Pure and DOM-free like the reducer, so the Reminders modal, the MCP server and
10
10
  // the tests all read the same wall the same way.
11
11
  //
12
- // Order: the wall gives the order, and the wall is free (D20), so order here is
13
- // reading order — rows top to bottom, cards left to right within a row. Arrows
14
- // do not yet change the order; that is the first refinement to make once this
15
- // slice has been used.
12
+ // Order: the wall gives the order, and the wall is free (D20). Reading order is
13
+ // rows top to bottom, cards left to right within a row; story order is reading
14
+ // order with each follows arrow pulling its source in front of its target, the
15
+ // order organize lays the wall out in. Since round fourteen (entry 41) every
16
+ // reading uses story order, so a card wired between two others reads there
17
+ // before any tidy — the arrows are the writer's claim about the order, and
18
+ // where they say nothing the positions decide.
16
19
 
17
20
  import { boardEighths, EIGHTHS_PER_PAGE, formatPages, NOTE_HEIGHT, noteEighths } from "./reducer.js";
18
21
 
@@ -46,6 +49,46 @@ const FILLER = new Set([
46
49
  * Cards in reading order: banded into rows by y, then left to right. A free
47
50
  * wall has no rows, so this is the order a person's eye takes across it.
48
51
  */
52
+ /**
53
+ * Story order: reading order, with each follows arrow pulling its source in
54
+ * front of its target; a pair pointing both ways is a tie and reading order
55
+ * keeps it. The order organize lays the wall out in, and the order every
56
+ * reading, numbering and page uses (R56). Returns the notes.
57
+ */
58
+ export function storyOrder(state, ids) {
59
+ const scope = ids ? new Set(ids) : null;
60
+ const notes = state.notes.filter((note) => !scope || scope.has(note.id));
61
+ const reading = readingOrder(notes);
62
+ const byId = new Map(reading.map((note) => [note.id, note]));
63
+ const rank = new Map(reading.map((note, index) => [note.id, index]));
64
+ const preds = new Map(reading.map((note) => [note.id, []]));
65
+ for (const arrow of state.arrows ?? []) {
66
+ if (arrow.kind === "setup") continue;
67
+ if (!rank.has(arrow.from) || !rank.has(arrow.to)) continue;
68
+ preds.get(arrow.to).push(arrow.from);
69
+ }
70
+ for (const list of preds.values()) list.sort((a, b) => rank.get(a) - rank.get(b));
71
+ const placed = new Set();
72
+ const visiting = new Set();
73
+ const order = [];
74
+ function visit(id) {
75
+ if (placed.has(id) || visiting.has(id)) return;
76
+ visiting.add(id);
77
+ for (const from of preds.get(id)) {
78
+ if (preds.get(from).includes(id)) continue;
79
+ visit(from);
80
+ }
81
+ visiting.delete(id);
82
+ placed.add(id);
83
+ order.push(byId.get(id));
84
+ }
85
+ for (const note of reading) visit(note.id);
86
+ return order;
87
+ }
88
+
89
+ /** The leading "Day three, night." of a headline, the convention the guide asks for until a card has a when: not a scene's words. */
90
+ const DAY_PREFIX = /^\s*day\s+[\w-]+(?:\s*,\s*[\w\s-]+?)?\s*[.:]\s*/i;
91
+
49
92
  export function readingOrder(notes) {
50
93
  const byTop = [...notes].sort((a, b) => a.y - b.y || a.x - b.x);
51
94
  const rows = [];
@@ -98,6 +141,14 @@ function pages(eighths) {
98
141
  return formatPages(Math.round(eighths));
99
142
  }
100
143
 
144
+ /** "1 page", "3 pages", "4/8 of a page" — never "1 pages" (round thirteen, entry 15). */
145
+ function pagesWord(eighths) {
146
+ const n = pages(eighths);
147
+ if (n === "1") return "1 page";
148
+ if (/^\d+\/8$/.test(n)) return `${n} of a page`;
149
+ return `${n} pages`;
150
+ }
151
+
101
152
  function quote(note) {
102
153
  return `"${note.headline}"`;
103
154
  }
@@ -114,7 +165,7 @@ export function readWall(state, options = {}) {
114
165
  // People on a card of another board of the project (R51) are cast, and
115
166
  // are not asked about here.
116
167
  const elsewhere = new Set(Array.isArray(options.elsewhere) ? options.elsewhere : []);
117
- const order = readingOrder(state.notes);
168
+ const order = storyOrder(state);
118
169
  const beats = order.filter((note) => note.rank === "beat");
119
170
 
120
171
  // Runs: the scene pages strictly between consecutive beats, plus the opening
@@ -172,14 +223,21 @@ export function readWall(state, options = {}) {
172
223
 
173
224
  // The sag detector (R25): one run out of proportion with the others.
174
225
  const between = runs.filter((run) => run.from !== null && run.to !== null);
175
- if (between.length >= 2) {
226
+ // A run of unsized, unwritten cards is the default page each, so a wall
227
+ // where every run is defaults measures nothing but card counts; the sag
228
+ // waits until some card in some run is sized or written (round fourteen, 12).
229
+ const claimed = between.some((run) => run.ids.some((id) => {
230
+ const note = byId.get(id);
231
+ return note && (note.lengthEighths !== null || (note.text ?? "").trim());
232
+ }));
233
+ if (between.length >= 2 && claimed) {
176
234
  const typical = median(between.map((run) => run.eighths));
177
235
  const longest = between.reduce((top, run) => (run.eighths > top.eighths ? run : top));
178
236
  if (typical > 0 && longest.eighths > SAG_RATIO * typical) {
179
237
  findings.push({
180
238
  kind: "sag",
181
239
  ids: [longest.from, longest.to],
182
- text: `About ${pages(longest.eighths)} pages run between "${headline(longest.from)}" and "${headline(longest.to)}"; the median run here is about ${pages(typical)} (a beat's own pages are in no run). Is something sagging there, or is it one long set piece?`,
240
+ text: `About ${pagesWord(longest.eighths)} run between "${headline(longest.from)}" and "${headline(longest.to)}"; the median run here is about ${pagesWord(typical)} (a beat's own pages are in no run). Is something sagging there, or is it one long set piece?`,
183
241
  });
184
242
  }
185
243
  }
@@ -267,7 +325,9 @@ export function readWall(state, options = {}) {
267
325
  for (let j = i + 1; j < order.length; j += 1) {
268
326
  const a = order[i];
269
327
  const b = order[j];
270
- if (sameScene(a.headline, b.headline)) {
328
+ // A leading "Day three." is the guide's convention for when a scene
329
+ // happens, not the scene's words (round fourteen, entry 13).
330
+ if (sameScene(a.headline.replace(DAY_PREFIX, ""), b.headline.replace(DAY_PREFIX, ""))) {
271
331
  findings.push({
272
332
  kind: "duplicate",
273
333
  ids: [a.id, b.id],
@@ -387,7 +447,7 @@ export function readWall(state, options = {}) {
387
447
  (item) => item.kind === finding.kind && sameList(item.ids, finding.ids) && item.text === finding.text,
388
448
  );
389
449
  if (!entry) return true;
390
- left.push({ ...finding, since: entry.since });
450
+ left.push({ ...finding, since: entry.since, ...(entry.why ? { why: entry.why } : {}) });
391
451
  return false;
392
452
  });
393
453