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,6 @@
1
+ // Type surface for reminders.js.
2
+
3
+ import type { Reminder } from "../reminderStore";
4
+
5
+ export declare const DEFAULT_REMINDERS: Reminder[];
6
+ export declare function titleFromBody(body: string): string;
@@ -0,0 +1,53 @@
1
+ // The built-in reminders (R10) and the title rule (R11), DOM-free so the
2
+ // MCP server can list and add reminders through the bridge (R17 backlog).
3
+
4
+ export const DEFAULT_REMINDERS = [
5
+ {
6
+ id: "story-is-change",
7
+ title: "Story is change",
8
+ body: "A scene earns its place when someone wants something, meets resistance, and leaves in a different position than they entered. If nothing shifts — information, power, emotion, or the plan — the scene is usually decoration.",
9
+ builtIn: true,
10
+ createdAt: "2026-09-12T00:00:00.000Z",
11
+ },
12
+ {
13
+ id: "character-under-pressure",
14
+ title: "Character is revealed under pressure",
15
+ body: "Personality on the page is not description; it’s what a person does when the easy option is gone. Give them a clear want, a stronger need they may not admit, and a flaw that makes the want costly.",
16
+ builtIn: true,
17
+ createdAt: "2026-09-12T00:00:00.000Z",
18
+ },
19
+ {
20
+ id: "structure-is-a-map",
21
+ title: "Structure is a delivery system, not a religion",
22
+ body: "Three acts, sequences, and beats are useful because audiences need orientation: a world, a disruption, rising stakes, a point of no return, and a confrontation that answers the story’s question. Use the map; don’t let the map write the movie.",
23
+ builtIn: true,
24
+ createdAt: "2026-09-12T00:00:00.000Z",
25
+ },
26
+ {
27
+ id: "dialogue-does-two-jobs",
28
+ title: "Dialogue should do more than one job",
29
+ body: "The best lines reveal character, advance plot, and hide subtext at the same time. People rarely say exactly what they mean. Cut anything a character would not say in that moment to that person.",
30
+ builtIn: true,
31
+ createdAt: "2026-09-12T00:00:00.000Z",
32
+ },
33
+ {
34
+ id: "write-for-the-camera",
35
+ title: "Write for the camera and the cut",
36
+ body: "Prefer action, image, and behavior over explanation. White space matters. If a reader has to work to see the movie, the movie is not on the page yet.",
37
+ builtIn: true,
38
+ createdAt: "2026-09-12T00:00:00.000Z",
39
+ },
40
+ {
41
+ id: "readability-is-craft",
42
+ title: "Readability is a craft skill",
43
+ body: "Industry readers skim. Short paragraphs, precise sluglines, and verbs that carry the shot will get you further than clever formatting.",
44
+ builtIn: true,
45
+ createdAt: "2026-09-12T00:00:00.000Z",
46
+ },
47
+ ];
48
+
49
+
50
+ export function titleFromBody(body) {
51
+ const first = body.trim().split(/(?<=[.!?])\s+/)[0] ?? "";
52
+ return first.replace(/[.!?]$/, "").slice(0, 80);
53
+ }
@@ -0,0 +1,65 @@
1
+ // Type surface for sync.js — the sync decisions (R4).
2
+
3
+ import type { BoardMeta, ProjectRecord } from "./project";
4
+
5
+ export declare function conflictName(name: string, device: string, when: Date): string;
6
+
7
+ export type SignInPlan = {
8
+ /** The project this device should adopt, or null to keep its own. */
9
+ adopt: ProjectRecord | null;
10
+ /** The project record to push, or null. */
11
+ pushProject: ProjectRecord | null;
12
+ /** Board ids whose state this device should push. */
13
+ pushBoards: string[];
14
+ /** Boards carried over and renamed so it is plain where they came from. */
15
+ renamed: string[];
16
+ };
17
+
18
+ export declare function reconcileOnSignIn(input: {
19
+ local: { project: ProjectRecord };
20
+ remote: { project: ProjectRecord | null };
21
+ isSeed: boolean;
22
+ device: string;
23
+ now: Date;
24
+ }): SignInPlan;
25
+
26
+ export declare function mergeProjects(
27
+ remote: ProjectRecord,
28
+ local: ProjectRecord,
29
+ now: Date,
30
+ carry?: (board: BoardMeta) => BoardMeta,
31
+ ): ProjectRecord;
32
+
33
+ export declare function resolveBoardConflict(input: {
34
+ project: ProjectRecord;
35
+ boardId: string;
36
+ device: string;
37
+ now: Date;
38
+ }): { project: ProjectRecord; copy: BoardMeta } | null;
39
+
40
+ export declare function deviceName(userAgent: string | undefined): string;
41
+
42
+ export type PushOutcome = "insert" | "update" | "conflict";
43
+ export declare function pushOutcome(input: { remoteRev: number | null | undefined; seenRev: number }): PushOutcome;
44
+
45
+ export type OpenOutcome = "nothing" | "push" | "adopt" | "conflict";
46
+ export declare function openOutcome(input: {
47
+ remoteRev: number | null | undefined;
48
+ seenRev: number;
49
+ dirty: boolean;
50
+ }): OpenOutcome;
51
+
52
+ export declare function planSignIn(input: {
53
+ isSeed: boolean;
54
+ localProjectId: string;
55
+ remoteProjectIds: string[];
56
+ }): { pushLocalAsNew: boolean; open: string | null; pick: boolean };
57
+
58
+ export declare function liveOutcome(input: {
59
+ remoteRev: number;
60
+ seenRev: number;
61
+ dirty: boolean;
62
+ }): "nothing" | "adopt" | "conflict";
63
+
64
+ /** What a landed reset link's fragment says: nothing, or a stale link, or another error. */
65
+ export declare function linkOutcome(hash: string | null | undefined): { kind: "stale" | "error"; message: string } | null;
@@ -0,0 +1,198 @@
1
+ // Sync decisions (R4, roadmap item 2).
2
+ //
3
+ // The rules that decide what a device does when it meets the account, and
4
+ // what a push does when it finds the account ahead. Pure and DOM-free so they
5
+ // are tested like the kernel; the network is a thin layer in syncStore.ts.
6
+ //
7
+ // The one rule that matters: nothing is ever lost, and there is no merge
8
+ // dialog. When two copies disagree, the account's copy stays the board, and
9
+ // this device's copy becomes a new board of the project, named for where it
10
+ // came from and when, so the writer merges by hand with the tools they have.
11
+
12
+ /** "Episode 2 · from this laptop, 3:14 pm" */
13
+ export function conflictName(name, device, when) {
14
+ const time = when.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
15
+ return `${name} · from ${device}, ${time}`;
16
+ }
17
+
18
+ /**
19
+ * First meeting between a device and the account.
20
+ *
21
+ * - The account has nothing: push everything; this device seeds it.
22
+ * - The account has a project and this device has never synced: the account's
23
+ * project becomes this device's project. Boards on this device that the
24
+ * account does not have are carried over as new boards, renamed so it is
25
+ * plain where they came from — unless this device holds only the untouched
26
+ * seed wall, which is not work and is dropped.
27
+ *
28
+ * Returns what to adopt locally and what to push.
29
+ */
30
+ export function reconcileOnSignIn({ local, remote, isSeed, device, now }) {
31
+ if (!remote.project) {
32
+ return {
33
+ adopt: null,
34
+ pushProject: local.project,
35
+ pushBoards: local.project.boards.map((board) => board.id),
36
+ renamed: [],
37
+ };
38
+ }
39
+ const remoteIds = new Set(remote.project.boards.map((board) => board.id));
40
+ const carried = local.project.boards.filter((board) => !remoteIds.has(board.id));
41
+ if (isSeed || carried.length === 0) {
42
+ return { adopt: remote.project, pushProject: null, pushBoards: [], renamed: [] };
43
+ }
44
+ const merged = mergeProjects(remote.project, local.project, now, (board) => ({
45
+ ...board,
46
+ name: conflictName(board.name, device, now),
47
+ }));
48
+ return {
49
+ adopt: merged,
50
+ pushProject: merged,
51
+ pushBoards: carried.map((board) => board.id),
52
+ renamed: carried.map((board) => board.id),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * The account's record, plus every board this device has that the account
58
+ * does not. The account's name, premise and order win; the device's boards
59
+ * go after them, through `carry` (identity by default) so a caller can rename
60
+ * them. The open board stays this device's if the merged project has it.
61
+ */
62
+ export function mergeProjects(remote, local, now, carry = (board) => board) {
63
+ const remoteIds = new Set(remote.boards.map((board) => board.id));
64
+ const carried = local.boards
65
+ .filter((board) => !remoteIds.has(board.id))
66
+ .map((board) => ({ ...carry(board), updatedAt: now.toISOString() }));
67
+ if (carried.length === 0 && remote.boards.some((board) => board.id === local.activeBoardId) === false) {
68
+ return remote;
69
+ }
70
+ const boards = [...remote.boards, ...carried];
71
+ const activeBoardId = boards.some((board) => board.id === local.activeBoardId)
72
+ ? local.activeBoardId
73
+ : remote.activeBoardId;
74
+ if (carried.length === 0 && activeBoardId === remote.activeBoardId) return remote;
75
+ return { ...remote, boards, activeBoardId, updatedAt: now.toISOString() };
76
+ }
77
+
78
+ /**
79
+ * A push found the account ahead of what this device last saw. Keep the
80
+ * account's board; this device's version becomes a new board.
81
+ */
82
+ export function resolveBoardConflict({ project, boardId, device, now }) {
83
+ const board = project.boards.find((item) => item.id === boardId);
84
+ if (!board) return null;
85
+ const copy = {
86
+ id: `${boardId}-${Date.now().toString(36)}`,
87
+ name: conflictName(board.name, device, now),
88
+ createdAt: now.toISOString(),
89
+ updatedAt: now.toISOString(),
90
+ };
91
+ const index = project.boards.findIndex((item) => item.id === boardId);
92
+ const boards = [...project.boards];
93
+ boards.splice(index + 1, 0, copy);
94
+ return { project: { ...project, boards, updatedAt: now.toISOString() }, copy };
95
+ }
96
+
97
+ /** A short name for this device from the browser, without asking. */
98
+ export function deviceName(userAgent) {
99
+ const ua = userAgent ?? "";
100
+ if (/iPhone/.test(ua)) return "an iPhone";
101
+ if (/iPad/.test(ua)) return "an iPad";
102
+ if (/Android/.test(ua)) return "an Android phone";
103
+ if (/Mac OS X/.test(ua)) return "a Mac";
104
+ if (/Windows/.test(ua)) return "a Windows PC";
105
+ if (/Linux/.test(ua)) return "a Linux machine";
106
+ return "another device";
107
+ }
108
+
109
+ /**
110
+ * What a push should do, given what the account says about the row.
111
+ *
112
+ * - The row is missing: insert it.
113
+ * - The row is at the revision this device last saw: update it.
114
+ * - The row is ahead: conflict — the caller keeps the account's copy and
115
+ * turns this device's into a new board.
116
+ */
117
+ export function pushOutcome({ remoteRev, seenRev }) {
118
+ if (remoteRev === null || remoteRev === undefined) return "insert";
119
+ if (remoteRev === seenRev) return "update";
120
+ return "conflict";
121
+ }
122
+
123
+ /**
124
+ * What opening the app should do with a board the account holds, given what
125
+ * this device last saw of it and whether this device has changes it has not
126
+ * pushed yet.
127
+ *
128
+ * - Not ahead of what we saw: nothing to pull; push if dirty.
129
+ * - Ahead and this device is clean: adopt the account's copy.
130
+ * - Ahead and this device is dirty: conflict, resolved like a push.
131
+ */
132
+ export function openOutcome({ remoteRev, seenRev, dirty }) {
133
+ if (remoteRev === null || remoteRev === undefined) return dirty ? "push" : "nothing";
134
+ if (remoteRev <= seenRev) return dirty ? "push" : "nothing";
135
+ return dirty ? "conflict" : "adopt";
136
+ }
137
+
138
+ /**
139
+ * Meeting an account that can hold many projects (R40).
140
+ *
141
+ * - The account has none: this device's project becomes its first.
142
+ * - This device holds only the untouched seed wall: nothing to carry; open
143
+ * the account's one project, or ask which when there are several.
144
+ * - This device holds work: it becomes a new project of the account, kept
145
+ * whole, and the writer picks between it and the rest.
146
+ *
147
+ * Returns whether to push the local project as a new one, which project to
148
+ * open now (null when the writer should pick), and whether to show the picker.
149
+ */
150
+ export function planSignIn({ isSeed, localProjectId, remoteProjectIds }) {
151
+ if (remoteProjectIds.length === 0) {
152
+ return { pushLocalAsNew: true, open: localProjectId, pick: false };
153
+ }
154
+ if (isSeed) {
155
+ return remoteProjectIds.length === 1
156
+ ? { pushLocalAsNew: false, open: remoteProjectIds[0], pick: false }
157
+ : { pushLocalAsNew: false, open: null, pick: true };
158
+ }
159
+ if (remoteProjectIds.includes(localProjectId)) {
160
+ return { pushLocalAsNew: false, open: localProjectId, pick: remoteProjectIds.length > 1 };
161
+ }
162
+ return { pushLocalAsNew: true, open: localProjectId, pick: true };
163
+ }
164
+
165
+ /**
166
+ * A change to a board arrived live from another person (R41). Take it when
167
+ * it is ahead of what this device saw and this device has nothing unpushed on
168
+ * that board; otherwise let the next push settle it as a conflict.
169
+ */
170
+ export function liveOutcome({ remoteRev, seenRev, dirty }) {
171
+ if (remoteRev <= seenRev) return "nothing";
172
+ return dirty ? "conflict" : "adopt";
173
+ }
174
+
175
+ // --- The reset link (R39): what the address says when it lands ------------
176
+ //
177
+ // A reset link that still works signs the writer in; the client reads that
178
+ // from the address itself and says PASSWORD_RECOVERY. One that has expired
179
+ // or was already used comes back with the reason in the fragment instead:
180
+ // #error=access_denied&error_code=otp_expired&error_description=... . Pure,
181
+ // so it is testable; the store clears the fragment after reading it.
182
+
183
+ /**
184
+ * Read a landed link's fragment. Returns null when it says nothing, or
185
+ * { kind: "stale" | "error", message } for the writer.
186
+ */
187
+ export function linkOutcome(hash) {
188
+ const raw = (hash ?? "").replace(/^#/, "");
189
+ if (!raw) return null;
190
+ const params = new URLSearchParams(raw);
191
+ const code = params.get("error_code") ?? "";
192
+ const description = params.get("error_description") ?? "";
193
+ if (!params.get("error") && !code && !description) return null;
194
+ if (code === "otp_expired" || /expired|invalid/i.test(description)) {
195
+ return { kind: "stale", message: "That reset link has expired or was already used. Ask for another below — they last an hour and work once." };
196
+ }
197
+ return { kind: "error", message: description || "That link did not work. Ask for another below." };
198
+ }
@@ -0,0 +1,21 @@
1
+ // Type surface for templates.js — structure templates (R38).
2
+
3
+ export type TemplateBeat = {
4
+ name: string;
5
+ /** The question the beat exists to answer; goes on the change line. */
6
+ prompt: string;
7
+ /** Where it tends to fall, as a fraction of the story, 0 to 1. */
8
+ at: number;
9
+ };
10
+
11
+ export type StructureTemplate = {
12
+ id: string;
13
+ name: string;
14
+ blurb: string;
15
+ beats: TemplateBeat[];
16
+ };
17
+
18
+ export declare const TEMPLATES: readonly StructureTemplate[];
19
+ export declare function templateById(id: string): StructureTemplate | null;
20
+ /** The page a beat tends to fall near, on a story of this many eighths. */
21
+ export declare function beatPage(at: number, targetEighths: number): number;
@@ -0,0 +1,110 @@
1
+ // Structure templates (R38, closing open question 7).
2
+ //
3
+ // A template is a list of named beats, each with a prompt for its change line
4
+ // and the fraction of the story it tends to fall near. Applying one creates
5
+ // beat cards and nothing else: no mode, no lock, no field on the card that
6
+ // remembers which template it came from. The house method (R18) is the
7
+ // first and the default. Names are plain words where a book's are its own.
8
+ //
9
+ // Pure data and DOM-free, like the kernel, so the wall, window.plotcoder and
10
+ // the MCP server all read the same five.
11
+
12
+ /** A beat of a template. `at` is a fraction of the story, 0 to 1. */
13
+ function beat(name, prompt, at) {
14
+ return { name, prompt, at };
15
+ }
16
+
17
+ export const TEMPLATES = [
18
+ {
19
+ id: "turns",
20
+ name: "Turns",
21
+ blurb: "The house method: named major turns, eight to fifteen.",
22
+ beats: [
23
+ beat("Opening image", "What does the world look like before anything happens?", 0.01),
24
+ beat("The inciting incident", "What breaks the routine and cannot be ignored?", 0.1),
25
+ beat("The decision", "What do they choose to do about it, and what does it cost to choose?", 0.2),
26
+ beat("Into the middle", "What door closes behind them?", 0.25),
27
+ beat("The midpoint", "What turns here so there is no going back?", 0.5),
28
+ beat("The complication", "What makes the plan worse than no plan?", 0.62),
29
+ beat("The lowest point", "What is lost, and who is to blame?", 0.75),
30
+ beat("The climax", "What answers the central question, and how much does it cost?", 0.9),
31
+ beat("Closing image", "What does the world look like after?", 0.98),
32
+ ],
33
+ },
34
+ {
35
+ id: "three-acts",
36
+ name: "Three acts",
37
+ blurb: "The oldest map: beats as act breaks.",
38
+ beats: [
39
+ beat("Setup", "Who is this, and what do they want before the story starts?", 0.01),
40
+ beat("The inciting incident", "What breaks the routine and cannot be ignored?", 0.1),
41
+ beat("Into act two", "What choice ends the world of act one?", 0.25),
42
+ beat("The midpoint", "What turns here so there is no going back?", 0.5),
43
+ beat("Into act three", "What is the last decision that makes the ending necessary?", 0.75),
44
+ beat("The climax", "What answers the central question, and how much does it cost?", 0.9),
45
+ beat("Resolution", "What is different now, and who knows it?", 0.97),
46
+ ],
47
+ },
48
+ {
49
+ id: "eight-sequences",
50
+ name: "Eight sequences",
51
+ blurb: "One beat opening each sequence, an eighth of the story apart.",
52
+ beats: [
53
+ beat("Sequence one: the world", "What is the status quo, and what disturbs it?", 0.0),
54
+ beat("Sequence two: the problem", "What is the predicament, and what is chosen?", 0.125),
55
+ beat("Sequence three: the first try", "What is the first attempt, and why does it fail?", 0.25),
56
+ beat("Sequence four: the raised stakes", "What is tried next, and what does it cost?", 0.375),
57
+ beat("Sequence five: the reversal", "What turns at the midpoint?", 0.5),
58
+ beat("Sequence six: the collapse", "What falls apart?", 0.625),
59
+ beat("Sequence seven: the last push", "What is the final plan?", 0.75),
60
+ beat("Sequence eight: the resolution", "What is answered, and what is left?", 0.875),
61
+ ],
62
+ },
63
+ {
64
+ id: "fifteen-beats",
65
+ name: "Fifteen beats",
66
+ blurb: "The popular beat sheet, in plain words.",
67
+ beats: [
68
+ beat("Opening image", "What does the world look like before?", 0.01),
69
+ beat("The theme is said aloud", "Who says, in passing, what the story is about?", 0.05),
70
+ beat("The world before", "What is the routine, and what is wrong with it?", 0.06),
71
+ beat("The catalyst", "What arrives and changes everything?", 0.1),
72
+ beat("The debate", "Why not go? What is the fear?", 0.15),
73
+ beat("Into the new world", "What is the choice that starts act two?", 0.25),
74
+ beat("The second story", "Who arrives to carry the theme?", 0.28),
75
+ beat("The promise of the premise", "What is the fun the poster promised?", 0.35),
76
+ beat("The midpoint", "A false victory or a false defeat: which, and what is raised?", 0.5),
77
+ beat("The opposition closes in", "What goes wrong from outside, and what from inside?", 0.6),
78
+ beat("All is lost", "What dies, in fact or in feeling?", 0.75),
79
+ beat("The dark night", "What is understood in the dark?", 0.8),
80
+ beat("Into the finale", "What is the new idea that makes act three possible?", 0.85),
81
+ beat("The finale", "How is the lesson applied and the opposition beaten?", 0.9),
82
+ beat("Final image", "What does the world look like after?", 0.99),
83
+ ],
84
+ },
85
+ {
86
+ id: "story-circle",
87
+ name: "Story circle",
88
+ blurb: "The journey as a circle, in eight steps.",
89
+ beats: [
90
+ beat("Comfort", "Where are they at ease, and what is missing?", 0.0),
91
+ beat("Need", "What do they want badly enough to move?", 0.125),
92
+ beat("Crossing", "What unfamiliar place do they enter?", 0.25),
93
+ beat("Adapting", "What do they learn the hard way?", 0.375),
94
+ beat("Finding", "What do they get that they wanted?", 0.5),
95
+ beat("Paying", "What does it cost them?", 0.625),
96
+ beat("Returning", "How do they come back to where they started?", 0.75),
97
+ beat("Changed", "What is different about them now?", 0.875),
98
+ ],
99
+ },
100
+ ];
101
+
102
+ export function templateById(id) {
103
+ return TEMPLATES.find((template) => template.id === id) ?? null;
104
+ }
105
+
106
+ /** The page a beat tends to fall near, on a story of this many eighths. */
107
+ export function beatPage(at, targetEighths) {
108
+ const pages = Math.max(1, Math.round(targetEighths / 8));
109
+ return Math.min(pages, Math.max(1, Math.round(at * pages) + (at === 0 ? 1 : 0)));
110
+ }
@@ -0,0 +1,10 @@
1
+ // Type surface for words.js — what these words mean (R42).
2
+
3
+ export type WordTarget = "card" | "logline" | "beat" | "change" | "corner" | "arrow" | "length" | "cast" | "place" | "group" | "strip";
4
+ export type Word = { id: string; name: string; sentence: string; target?: WordTarget };
5
+ export type WordGroup = { id: string; name: string; words: Word[] };
6
+
7
+ export declare const WORD_GROUPS: WordGroup[];
8
+ export declare const WORDS: Word[];
9
+ export declare function wordSentence(id: string): string;
10
+ export declare function wordsAsText(): string;
@@ -0,0 +1,201 @@
1
+ // What these words mean (R42).
2
+ //
3
+ // PlotCoder talks like a story room: beat, logline, change line, the folded
4
+ // corner, eighths. This is the one place that says what each means — one
5
+ // sentence, PlotCoder's meaning, for a person who has not sat in that room.
6
+ // Grouped in the method's order (R18), which is the order a new person meets
7
+ // them. DOM-free and shared: the Words sheet, the one-second hovers on the
8
+ // wall, the skill and the MCP tool descriptions all read from here, so the
9
+ // app never explains a word two ways.
10
+ //
11
+ // `target` names the thing on the wall a definition can light up ("show me");
12
+ // words that are not things have none.
13
+
14
+ export const WORD_GROUPS = [
15
+ {
16
+ id: "first",
17
+ name: "First",
18
+ words: [
19
+ {
20
+ id: "wall",
21
+ name: "The wall",
22
+ sentence: "The corkboard. It is bigger than the window: drag to move around it, pinch or the magnifier to zoom.",
23
+ },
24
+ {
25
+ id: "card",
26
+ name: "A card",
27
+ sentence: "One scene of the movie: one place, one stretch of time. A headline on top, and under it the change line: what is different after the scene than before. A new place or a new time is a new card.",
28
+ target: "card",
29
+ },
30
+ {
31
+ id: "logline",
32
+ name: "The logline",
33
+ sentence: "The one question the whole story argues, at the top of the wall. “Can Maya forgive?”",
34
+ target: "logline",
35
+ },
36
+ {
37
+ id: "premise",
38
+ name: "The premise",
39
+ sentence: "Above the logline when a project has several boards: the line the whole series is about.",
40
+ },
41
+ {
42
+ id: "beat",
43
+ name: "A beat",
44
+ sentence:
45
+ "One of the eight to fifteen big turns — the moment it starts, the point of no return, the lowest point, the climax. A whole card, the scene where the turn happens, marked as a beat; marking it never moves it. Everything else is a scene.",
46
+ target: "beat",
47
+ },
48
+ {
49
+ id: "scene",
50
+ name: "A scene",
51
+ sentence: "Every card that is not a beat: what happens between the turns. One card, one scene.",
52
+ target: "card",
53
+ },
54
+ ],
55
+ },
56
+ {
57
+ id: "card",
58
+ name: "On a card",
59
+ words: [
60
+ {
61
+ id: "change",
62
+ name: "The change line",
63
+ sentence: "The second line. If nothing is different after the scene, the scene is usually decoration.",
64
+ target: "change",
65
+ },
66
+ {
67
+ id: "corner",
68
+ name: "The folded corner",
69
+ sentence:
70
+ "This card plants something — a gun on the wall — that must pay off later. The card says unpaid, and the bar’s Asks line keeps asking, until a setup arrow leaves it. One thing, three words: the corner is folded, the tool’s flag is plants, the wall’s question is unpaid.",
71
+ target: "corner",
72
+ },
73
+ {
74
+ id: "arrow",
75
+ name: "An arrow",
76
+ sentence: "Follows: this comes after that. Setup: this plants what that pays off. Drag a card’s handle onto another.",
77
+ target: "arrow",
78
+ },
79
+ {
80
+ id: "length",
81
+ name: "Length",
82
+ sentence:
83
+ "How long the scene runs, in eighths of a script page, the industry’s unit; the tools take pages, and a fraction is fine. Written scenes measure themselves; the rest is your guess.",
84
+ target: "length",
85
+ },
86
+ {
87
+ id: "cast",
88
+ name: "The cast on a card",
89
+ sentence: "Who is in the scene. Each person has a page: looks, voice, wants, needs.",
90
+ target: "cast",
91
+ },
92
+ {
93
+ id: "place",
94
+ name: "The place",
95
+ sentence: "Where it happens, in your own words. Hold a place and its scenes light up.",
96
+ target: "place",
97
+ },
98
+ ],
99
+ },
100
+ {
101
+ id: "around",
102
+ name: "Around the wall",
103
+ words: [
104
+ { id: "group", name: "Group", sentence: "A frame around cards you select, with a title. It moves as one. A treatment’s acts can be groups, titled Act one, Act two.", target: "group" },
105
+ {
106
+ id: "paper",
107
+ name: "Paper",
108
+ sentence: "The colour of a card. It means nothing to the app; use it as the writer does — an act, a thread, a mood.",
109
+ },
110
+ { id: "organize", name: "Organize", sentence: "Lays the cards out in rows along their arrows. One undo step." },
111
+ {
112
+ id: "structure",
113
+ name: "A structure",
114
+ sentence:
115
+ "A list of named beats laid on the wall as beat cards to fill in — Turns (the house method), Three acts, and others. Afterwards there are only cards; nothing remembers which structure it was.",
116
+ },
117
+ {
118
+ id: "acts",
119
+ name: "Acts",
120
+ sentence:
121
+ "PlotCoder has no acts of its own. The wall reads left to right, and a structure’s beats are the act breaks. A colour or a group can mark an act if the writer wants one.",
122
+ },
123
+ {
124
+ id: "wordmark",
125
+ name: "The wordmark",
126
+ sentence: "The PlotCoder mark at the top left. It is the door to you and your projects: sign in, share, switch.",
127
+ },
128
+ {
129
+ id: "lens",
130
+ name: "The Cast panel",
131
+ sentence: "Cast, at the top right: the roster, each person’s page, and the places. Hold a name or a place and its scenes light up.",
132
+ },
133
+ {
134
+ id: "strip",
135
+ name: "Read the story",
136
+ sentence: "The strip along the bottom: every card by length, the beats marked. Move along it to read the story in order.",
137
+ target: "strip",
138
+ },
139
+ {
140
+ id: "runtime",
141
+ name: "Runtime and target",
142
+ sentence: "What the cards add up to, against the length you are aiming at: 120 pages is a feature, 60 an hour, 30 a half. A page runs about a minute on screen.",
143
+ },
144
+ {
145
+ id: "reminders",
146
+ name: "Reminders",
147
+ sentence: "Your own principles, kept where you and an agent read them before touching the wall.",
148
+ },
149
+ ],
150
+ },
151
+ {
152
+ id: "pages",
153
+ name: "Pages",
154
+ words: [
155
+ {
156
+ id: "pages",
157
+ name: "Pages",
158
+ sentence: "The wall as a script: the scenes’ text in order, set as a screenplay, with page numbers. Type there and it lands on the card.",
159
+ },
160
+ {
161
+ id: "formats",
162
+ name: "Fountain and Final Draft",
163
+ sentence: "Two ways out and in. Fountain is plain text any tool reads; Final Draft is the program most productions use.",
164
+ },
165
+ {
166
+ id: "revisions",
167
+ name: "Locked numbers, revisions",
168
+ sentence: "Once a draft goes out, scene numbers stop moving and changes print in a colour; a scene added after the lock keeps its own letter, 2A after 2. For after the writing.",
169
+ },
170
+ ],
171
+ },
172
+ {
173
+ id: "horizon",
174
+ name: "The horizon",
175
+ words: [
176
+ {
177
+ id: "take",
178
+ name: "A brief, a take",
179
+ sentence:
180
+ "A brief is everything the wall knows about a scene, written for a tool that makes video. A take is what came back, filed on the card.",
181
+ },
182
+ {
183
+ id: "agent",
184
+ name: "An agent",
185
+ sentence: "A program you direct, which has every tool a person here has. It calls them; it never fakes a mouse.",
186
+ },
187
+ ],
188
+ },
189
+ ];
190
+
191
+ export const WORDS = WORD_GROUPS.flatMap((group) => group.words);
192
+
193
+ /** One word's sentence, by id; empty when there is no such word. */
194
+ export function wordSentence(id) {
195
+ return WORDS.find((word) => word.id === id)?.sentence ?? "";
196
+ }
197
+
198
+ /** The words as one block of text, for the skill and an agent's reading. */
199
+ export function wordsAsText() {
200
+ return WORD_GROUPS.map((group) => [group.name, ...group.words.map((word) => ` ${word.name}: ${word.sentence}`)].join("\n")).join("\n\n");
201
+ }