cowriter 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.
Files changed (48) hide show
  1. package/README.md +283 -0
  2. package/assets/cowriter-header.png +0 -0
  3. package/frontend/app/api/cowriter/codex/route.ts +65 -0
  4. package/frontend/app/api/cowriter/cover/route.ts +45 -0
  5. package/frontend/app/api/cowriter/events/hub.ts +24 -0
  6. package/frontend/app/api/cowriter/events/route.ts +77 -0
  7. package/frontend/app/api/cowriter/route.ts +83 -0
  8. package/frontend/app/api/cowriter/selection/route.ts +69 -0
  9. package/frontend/app/api/cowriter/selection/store.ts +27 -0
  10. package/frontend/app/globals.css +274 -0
  11. package/frontend/app/layout.tsx +14 -0
  12. package/frontend/app/page.tsx +1554 -0
  13. package/frontend/components/ui.tsx +66 -0
  14. package/frontend/lib/highlight.ts +53 -0
  15. package/frontend/lib/markdown.ts +47 -0
  16. package/frontend/lib/project.ts +335 -0
  17. package/frontend/lib/skills.ts +15 -0
  18. package/frontend/lib/turndown-plugin-gfm.d.ts +5 -0
  19. package/frontend/lib/types.ts +143 -0
  20. package/frontend/lib/utils.ts +6 -0
  21. package/frontend/lib/writing-skills.json +58 -0
  22. package/frontend/next-env.d.ts +6 -0
  23. package/frontend/next.config.js +10 -0
  24. package/frontend/package.json +44 -0
  25. package/frontend/postcss.config.mjs +7 -0
  26. package/frontend/tsconfig.json +22 -0
  27. package/package.json +62 -0
  28. package/scripts/cowriter-ai.mjs +1126 -0
  29. package/templates/init/.codex/skills/cowriter/SKILL.md +273 -0
  30. package/templates/init/.codex/skills/cowriter/references/actions.md +52 -0
  31. package/templates/init/.codex/skills/cowriter/references/character-voice.md +23 -0
  32. package/templates/init/.codex/skills/cowriter/references/context-priming.md +15 -0
  33. package/templates/init/.codex/skills/cowriter/references/continuity-review.md +22 -0
  34. package/templates/init/.codex/skills/cowriter/references/import-existing.md +16 -0
  35. package/templates/init/.codex/skills/cowriter/references/onboarding.md +45 -0
  36. package/templates/init/.codex/skills/cowriter/references/project-model.md +45 -0
  37. package/templates/init/.codex/skills/cowriter/references/prose-diagnostics.md +33 -0
  38. package/templates/init/.codex/skills/cowriter/references/prose-review.md +22 -0
  39. package/templates/init/.codex/skills/cowriter/references/scene-planning.md +28 -0
  40. package/templates/init/.codex/skills/cowriter/references/state-updates.md +22 -0
  41. package/templates/init/.codex/skills/cowriter/references/title-brainstorming.md +27 -0
  42. package/templates/init/.cowriter/project.yaml +3 -0
  43. package/templates/init/.cowriter/reports/.gitkeep +1 -0
  44. package/templates/init/AGENTS.md +79 -0
  45. package/templates/init/chapters/001-opening.md +0 -0
  46. package/templates/init/characters/primary-character.yaml +6 -0
  47. package/templates/init/outline.yaml +4 -0
  48. package/templates/init/story.yaml +8 -0
@@ -0,0 +1,1554 @@
1
+ "use client";
2
+
3
+ import { type ComponentProps, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { EditorContent, useEditor, type Editor } from "@tiptap/react";
5
+ import StarterKit from "@tiptap/starter-kit";
6
+ import Placeholder from "@tiptap/extension-placeholder";
7
+ import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
8
+ import {
9
+ BookmarkIcon,
10
+ CheckIcon,
11
+ Cross2Icon,
12
+ FileTextIcon,
13
+ ListBulletIcon,
14
+ Pencil2Icon,
15
+ PersonIcon,
16
+ QuestionMarkCircledIcon,
17
+ ReaderIcon,
18
+ } from "@radix-ui/react-icons";
19
+ import { Button, Field, Input, Textarea } from "@/components/ui";
20
+ import { findNormalizedTextMatch } from "@/lib/highlight";
21
+ import { htmlToMarkdown, markdownToHtml, plainTextFromMarkdown } from "@/lib/markdown";
22
+ import {
23
+ readActiveProject,
24
+ readReport,
25
+ writeChapter,
26
+ writeCharacters,
27
+ writeOutline,
28
+ writeStory,
29
+ } from "@/lib/project";
30
+ import type { CharacterRecord, CowriterProject, CowriterReport, OutlineBeat, StoryBible } from "@/lib/types";
31
+ import { cn } from "@/lib/utils";
32
+
33
+ type StoryMaterialTab = "story" | "characters" | "outline" | "reports";
34
+ type CenterView = "front-matter" | "manuscript" | StoryMaterialTab;
35
+ type CodexHighlightCommand = {
36
+ command: "highlight";
37
+ text: string;
38
+ chapterPath?: string;
39
+ occurrence?: number;
40
+ };
41
+ type PageMetrics = {
42
+ page: HTMLDivElement;
43
+ pageAdvance: number;
44
+ totalPages: number;
45
+ };
46
+ type EditorTextRange = {
47
+ from: number;
48
+ to: number;
49
+ };
50
+ type SelectionContextSnapshot = {
51
+ source: "manuscript";
52
+ selectedText: string;
53
+ chapterId: string;
54
+ chapterPath: string;
55
+ chapterTitle: string;
56
+ range: EditorTextRange;
57
+ activePage: number;
58
+ surroundingText: string;
59
+ };
60
+ type CssHighlightRegistry = {
61
+ set(name: string, highlight: unknown): void;
62
+ delete(name: string): boolean;
63
+ };
64
+ type HighlightConstructor = new (...ranges: Range[]) => unknown;
65
+ type PromptHelpSection = {
66
+ category: string;
67
+ title: string;
68
+ prompt: string;
69
+ beforeLabel?: string;
70
+ before?: string;
71
+ afterLabel?: string;
72
+ after?: string;
73
+ };
74
+
75
+ const selectionContextRadius = 600;
76
+
77
+ const storyMaterialTabs: { id: StoryMaterialTab; label: string; eyebrow: string }[] = [
78
+ { id: "story", label: "Synopsis & continuity", eyebrow: "Story bible" },
79
+ { id: "characters", label: "Characters", eyebrow: "Cast" },
80
+ { id: "outline", label: "Outline", eyebrow: "Structure" },
81
+ { id: "reports", label: "Reports", eyebrow: "Reviews" },
82
+ ];
83
+
84
+ const storyPlaceholders = {
85
+ title: "The title you would put on the cover",
86
+ synopsis: "A private investigator returns to a flooded coastal town after her sister leaves behind a ledger no one else can read.",
87
+ setting: "Place, era, social pressure, rules of the world, and what feels different here.",
88
+ themes: "The questions the story keeps testing, such as loyalty, inheritance, grief, ambition, or faith.",
89
+ genre: "Literary mystery",
90
+ tone: "Spare, tense, darkly funny",
91
+ perspective: "Close third person",
92
+ continuity: "Facts that must stay true: dates, injuries, promises, secrets, names, locations.",
93
+ } satisfies Record<keyof StoryBible, string>;
94
+
95
+ const characterPlaceholders = {
96
+ name: "Iris Calder",
97
+ role: "Protagonist, rival, witness, mentor",
98
+ desire: "What this person wants badly enough to make a mistake.",
99
+ conflict: "The pressure, flaw, or opposing want that keeps them from getting it.",
100
+ notes: "Voice, habits, secrets, contradictions, and details Codex should remember.",
101
+ } satisfies Omit<CharacterRecord, "id">;
102
+
103
+ const outlinePlaceholders = {
104
+ title: "The first irreversible choice",
105
+ summary: "What changes in this beat, what the reader learns, and why the next scene becomes necessary.",
106
+ } satisfies Omit<OutlineBeat, "id">;
107
+
108
+ const promptHelpSections: PromptHelpSection[] = [
109
+ {
110
+ category: "Project",
111
+ title: "Shape a story bible",
112
+ prompt: "$cowriter I have a synopsis for this book. Help me formulate the story bible: A retired diver returns to a sinking town after her brother leaves her a coded salvage map.",
113
+ },
114
+ {
115
+ category: "Project",
116
+ title: "Inspect the current book",
117
+ prompt: "$cowriter inspect this book and tell me the next useful writing task",
118
+ },
119
+ {
120
+ category: "Manuscript",
121
+ title: "Draft the next page",
122
+ prompt: "$cowriter draft the next manuscript page from the active outline beat and current story materials",
123
+ beforeLabel: "Before",
124
+ before: "Outline beat: Iris reaches the drowned courthouse and finds the clerk's window lit.",
125
+ afterLabel: "After",
126
+ after: "Rain thinned as Iris crossed the courthouse lawn, though the water there still took her boots to the ankle. Every window was black except the clerk's, where one yellow square trembled on the floodwater like a warning she had already failed to heed.",
127
+ },
128
+ {
129
+ category: "Manuscript",
130
+ title: "Rewrite selected prose",
131
+ prompt: "$cowriter rewrite the selected passage with stronger rhythm while preserving point of view",
132
+ beforeLabel: "Before",
133
+ before: "Mara walked into the archive. It was cold and quiet. She felt nervous because the ledger might be there.",
134
+ afterLabel: "After",
135
+ after: "Mara stepped into the archive and let the door sigh shut behind her. Cold gathered under the shelves. Somewhere in that hush, if Elias had not lied, the ledger was waiting.",
136
+ },
137
+ {
138
+ category: "Manuscript",
139
+ title: "Expand selected prose",
140
+ prompt: "$cowriter expand the selected passage with more concrete detail and interiority",
141
+ beforeLabel: "Before",
142
+ before: "The ferry left at dawn. Jonah watched the island get smaller.",
143
+ afterLabel: "After",
144
+ after: "The ferry pulled away at dawn, its wake folding the harbor into strips of pewter. Jonah kept his hands on the rail until the salt worked into his cuts. The island shrank behind him, but the chapel bell still seemed to ring inside his teeth.",
145
+ },
146
+ {
147
+ category: "Manuscript",
148
+ title: "Polish dialogue",
149
+ prompt: "$cowriter polish the selected dialogue so each speaker has clearer subtext",
150
+ beforeLabel: "Before",
151
+ before: "\"Did you take the key?\" Lina asked.\n\"No,\" Orrin said. \"Why would I?\"",
152
+ afterLabel: "After",
153
+ after: "\"You were alone with the cabinet,\" Lina said.\nOrrin kept his thumb over the black half-moon of grease on the key ring. \"I was alone with a lot of things. Try being specific.\"",
154
+ },
155
+ {
156
+ category: "Manuscript",
157
+ title: "Match the book's tone",
158
+ prompt: "$cowriter match the selected passage to the book's spare, tense tone",
159
+ beforeLabel: "Before",
160
+ before: "The hallway was incredibly scary, and Vivienne could not believe how worried she was about going farther.",
161
+ afterLabel: "After",
162
+ after: "The hallway narrowed after the third sconce. Vivienne stopped there, listening to the old house settle around her name.",
163
+ },
164
+ {
165
+ category: "Story bible",
166
+ title: "Brainstorm titles",
167
+ prompt: "$cowriter brainstorm titles from the synopsis, genre, tone, and themes without changing story.yaml yet",
168
+ },
169
+ {
170
+ category: "Story bible",
171
+ title: "Expand the synopsis",
172
+ prompt: "$cowriter expand this rough synopsis into a clearer dramatic arc and save it to story.yaml",
173
+ beforeLabel: "Before",
174
+ before: "A cartographer finds a map of a city that should not exist and follows it.",
175
+ afterLabel: "After",
176
+ after: "A disgraced cartographer discovers a hand-drawn map that predicts changes in her city before they happen. To clear her father's name, she follows the map into sealed districts, learns who is redrawing the streets, and must decide whether restoring the old city is worth erasing the people who survived its collapse.",
177
+ },
178
+ {
179
+ category: "Story bible",
180
+ title: "Develop a character",
181
+ prompt: "$cowriter develop Iris Calder's role, desire, conflict, and notes from the current synopsis",
182
+ },
183
+ {
184
+ category: "Story bible",
185
+ title: "Plan a scene beat",
186
+ prompt: "$cowriter plan the next scene beat with a goal, obstacle, reversal, and exit image",
187
+ },
188
+ {
189
+ category: "Continuity",
190
+ title: "Review continuity",
191
+ prompt: "$cowriter review continuity for the current chapter and record confirmed facts after I approve them",
192
+ },
193
+ {
194
+ category: "Continuity",
195
+ title: "Save stable story facts",
196
+ prompt: "$cowriter extract stable facts from the approved scene and update continuity, character notes, or outline beats",
197
+ },
198
+ {
199
+ category: "Review",
200
+ title: "Run a prose review",
201
+ prompt: "$cowriter run a prose review for chapters/001-opening.md and save a report if the findings are useful",
202
+ },
203
+ {
204
+ category: "Import",
205
+ title: "Import an existing manuscript",
206
+ prompt: "$cowriter help import this existing draft into Cowriter chapters and story materials",
207
+ },
208
+ {
209
+ category: "Reports",
210
+ title: "Use saved review reports",
211
+ prompt: "$cowriter read the latest prose report and turn the top three findings into a revision plan",
212
+ },
213
+ ];
214
+
215
+ const materialTextareaClass = "min-h-36 text-[13px] leading-5 md:text-sm md:leading-6";
216
+ const longMaterialTextareaClass = "min-h-64 text-[13px] leading-5 md:min-h-72 md:text-sm md:leading-6";
217
+
218
+ function normalizedMarkdown(markdown: string) {
219
+ return markdown.trim();
220
+ }
221
+
222
+ function stripReportFrontmatter(markdown: string) {
223
+ return markdown.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, "").trim();
224
+ }
225
+
226
+ function formatReportDate(value: string) {
227
+ const date = new Date(value);
228
+ if (Number.isNaN(date.getTime())) return value;
229
+ return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }).format(date);
230
+ }
231
+
232
+ function storyMaterialEyebrow(tab: StoryMaterialTab, reportCount: number) {
233
+ if (tab === "reports") return `${reportCount} ${reportCount === 1 ? "report" : "reports"}`;
234
+ return storyMaterialTabs.find((item) => item.id === tab)?.eyebrow ?? "";
235
+ }
236
+
237
+ function editorTextIndex(editor: Editor) {
238
+ const text: string[] = [];
239
+ const positions: Array<number | null> = [];
240
+
241
+ editor.state.doc.descendants((node, pos) => {
242
+ if (node.isText && node.text) {
243
+ for (let index = 0; index < node.text.length; index += 1) {
244
+ text.push(node.text[index]);
245
+ positions.push(pos + index);
246
+ }
247
+ return;
248
+ }
249
+
250
+ if (node.isBlock && text.length > 0 && text[text.length - 1] !== "\n") {
251
+ text.push("\n");
252
+ positions.push(null);
253
+ }
254
+ });
255
+
256
+ return { text: text.join(""), positions };
257
+ }
258
+
259
+ function findEditorTextRange(editor: Editor, command: CodexHighlightCommand): EditorTextRange | null {
260
+ const index = editorTextIndex(editor);
261
+ const match = findNormalizedTextMatch(index.text, command.text, command.occurrence ?? 1);
262
+ if (!match) return null;
263
+
264
+ let fromIndex = match.start;
265
+ while (fromIndex < match.end && index.positions[fromIndex] === null) fromIndex += 1;
266
+ let toIndex = match.end - 1;
267
+ while (toIndex >= fromIndex && index.positions[toIndex] === null) toIndex -= 1;
268
+
269
+ const from = index.positions[fromIndex];
270
+ const to = index.positions[toIndex];
271
+ return typeof from === "number" && typeof to === "number" ? { from, to: to + 1 } : null;
272
+ }
273
+
274
+ function selectionContextForEditor(editor: Editor, chapter: CowriterProject["chapters"][number], activePage: number): SelectionContextSnapshot | null {
275
+ const { from, to, empty } = editor.state.selection;
276
+ if (empty) return null;
277
+
278
+ const selectedText = editor.state.doc.textBetween(from, to, "\n");
279
+ if (!selectedText.trim()) return null;
280
+
281
+ const contextFrom = Math.max(0, from - selectionContextRadius);
282
+ const contextTo = Math.min(editor.state.doc.content.size, to + selectionContextRadius);
283
+
284
+ return {
285
+ source: "manuscript",
286
+ selectedText,
287
+ chapterId: chapter.id,
288
+ chapterPath: chapter.path,
289
+ chapterTitle: chapter.title,
290
+ range: { from, to },
291
+ activePage,
292
+ surroundingText: editor.state.doc.textBetween(contextFrom, contextTo, "\n"),
293
+ };
294
+ }
295
+
296
+ function cssHighlights() {
297
+ if (typeof CSS === "undefined") return null;
298
+ return (CSS as typeof CSS & { highlights?: CssHighlightRegistry }).highlights ?? null;
299
+ }
300
+
301
+ function clearCodexHighlight() {
302
+ cssHighlights()?.delete("cowriter-codex-highlight");
303
+ }
304
+
305
+ function ensureCodexHighlightStyle() {
306
+ if (document.getElementById("cowriter-codex-highlight-style")) return;
307
+ const style = document.createElement("style");
308
+ style.id = "cowriter-codex-highlight-style";
309
+ style.textContent = "::highlight(cowriter-codex-highlight) { background: rgba(180, 83, 9, 0.28); }";
310
+ document.head.appendChild(style);
311
+ }
312
+
313
+ function showCodexHighlight(editor: Editor, range: EditorTextRange) {
314
+ const highlights = cssHighlights();
315
+ const HighlightClass = (window as Window & { Highlight?: HighlightConstructor }).Highlight;
316
+ if (!highlights || !HighlightClass) return false;
317
+
318
+ try {
319
+ ensureCodexHighlightStyle();
320
+ const from = editor.view.domAtPos(range.from);
321
+ const to = editor.view.domAtPos(range.to);
322
+ const domRange = document.createRange();
323
+ domRange.setStart(from.node, from.offset);
324
+ domRange.setEnd(to.node, to.offset);
325
+ highlights.set("cowriter-codex-highlight", new HighlightClass(domRange));
326
+ return true;
327
+ } catch {
328
+ return false;
329
+ }
330
+ }
331
+
332
+ function measuredPageIndexForPosition(editor: Editor, metrics: PageMetrics, position: number) {
333
+ const editorElement = editor.view.dom as HTMLElement;
334
+ const previousTransform = editorElement.style.transform;
335
+ const previousTransition = editorElement.style.transition;
336
+ editorElement.style.transition = "none";
337
+ editorElement.style.transform = "translateX(0px)";
338
+
339
+ try {
340
+ const coords = editor.view.coordsAtPos(position);
341
+ const editorRect = editorElement.getBoundingClientRect();
342
+ const pageIndex = Math.floor(Math.max(0, coords.left - editorRect.left) / metrics.pageAdvance);
343
+ return Math.min(metrics.totalPages - 1, Math.max(0, pageIndex));
344
+ } finally {
345
+ editorElement.style.transform = previousTransform;
346
+ editorElement.style.transition = previousTransition;
347
+ }
348
+ }
349
+
350
+ export default function Home() {
351
+ const [project, setProject] = useState<CowriterProject | null>(null);
352
+ const [activePage, setActivePage] = useState(0);
353
+ const [activeStoryTab, setActiveStoryTab] = useState<StoryMaterialTab>("story");
354
+ const [centerView, setCenterView] = useState<CenterView>("manuscript");
355
+ const [activeReportPath, setActiveReportPath] = useState<string | null>(null);
356
+ const [reportMarkdown, setReportMarkdown] = useState("");
357
+ const [reportStatus, setReportStatus] = useState("Select a report");
358
+ const [promptHelpOpen, setPromptHelpOpen] = useState(false);
359
+ const [status, setStatus] = useState("Ready");
360
+ const [pageCount, setPageCount] = useState(1);
361
+ const bookPageRef = useRef<HTMLDivElement | null>(null);
362
+ const paginationFrameRef = useRef<number | null>(null);
363
+ const syncedEditorChapterId = useRef<string | null>(null);
364
+ const pendingHighlightRef = useRef<CodexHighlightCommand | null>(null);
365
+ const highlightHandlerRef = useRef<(command: CodexHighlightCommand) => void>(() => {});
366
+ const selectionFrameRef = useRef<number | null>(null);
367
+ const lastSelectionSnapshotRef = useRef("");
368
+ const activePageRef = useRef(activePage);
369
+ const centerViewRef = useRef(centerView);
370
+ const firstChapterIdRef = useRef<string | null>(null);
371
+ const activeChapterIndexRef = useRef(-1);
372
+ const hasFrontMatterRef = useRef(false);
373
+ const goToPageRef = useRef<(pageIndex: number) => void>(() => {});
374
+
375
+ const activeChapter = project?.chapters.find((chapter) => chapter.id === project.activeChapterId) ?? null;
376
+ const activeChapterIndex = project && activeChapter ? project.chapters.findIndex((chapter) => chapter.id === activeChapter.id) : -1;
377
+ const hasFrontMatter = Boolean(project?.cover);
378
+ const isFrontMatterVisible = centerView === "front-matter";
379
+ const isWritingView = centerView === "front-matter" || centerView === "manuscript";
380
+ const isStoryMaterialView = !isWritingView;
381
+ const chapterWordCount = useMemo(() => plainTextFromMarkdown(activeChapter?.markdown ?? "").split(/\s+/).filter(Boolean).length, [activeChapter?.markdown]);
382
+ const activeMaterial = storyMaterialTabs.find((tab) => tab.id === activeStoryTab) ?? storyMaterialTabs[0];
383
+ const activeMaterialEyebrow = project ? storyMaterialEyebrow(activeMaterial.id, project.reports.length) : activeMaterial.eyebrow;
384
+ const activeReport = project?.reports.find((report) => report.path === activeReportPath) ?? null;
385
+ const refreshProject = useCallback(async (statusMessage?: string) => {
386
+ const stored = await readActiveProject();
387
+ if (!stored) return;
388
+ setProject((current) => {
389
+ if (!current && stored.cover) setCenterView("front-matter");
390
+ return stored;
391
+ });
392
+ if (statusMessage) setStatus(statusMessage);
393
+ }, []);
394
+
395
+ const getPageMetrics = useCallback((): PageMetrics | null => {
396
+ const page = bookPageRef.current;
397
+ const clip = page?.querySelector<HTMLElement>(".book-page-clip");
398
+ const editorElement = page?.querySelector<HTMLElement>(".ProseMirror");
399
+ if (!page || !clip || !editorElement) return null;
400
+
401
+ const pageWidth = Math.max(1, clip.clientWidth);
402
+ page.style.setProperty("--book-content-width", `${pageWidth}px`);
403
+
404
+ const columnGap = Number.parseFloat(window.getComputedStyle(editorElement).columnGap) || 0;
405
+ const pageAdvance = pageWidth + columnGap;
406
+ const totalPages = Math.max(1, Math.ceil((editorElement.scrollWidth + columnGap) / pageAdvance));
407
+ return { page, pageAdvance, totalPages };
408
+ }, []);
409
+
410
+ const syncPagination = useCallback(() => {
411
+ if (isFrontMatterVisible) return;
412
+
413
+ const metrics = getPageMetrics();
414
+ if (!metrics) {
415
+ setActivePage(0);
416
+ setPageCount(1);
417
+ return;
418
+ }
419
+
420
+ const currentPage = Math.min(metrics.totalPages - 1, Math.max(0, activePage));
421
+ metrics.page.style.setProperty("--manuscript-page-offset", `${currentPage * metrics.pageAdvance}px`);
422
+
423
+ setPageCount((current) => (current === metrics.totalPages ? current : metrics.totalPages));
424
+ setActivePage((current) => (current === currentPage ? current : currentPage));
425
+ }, [activePage, getPageMetrics, isFrontMatterVisible]);
426
+
427
+ const schedulePaginationSync = useCallback(() => {
428
+ if (typeof window === "undefined") return;
429
+ if (paginationFrameRef.current !== null) window.cancelAnimationFrame(paginationFrameRef.current);
430
+ paginationFrameRef.current = window.requestAnimationFrame(() => {
431
+ paginationFrameRef.current = null;
432
+ syncPagination();
433
+ });
434
+ }, [syncPagination]);
435
+
436
+ const goToPage = useCallback((pageIndex: number) => {
437
+ const metrics = getPageMetrics();
438
+ const maxPage = metrics ? metrics.totalPages - 1 : pageCount - 1;
439
+ const nextPage = Math.min(maxPage, Math.max(0, pageIndex));
440
+
441
+ setActivePage(nextPage);
442
+ if (metrics) {
443
+ metrics.page.style.setProperty("--manuscript-page-offset", `${nextPage * metrics.pageAdvance}px`);
444
+ }
445
+ }, [getPageMetrics, pageCount]);
446
+
447
+ useEffect(() => {
448
+ activePageRef.current = activePage;
449
+ }, [activePage]);
450
+
451
+ useEffect(() => {
452
+ centerViewRef.current = centerView;
453
+ }, [centerView]);
454
+
455
+ useEffect(() => {
456
+ firstChapterIdRef.current = project?.chapters[0]?.id ?? null;
457
+ activeChapterIndexRef.current = activeChapterIndex;
458
+ hasFrontMatterRef.current = hasFrontMatter;
459
+ }, [activeChapterIndex, hasFrontMatter, project?.chapters]);
460
+
461
+ useEffect(() => {
462
+ goToPageRef.current = goToPage;
463
+ }, [goToPage]);
464
+
465
+ const editor = useEditor({
466
+ immediatelyRender: false,
467
+ extensions: [
468
+ StarterKit,
469
+ Table.configure({ resizable: false }),
470
+ TableRow,
471
+ TableHeader,
472
+ TableCell,
473
+ Placeholder.configure({ placeholder: "Start the page here." }),
474
+ ],
475
+ content: markdownToHtml(activeChapter?.markdown ?? ""),
476
+ editorProps: {
477
+ attributes: {
478
+ class: "prose prose-zinc max-w-none min-h-full focus:outline-none font-book text-zinc-900",
479
+ },
480
+ },
481
+ onUpdate: ({ editor }) => {
482
+ if (!project || !activeChapter) return;
483
+ const nextChapter = { ...activeChapter, markdown: htmlToMarkdown(editor.getHTML()), updatedAt: new Date().toISOString() };
484
+ setProject({
485
+ ...project,
486
+ chapters: project.chapters.map((chapter) => (chapter.id === activeChapter.id ? nextChapter : chapter)),
487
+ git: { ...project.git, dirty: true, changedPaths: Array.from(new Set([...project.git.changedPaths, activeChapter.path])) },
488
+ });
489
+ schedulePaginationSync();
490
+ },
491
+ });
492
+
493
+ const publishSelectionContext = useCallback((selection: SelectionContextSnapshot | null) => {
494
+ if (typeof window === "undefined") return;
495
+ const body = JSON.stringify({ selection });
496
+ if (lastSelectionSnapshotRef.current === body) return;
497
+ lastSelectionSnapshotRef.current = body;
498
+
499
+ void fetch("/api/cowriter/selection", {
500
+ method: "POST",
501
+ headers: { "content-type": "application/json" },
502
+ body,
503
+ }).catch(() => {});
504
+ }, []);
505
+
506
+ const syncSelectionContext = useCallback((selectionEditor: Editor | null = editor) => {
507
+ if (centerView !== "manuscript" || !selectionEditor || !activeChapter) {
508
+ publishSelectionContext(null);
509
+ return;
510
+ }
511
+
512
+ publishSelectionContext(selectionContextForEditor(selectionEditor, activeChapter, activePage));
513
+ }, [activeChapter, activePage, centerView, editor, publishSelectionContext]);
514
+
515
+ const scheduleSelectionContextSync = useCallback((selectionEditor: Editor | null = editor) => {
516
+ if (typeof window === "undefined") return;
517
+ if (selectionFrameRef.current !== null) window.cancelAnimationFrame(selectionFrameRef.current);
518
+ selectionFrameRef.current = window.requestAnimationFrame(() => {
519
+ selectionFrameRef.current = null;
520
+ syncSelectionContext(selectionEditor);
521
+ });
522
+ }, [editor, syncSelectionContext]);
523
+
524
+ const applyHighlightCommand = useCallback((command: CodexHighlightCommand) => {
525
+ if (!project || !editor) {
526
+ pendingHighlightRef.current = command;
527
+ return;
528
+ }
529
+
530
+ const requestedChapter = command.chapterPath
531
+ ? project.chapters.find((chapter) => chapter.path === command.chapterPath)
532
+ : activeChapter;
533
+ if (!requestedChapter) {
534
+ clearCodexHighlight();
535
+ setStatus(`Could not find ${command.chapterPath ?? "the active chapter"} for Codex highlight`);
536
+ return;
537
+ }
538
+
539
+ if (centerView !== "manuscript") {
540
+ pendingHighlightRef.current = command;
541
+ setCenterView("manuscript");
542
+ return;
543
+ }
544
+
545
+ if (requestedChapter.id !== project.activeChapterId) {
546
+ pendingHighlightRef.current = command;
547
+ setProject({ ...project, activeChapterId: requestedChapter.id });
548
+ setCenterView("manuscript");
549
+ return;
550
+ }
551
+
552
+ const range = findEditorTextRange(editor, command);
553
+ if (!range) {
554
+ clearCodexHighlight();
555
+ setStatus("Could not find the passage Codex asked to highlight");
556
+ return;
557
+ }
558
+
559
+ const metrics = getPageMetrics();
560
+ if (metrics) {
561
+ const pageIndex = measuredPageIndexForPosition(editor, metrics, range.from);
562
+ goToPage(pageIndex);
563
+ }
564
+
565
+ editor.chain().focus().setTextSelection(range).run();
566
+ showCodexHighlight(editor, range);
567
+ setStatus("Highlighted Codex passage");
568
+ }, [activeChapter, centerView, editor, getPageMetrics, goToPage, project]);
569
+
570
+ useEffect(() => {
571
+ highlightHandlerRef.current = applyHighlightCommand;
572
+ }, [applyHighlightCommand]);
573
+
574
+ useEffect(() => {
575
+ void refreshProject("Book folder loaded");
576
+ }, [refreshProject]);
577
+
578
+ useEffect(() => {
579
+ if (!project) return;
580
+ if (!project.reports.length) {
581
+ setActiveReportPath(null);
582
+ setReportMarkdown("");
583
+ setReportStatus("No reports");
584
+ return;
585
+ }
586
+ if (!activeReportPath || !project.reports.some((report) => report.path === activeReportPath)) {
587
+ setActiveReportPath(project.reports[0].path);
588
+ }
589
+ }, [activeReportPath, project]);
590
+
591
+ useEffect(() => {
592
+ if (activeStoryTab !== "reports" || !activeReportPath) return;
593
+ let cancelled = false;
594
+ setReportStatus("Loading report");
595
+ readReport(activeReportPath)
596
+ .then((report) => {
597
+ if (cancelled) return;
598
+ setReportMarkdown(report.markdown);
599
+ setReportStatus("Report loaded");
600
+ })
601
+ .catch((error) => {
602
+ if (cancelled) return;
603
+ setReportMarkdown("");
604
+ setReportStatus(error instanceof Error ? error.message : "Unable to load report");
605
+ });
606
+ return () => {
607
+ cancelled = true;
608
+ };
609
+ }, [activeReportPath, activeStoryTab]);
610
+
611
+ useEffect(() => {
612
+ if (!editor) return;
613
+ const handleSelectionUpdate = () => scheduleSelectionContextSync(editor);
614
+ editor.on("selectionUpdate", handleSelectionUpdate);
615
+ return () => {
616
+ editor.off("selectionUpdate", handleSelectionUpdate);
617
+ };
618
+ }, [editor, scheduleSelectionContextSync]);
619
+
620
+ useEffect(() => {
621
+ scheduleSelectionContextSync(editor);
622
+ }, [activeChapter?.id, activePage, centerView, editor, scheduleSelectionContextSync]);
623
+
624
+ useEffect(() => {
625
+ return () => {
626
+ if (selectionFrameRef.current !== null) {
627
+ window.cancelAnimationFrame(selectionFrameRef.current);
628
+ selectionFrameRef.current = null;
629
+ }
630
+ };
631
+ }, []);
632
+
633
+ useEffect(() => {
634
+ if (typeof EventSource === "undefined") return;
635
+ const events = new EventSource("/api/cowriter/events");
636
+ events.addEventListener("message", (event) => {
637
+ try {
638
+ const payload = JSON.parse(event.data);
639
+ if (payload.type === "change") void refreshProject(`Reloaded ${payload.path ?? "project files"}`);
640
+ if (payload.type === "codex-command" && payload.command?.command === "highlight") {
641
+ highlightHandlerRef.current(payload.command);
642
+ }
643
+ } catch {}
644
+ });
645
+
646
+ const poll = window.setInterval(() => void refreshProject(), 5000);
647
+
648
+ return () => {
649
+ events.close();
650
+ window.clearInterval(poll);
651
+ };
652
+ }, [refreshProject]);
653
+
654
+ useEffect(() => {
655
+ if (!editor || !activeChapter) return;
656
+ const editorChapterChanged = syncedEditorChapterId.current !== activeChapter.id;
657
+ const editorMarkdown = normalizedMarkdown(htmlToMarkdown(editor.getHTML()));
658
+ const chapterMarkdown = normalizedMarkdown(activeChapter.markdown);
659
+ const html = markdownToHtml(activeChapter.markdown);
660
+ if (editorChapterChanged || editorMarkdown !== chapterMarkdown) {
661
+ editor.commands.setContent(html, { emitUpdate: false });
662
+ bookPageRef.current?.style.setProperty("--manuscript-page-offset", "0px");
663
+ setActivePage(0);
664
+ }
665
+ syncedEditorChapterId.current = activeChapter.id;
666
+ schedulePaginationSync();
667
+ }, [editor, activeChapter?.id, activeChapter?.markdown, schedulePaginationSync]);
668
+
669
+ useEffect(() => {
670
+ if (!pendingHighlightRef.current || !editor || !activeChapter || centerView !== "manuscript") return;
671
+ const command = pendingHighlightRef.current;
672
+ pendingHighlightRef.current = null;
673
+ window.requestAnimationFrame(() => highlightHandlerRef.current(command));
674
+ }, [activeChapter?.id, activePage, centerView, editor]);
675
+
676
+ useEffect(() => {
677
+ if (centerView !== "manuscript") return;
678
+ const page = bookPageRef.current;
679
+ if (!page) return;
680
+
681
+ schedulePaginationSync();
682
+
683
+ const observer = new ResizeObserver(schedulePaginationSync);
684
+ observer.observe(page);
685
+ const clip = page.querySelector(".book-page-clip");
686
+ if (clip) observer.observe(clip);
687
+ const editorElement = page.querySelector(".ProseMirror");
688
+ if (editorElement) observer.observe(editorElement);
689
+
690
+ const preventPageScroll = (event: WheelEvent | TouchEvent) => {
691
+ event.preventDefault();
692
+ };
693
+ page.addEventListener("wheel", preventPageScroll, { passive: false });
694
+ page.addEventListener("touchmove", preventPageScroll, { passive: false });
695
+
696
+ return () => {
697
+ page.removeEventListener("wheel", preventPageScroll);
698
+ page.removeEventListener("touchmove", preventPageScroll);
699
+ observer.disconnect();
700
+ if (paginationFrameRef.current !== null) {
701
+ window.cancelAnimationFrame(paginationFrameRef.current);
702
+ paginationFrameRef.current = null;
703
+ }
704
+ };
705
+ }, [centerView, activeChapter?.id, schedulePaginationSync]);
706
+
707
+ useEffect(() => {
708
+ const handleKeyDown = (event: KeyboardEvent) => {
709
+ const currentView = centerViewRef.current;
710
+ if (
711
+ event.defaultPrevented ||
712
+ (currentView !== "front-matter" && currentView !== "manuscript") ||
713
+ event.isComposing ||
714
+ event.altKey ||
715
+ event.ctrlKey ||
716
+ event.metaKey ||
717
+ event.shiftKey ||
718
+ (event.key !== "ArrowLeft" && event.key !== "ArrowRight")
719
+ ) {
720
+ return;
721
+ }
722
+
723
+ event.preventDefault();
724
+ event.stopPropagation();
725
+ event.stopImmediatePropagation();
726
+
727
+ if (currentView === "front-matter") {
728
+ if (event.key === "ArrowRight" && firstChapterIdRef.current) {
729
+ setProject((current) => current ? { ...current, activeChapterId: firstChapterIdRef.current ?? current.activeChapterId } : current);
730
+ setActivePage(0);
731
+ setCenterView("manuscript");
732
+ }
733
+ return;
734
+ }
735
+
736
+ if (event.key === "ArrowLeft" && activePageRef.current === 0 && hasFrontMatterRef.current && activeChapterIndexRef.current === 0) {
737
+ setCenterView("front-matter");
738
+ return;
739
+ }
740
+
741
+ goToPageRef.current(event.key === "ArrowRight" ? activePageRef.current + 1 : activePageRef.current - 1);
742
+ };
743
+
744
+ window.addEventListener("keydown", handleKeyDown, true);
745
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
746
+ }, []);
747
+
748
+ async function saveChapter() {
749
+ if (!project || !activeChapter || !editor) return;
750
+ const nextChapter = { ...activeChapter, markdown: htmlToMarkdown(editor.getHTML()), updatedAt: new Date().toISOString() };
751
+ try {
752
+ setProject(await writeChapter(project, nextChapter));
753
+ setStatus(`Saved ${nextChapter.path}`);
754
+ } catch (error) {
755
+ setStatus(error instanceof Error ? error.message : "Save failed");
756
+ }
757
+ }
758
+
759
+ function updateStoryField(key: keyof StoryBible, value: string) {
760
+ if (project) setProject({ ...project, story: { ...project.story, [key]: value } });
761
+ }
762
+
763
+ async function saveStoryBible() {
764
+ if (!project) return;
765
+ try {
766
+ let updated = await writeStory(project, project.story);
767
+ updated = await writeCharacters(updated, project.characters);
768
+ updated = await writeOutline(updated, project.outline);
769
+ setProject(updated);
770
+ setStatus("Story bible saved");
771
+ } catch (error) {
772
+ setStatus(error instanceof Error ? error.message : "Save failed");
773
+ }
774
+ }
775
+
776
+ function updateCharacter(id: string, patch: Partial<CharacterRecord>) {
777
+ if (!project) return;
778
+ setProject({ ...project, characters: project.characters.map((character) => (character.id === id ? { ...character, ...patch } : character)) });
779
+ }
780
+
781
+ function updateOutlineBeat(id: string, patch: Partial<OutlineBeat>) {
782
+ if (!project) return;
783
+ setProject({ ...project, outline: project.outline.map((beat) => (beat.id === id ? { ...beat, ...patch } : beat)) });
784
+ }
785
+
786
+ if (!project) {
787
+ return (
788
+ <main className="min-h-[100dvh] bg-[#f3efe7] text-stone-900">
789
+ <div className="mx-auto grid min-h-[100dvh] max-w-4xl place-items-center px-6 py-8 md:px-10">
790
+ <section className="w-full border-l border-stone-300 pl-6">
791
+ <div>
792
+ <p className="text-xs font-medium uppercase text-stone-500">Local macOS writer</p>
793
+ <h1 className="mt-8 max-w-2xl text-4xl font-semibold leading-none text-stone-950 md:text-6xl">Open a Cowriter book</h1>
794
+ <p className="mt-6 max-w-xl text-lg leading-8 text-stone-600">
795
+ This workspace must be served from a local book folder.
796
+ </p>
797
+ </div>
798
+ <div className="mt-10 grid gap-3 rounded-md border border-stone-300 bg-[#fffdf8] p-4 text-sm text-stone-700 shadow-[0_22px_56px_-44px_rgba(87,83,78,0.55)]">
799
+ <p>From a book folder, run:</p>
800
+ <code className="overflow-auto rounded bg-stone-950 px-3 py-2 font-mono text-xs text-stone-50">npx cowriter serve --open</code>
801
+ <p>For a new book folder, run <code className="font-mono text-xs text-stone-900">npx cowriter init</code> first.</p>
802
+ <StatusLine status={status} />
803
+ </div>
804
+ </section>
805
+ </div>
806
+ </main>
807
+ );
808
+ }
809
+
810
+ return (
811
+ <main className="min-h-[100dvh] overflow-auto bg-[#f3efe7] text-stone-900 lg:overflow-hidden">
812
+ <a className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-50 focus:rounded-md focus:bg-stone-900 focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-stone-50" href="#main-workspace">
813
+ Skip to manuscript
814
+ </a>
815
+ <div className="grid min-h-[100dvh] grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)]">
816
+ <ProjectSidebar
817
+ activeChapter={activeChapter}
818
+ activePage={activePage}
819
+ activeStoryTab={activeStoryTab}
820
+ centerView={centerView}
821
+ chapterWordCount={chapterWordCount}
822
+ hasFrontMatter={hasFrontMatter}
823
+ pageCount={pageCount}
824
+ project={project}
825
+ status={status}
826
+ onOpenPromptHelp={() => setPromptHelpOpen(true)}
827
+ onSaveChapter={saveChapter}
828
+ onSelectFrontMatter={() => {
829
+ setActivePage(0);
830
+ setCenterView("front-matter");
831
+ }}
832
+ onSelectChapter={(chapterId) => {
833
+ setProject({ ...project, activeChapterId: chapterId });
834
+ setActivePage(0);
835
+ setCenterView("manuscript");
836
+ }}
837
+ onSelectStoryMaterial={(tabId) => {
838
+ setActiveStoryTab(tabId);
839
+ setCenterView(tabId);
840
+ }}
841
+ />
842
+
843
+ <section id="main-workspace" className={cn("grid min-h-[100dvh] px-4 py-4 md:px-8", isWritingView ? "grid-rows-[auto_minmax(0,1fr)]" : "grid-rows-[auto_auto_minmax(0,1fr)]")}>
844
+ {isStoryMaterialView ? (
845
+ <header className="flex flex-wrap items-center justify-between gap-3">
846
+ <div>
847
+ <p className="text-xs uppercase text-stone-500">{activeMaterialEyebrow}</p>
848
+ <h1 className="text-xl font-semibold text-stone-950">{activeMaterial.label}</h1>
849
+ </div>
850
+ <div className="flex flex-wrap items-center gap-2">
851
+ <StatusLine status={status} compact />
852
+ {activeStoryTab !== "reports" ? (
853
+ <Button onClick={saveStoryBible}>
854
+ <CheckIcon />
855
+ Save material
856
+ </Button>
857
+ ) : null}
858
+ </div>
859
+ </header>
860
+ ) : null}
861
+ <NarrowWorkspaceNav
862
+ activeStoryTab={activeStoryTab}
863
+ centerView={centerView}
864
+ chapters={project.chapters}
865
+ activeChapterId={project.activeChapterId}
866
+ hasFrontMatter={hasFrontMatter}
867
+ reportCount={project.reports.length}
868
+ onSelectFrontMatter={() => {
869
+ setActivePage(0);
870
+ setCenterView("front-matter");
871
+ }}
872
+ onSelectChapter={(chapterId) => {
873
+ setProject({ ...project, activeChapterId: chapterId });
874
+ setActivePage(0);
875
+ setCenterView("manuscript");
876
+ }}
877
+ onSelectStoryMaterial={(tabId) => {
878
+ setActiveStoryTab(tabId);
879
+ setCenterView(tabId);
880
+ }}
881
+ />
882
+ {isWritingView ? (
883
+ <div className="book-stage grid min-h-0 items-start justify-items-center overflow-visible">
884
+ <div className={cn("book-page", isFrontMatterVisible && "book-page--cover")} ref={bookPageRef}>
885
+ {isFrontMatterVisible && project.cover ? (
886
+ <img
887
+ className="book-cover-image"
888
+ src={`/api/cowriter/cover?v=${encodeURIComponent(project.cover.updatedAt)}`}
889
+ alt={`${project.metadata.title} cover`}
890
+ draggable={false}
891
+ />
892
+ ) : (
893
+ <div className="book-page-clip">
894
+ <EditorContent editor={editor} />
895
+ </div>
896
+ )}
897
+ </div>
898
+ </div>
899
+ ) : (
900
+ <StoryMaterialWorkspace
901
+ activeStoryTab={activeStoryTab}
902
+ characters={project.characters}
903
+ outline={project.outline}
904
+ reports={project.reports}
905
+ activeReport={activeReport}
906
+ reportMarkdown={reportMarkdown}
907
+ reportStatus={reportStatus}
908
+ story={project.story}
909
+ onCharacterChange={updateCharacter}
910
+ onOutlineChange={updateOutlineBeat}
911
+ onReportSelect={setActiveReportPath}
912
+ onStoryChange={updateStoryField}
913
+ />
914
+ )}
915
+ </section>
916
+ </div>
917
+ <PromptHelpDialog open={promptHelpOpen} onClose={() => setPromptHelpOpen(false)} />
918
+ </main>
919
+ );
920
+ }
921
+
922
+ function ProjectSidebar({
923
+ activeChapter,
924
+ activePage,
925
+ activeStoryTab,
926
+ centerView,
927
+ chapterWordCount,
928
+ hasFrontMatter,
929
+ pageCount,
930
+ project,
931
+ status,
932
+ onOpenPromptHelp,
933
+ onSaveChapter,
934
+ onSelectFrontMatter,
935
+ onSelectChapter,
936
+ onSelectStoryMaterial,
937
+ }: {
938
+ activeChapter: CowriterProject["chapters"][number] | null;
939
+ activePage: number;
940
+ activeStoryTab: StoryMaterialTab;
941
+ centerView: CenterView;
942
+ chapterWordCount: number;
943
+ hasFrontMatter: boolean;
944
+ pageCount: number;
945
+ project: CowriterProject;
946
+ status: string;
947
+ onOpenPromptHelp: () => void;
948
+ onSaveChapter: () => void;
949
+ onSelectFrontMatter: () => void;
950
+ onSelectChapter: (chapterId: string) => void;
951
+ onSelectStoryMaterial: (tabId: StoryMaterialTab) => void;
952
+ }) {
953
+ const dirtyCount = project.git.changedPaths.length;
954
+ const projectPath = project.path.replace(/^\/Users\/mxcl(?=\/|$)/, "~");
955
+
956
+ return (
957
+ <aside className="hidden max-h-[100dvh] overflow-auto border-r border-stone-300 bg-[#fbfaf7] text-stone-900 lg:block" aria-label="Book workspace">
958
+ <div className="flex min-h-full flex-col px-4 py-4">
959
+ <div className="border-b border-stone-300 pb-4">
960
+ <div className="flex items-start gap-3">
961
+ <span className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-[5px] border border-stone-300 bg-[#fffdf8] text-stone-700 shadow-[0_16px_36px_-30px_rgba(87,83,78,0.45)]" aria-hidden="true">
962
+ <Pencil2Icon className="h-4 w-4" />
963
+ </span>
964
+ <div className="min-w-0">
965
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-stone-500">Cowriter</p>
966
+ <h1 className="mt-1 line-clamp-2 text-[22px] font-semibold leading-[1.05] tracking-tight text-stone-950">{project.metadata.title}</h1>
967
+ <p className="mt-1 truncate text-xs text-stone-500">{project.metadata.author}</p>
968
+ </div>
969
+ </div>
970
+
971
+ <div className="mt-4 grid gap-3 border-l border-stone-300 pl-3">
972
+ <div className="min-w-0">
973
+ <p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-stone-500">Book folder</p>
974
+ <p className="mt-1 truncate whitespace-nowrap font-mono text-xs font-medium leading-5 text-stone-800" title={projectPath}>{projectPath}</p>
975
+ </div>
976
+ <div className="flex items-center justify-between gap-3 text-xs">
977
+ <span className="flex min-w-0 items-center gap-2 text-stone-600">
978
+ <span className={cn("h-1.5 w-1.5 rounded-full", project.git.dirty ? "bg-amber-700" : "bg-stone-400")} aria-hidden="true" />
979
+ <span className="truncate">{project.git.dirty ? "Uncommitted edits" : "Git clean"}</span>
980
+ </span>
981
+ {dirtyCount ? <span className="shrink-0 tabular-nums text-stone-500">{dirtyCount} changed</span> : null}
982
+ </div>
983
+ </div>
984
+ </div>
985
+
986
+ <SidebarSection label="Manuscript" count={project.chapters.length + (hasFrontMatter ? 1 : 0)}>
987
+ <nav className="grid gap-1.5" aria-label="Chapters">
988
+ {hasFrontMatter ? (
989
+ <SidebarNavButton
990
+ active={centerView === "front-matter"}
991
+ eyebrow="Cover, title, legal, prelude"
992
+ icon={<BookmarkIcon />}
993
+ label="Front matter"
994
+ marker="FM"
995
+ onClick={onSelectFrontMatter}
996
+ />
997
+ ) : null}
998
+ {project.chapters.map((chapter, index) => (
999
+ <SidebarNavButton
1000
+ key={chapter.id}
1001
+ active={centerView === "manuscript" && chapter.id === project.activeChapterId}
1002
+ eyebrow={chapter.path}
1003
+ icon={<FileTextIcon />}
1004
+ label={chapter.title}
1005
+ marker={String(index + 1).padStart(2, "0")}
1006
+ onClick={() => onSelectChapter(chapter.id)}
1007
+ />
1008
+ ))}
1009
+ </nav>
1010
+ </SidebarSection>
1011
+
1012
+ {centerView === "manuscript" ? (
1013
+ <ManuscriptSidebarControls
1014
+ activeChapterPath={activeChapter?.path}
1015
+ activeChapterTitle={activeChapter?.title}
1016
+ activePage={activePage}
1017
+ chapterWordCount={chapterWordCount}
1018
+ pageCount={pageCount}
1019
+ status={status}
1020
+ onSave={onSaveChapter}
1021
+ />
1022
+ ) : centerView === "front-matter" ? (
1023
+ <FrontMatterSidebarControls status={status} />
1024
+ ) : null}
1025
+
1026
+ <SidebarSection label="Story Materials" count={storyMaterialTabs.length}>
1027
+ <nav className="grid gap-1.5" aria-label="Story material">
1028
+ {storyMaterialTabs.map((tab) => (
1029
+ <SidebarNavButton
1030
+ key={tab.id}
1031
+ active={centerView === tab.id && activeStoryTab === tab.id}
1032
+ eyebrow={storyMaterialEyebrow(tab.id, project.reports.length)}
1033
+ icon={tab.id === "story" ? <ReaderIcon /> : tab.id === "characters" ? <PersonIcon /> : tab.id === "outline" ? <ListBulletIcon /> : <FileTextIcon />}
1034
+ label={tab.label}
1035
+ onClick={() => onSelectStoryMaterial(tab.id)}
1036
+ />
1037
+ ))}
1038
+ </nav>
1039
+ </SidebarSection>
1040
+
1041
+ <div className="mt-auto pt-4">
1042
+ <button
1043
+ type="button"
1044
+ className="group grid w-full cursor-pointer grid-cols-[36px_1fr] items-center gap-3 rounded-[5px] border border-stone-300 bg-[#fffdf8] px-2 py-2 text-left text-sm text-stone-700 outline-none transition duration-200 ease-out hover:border-stone-400 hover:bg-stone-50 hover:text-stone-950 focus-visible:border-amber-700 active:translate-y-px"
1045
+ onClick={onOpenPromptHelp}
1046
+ >
1047
+ <span className="grid h-8 w-8 place-items-center rounded-[4px] border border-stone-300 bg-stone-100 text-amber-700 transition group-hover:border-stone-400" aria-hidden="true">
1048
+ <QuestionMarkCircledIcon />
1049
+ </span>
1050
+ <span className="min-w-0">
1051
+ <span className="block truncate font-medium">Prompt help</span>
1052
+ <span className="mt-0.5 block truncate text-xs text-stone-500">Examples for every workflow</span>
1053
+ </span>
1054
+ </button>
1055
+ </div>
1056
+ </div>
1057
+ </aside>
1058
+ );
1059
+ }
1060
+
1061
+ function PromptHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
1062
+ useEffect(() => {
1063
+ if (!open) return;
1064
+
1065
+ const handleKeyDown = (event: KeyboardEvent) => {
1066
+ if (event.key === "Escape") onClose();
1067
+ };
1068
+
1069
+ window.addEventListener("keydown", handleKeyDown);
1070
+ return () => window.removeEventListener("keydown", handleKeyDown);
1071
+ }, [onClose, open]);
1072
+
1073
+ if (!open) return null;
1074
+
1075
+ return (
1076
+ <div className="fixed inset-0 z-50 grid place-items-stretch bg-stone-950/32 px-3 py-3 backdrop-blur-sm md:px-6 md:py-6" role="presentation" onMouseDown={onClose}>
1077
+ <section
1078
+ aria-modal="true"
1079
+ aria-labelledby="prompt-help-title"
1080
+ className="mx-auto grid min-h-0 w-full max-w-6xl grid-rows-[auto_minmax(0,1fr)] overflow-hidden rounded-[6px] border border-stone-300 bg-[#fbfaf7] text-stone-900 shadow-[0_32px_90px_-54px_rgba(87,83,78,0.7)]"
1081
+ role="dialog"
1082
+ onMouseDown={(event) => event.stopPropagation()}
1083
+ >
1084
+ <header className="grid gap-4 border-b border-stone-300 px-4 py-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-start md:px-6">
1085
+ <div className="max-w-3xl">
1086
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-stone-500">Cowriter prompt guide</p>
1087
+ <h2 id="prompt-help-title" className="mt-2 text-2xl font-semibold tracking-tight text-stone-950 md:text-3xl">Ask Codex from the book workspace</h2>
1088
+ <p className="mt-3 text-sm leading-6 text-stone-600">
1089
+ Use the explicit syntax when you want Codex to route the request through the installed Cowriter skill. Plain writing requests work too, but these prompts keep the target clear.
1090
+ </p>
1091
+ </div>
1092
+ <Button className="h-9 rounded-[5px] px-2.5" variant="line" onClick={onClose} aria-label="Close prompt help">
1093
+ <Cross2Icon />
1094
+ </Button>
1095
+ </header>
1096
+
1097
+ <div className="min-h-0 overflow-auto">
1098
+ <div className="grid gap-0 divide-y divide-stone-300">
1099
+ {promptHelpSections.map((section, index) => (
1100
+ <PromptHelpFeature key={`${section.category}-${section.title}`} section={section} index={index} />
1101
+ ))}
1102
+ </div>
1103
+ </div>
1104
+ </section>
1105
+ </div>
1106
+ );
1107
+ }
1108
+
1109
+ function PromptHelpFeature({ index, section }: { index: number; section: PromptHelpSection }) {
1110
+ const hasExamples = Boolean(section.before && section.after);
1111
+
1112
+ return (
1113
+ <section className="grid gap-4 px-4 py-5 md:grid-cols-[220px_minmax(0,1fr)] md:px-6">
1114
+ <div>
1115
+ <p className="font-mono text-[11px] text-stone-500">{String(index + 1).padStart(2, "0")}</p>
1116
+ <p className="mt-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-amber-700">{section.category}</p>
1117
+ <h3 className="mt-1 text-lg font-semibold leading-tight text-stone-950">{section.title}</h3>
1118
+ </div>
1119
+
1120
+ <div className="grid gap-3">
1121
+ <div className="grid gap-2">
1122
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-stone-500">Prompt</p>
1123
+ <code className="block overflow-auto rounded-[5px] border border-stone-300 bg-[#eee8dc] px-3 py-2 font-mono text-xs leading-5 text-stone-700 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]">
1124
+ {section.prompt}
1125
+ </code>
1126
+ </div>
1127
+
1128
+ {hasExamples ? (
1129
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
1130
+ <PromptExampleBlock label={section.beforeLabel ?? "Before"} text={section.before ?? ""} />
1131
+ <PromptExampleBlock label={section.afterLabel ?? "After"} text={section.after ?? ""} highlight />
1132
+ </div>
1133
+ ) : null}
1134
+ </div>
1135
+ </section>
1136
+ );
1137
+ }
1138
+
1139
+ function PromptExampleBlock({ highlight, label, text }: { highlight?: boolean; label: string; text: string }) {
1140
+ return (
1141
+ <div className={cn("min-w-0 rounded-[5px] border px-3 py-3", highlight ? "border-amber-700/30 bg-amber-50/70" : "border-stone-300 bg-[#fffdf8]")}>
1142
+ <p className={cn("text-[11px] font-semibold uppercase tracking-[0.14em]", highlight ? "text-amber-700" : "text-stone-500")}>{label}</p>
1143
+ <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-stone-700">{text}</p>
1144
+ </div>
1145
+ );
1146
+ }
1147
+
1148
+ function SidebarSection({ children, count, label }: { children: ReactNode; count?: number; label: string }) {
1149
+ return (
1150
+ <section className="border-b border-stone-300 py-4">
1151
+ <div className="mb-3 flex items-center justify-between gap-3">
1152
+ <h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-stone-500">{label}</h2>
1153
+ {typeof count === "number" ? <span className="font-mono text-[11px] text-stone-500">{count}</span> : null}
1154
+ </div>
1155
+ {children}
1156
+ </section>
1157
+ );
1158
+ }
1159
+
1160
+ function SidebarNavButton({
1161
+ active,
1162
+ eyebrow,
1163
+ icon,
1164
+ label,
1165
+ marker,
1166
+ onClick,
1167
+ }: {
1168
+ active: boolean;
1169
+ eyebrow: string;
1170
+ icon: ReactNode;
1171
+ label: string;
1172
+ marker?: string;
1173
+ onClick: () => void;
1174
+ }) {
1175
+ return (
1176
+ <button
1177
+ className={cn(
1178
+ "group relative grid min-h-12 cursor-pointer grid-cols-[36px_1fr] items-center gap-3 rounded-[5px] px-2 py-2 text-left text-sm outline-none transition duration-200 ease-out active:translate-y-px",
1179
+ active ? "bg-stone-200/80 text-stone-950 ring-1 ring-stone-300" : "text-stone-700 hover:bg-stone-100 focus-visible:bg-stone-100",
1180
+ )}
1181
+ type="button"
1182
+ aria-current={active ? "page" : undefined}
1183
+ onClick={onClick}
1184
+ >
1185
+ <span className={cn("absolute left-0 top-2 h-8 w-0.5 rounded-full transition", active ? "bg-amber-600" : "bg-transparent")} aria-hidden="true" />
1186
+ <span className={cn("grid h-8 w-8 place-items-center rounded-[4px] border transition", active ? "border-stone-300 bg-[#fffdf8] text-stone-800" : "border-stone-300 bg-[#fffdf8] text-stone-500 group-hover:border-stone-400 group-hover:text-stone-700")} aria-hidden="true">
1187
+ {marker ? <span className="font-mono text-[10px] font-semibold">{marker}</span> : icon}
1188
+ </span>
1189
+ <span className="min-w-0">
1190
+ <span className="block truncate font-medium">{label}</span>
1191
+ <span className={cn("mt-0.5 block truncate text-xs", active ? "text-stone-600" : "text-stone-500")}>{eyebrow}</span>
1192
+ </span>
1193
+ </button>
1194
+ );
1195
+ }
1196
+
1197
+ function ManuscriptSidebarControls({
1198
+ activeChapterPath,
1199
+ activeChapterTitle,
1200
+ activePage,
1201
+ chapterWordCount,
1202
+ pageCount,
1203
+ status,
1204
+ onSave,
1205
+ }: {
1206
+ activeChapterPath?: string;
1207
+ activeChapterTitle?: string;
1208
+ activePage: number;
1209
+ chapterWordCount: number;
1210
+ pageCount: number;
1211
+ status: string;
1212
+ onSave: () => void;
1213
+ }) {
1214
+ const progress = pageCount > 0 ? Math.min(100, Math.max(0, ((activePage + 1) / pageCount) * 100)) : 0;
1215
+
1216
+ return (
1217
+ <section className="border-b border-stone-300 py-4">
1218
+ <div className="flex items-center justify-between gap-3">
1219
+ <div className="flex items-center gap-2">
1220
+ <BookmarkIcon className="text-amber-700" aria-hidden="true" />
1221
+ <h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-stone-500">Active Chapter</h2>
1222
+ </div>
1223
+ <span className="font-mono text-[11px] text-stone-500">{activePage + 1}/{pageCount}</span>
1224
+ </div>
1225
+ <div className="mt-3 grid gap-3 border-l border-stone-300 pl-3">
1226
+ <div className="min-w-0">
1227
+ <h3 className="truncate text-sm font-semibold text-stone-950">{activeChapterTitle ?? "No chapter selected"}</h3>
1228
+ <p className="mt-1 truncate text-xs text-stone-500">{activeChapterPath ?? "chapters/*.md"}</p>
1229
+ </div>
1230
+ <div className="grid gap-2 text-xs text-stone-500">
1231
+ <div className="flex items-center justify-between gap-3 text-stone-700">
1232
+ <span className="truncate">{chapterWordCount ? `${chapterWordCount} words` : "Empty chapter"}</span>
1233
+ <span className="shrink-0 tabular-nums">Page {activePage + 1} of {pageCount}</span>
1234
+ </div>
1235
+ <div className="h-1 overflow-hidden rounded-full bg-stone-200" aria-hidden="true">
1236
+ <div className="h-full rounded-full bg-amber-700 transition-[width] duration-300 ease-out" style={{ width: `${progress}%` }} />
1237
+ </div>
1238
+ </div>
1239
+ <StatusLine status={status} />
1240
+ <Button className="h-10 w-full cursor-pointer rounded-[5px]" onClick={onSave}>
1241
+ <CheckIcon />
1242
+ Save chapter
1243
+ </Button>
1244
+ </div>
1245
+ </section>
1246
+ );
1247
+ }
1248
+
1249
+ function FrontMatterSidebarControls({ status }: { status: string }) {
1250
+ return (
1251
+ <section className="border-b border-stone-300 py-4">
1252
+ <div className="flex items-center gap-2">
1253
+ <BookmarkIcon className="text-amber-700" aria-hidden="true" />
1254
+ <h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-stone-500">Active Section</h2>
1255
+ </div>
1256
+ <div className="mt-3 grid gap-3 border-l border-stone-300 pl-3">
1257
+ <div className="min-w-0">
1258
+ <h3 className="truncate text-sm font-semibold text-stone-950">Front matter</h3>
1259
+ <p className="mt-1 truncate text-xs text-stone-500">cover, title, legal, prelude</p>
1260
+ </div>
1261
+ <StatusLine status={status} />
1262
+ </div>
1263
+ </section>
1264
+ );
1265
+ }
1266
+
1267
+ function NarrowWorkspaceNav({
1268
+ activeStoryTab,
1269
+ centerView,
1270
+ chapters,
1271
+ activeChapterId,
1272
+ hasFrontMatter,
1273
+ reportCount,
1274
+ onSelectFrontMatter,
1275
+ onSelectChapter,
1276
+ onSelectStoryMaterial,
1277
+ }: {
1278
+ activeStoryTab: StoryMaterialTab;
1279
+ centerView: CenterView;
1280
+ chapters: CowriterProject["chapters"];
1281
+ activeChapterId: string;
1282
+ hasFrontMatter: boolean;
1283
+ reportCount: number;
1284
+ onSelectFrontMatter: () => void;
1285
+ onSelectChapter: (chapterId: string) => void;
1286
+ onSelectStoryMaterial: (tabId: StoryMaterialTab) => void;
1287
+ }) {
1288
+ return (
1289
+ <nav className="-mx-4 mt-4 flex gap-2 overflow-x-auto border-y border-stone-300 bg-[#fbfaf7]/95 px-4 py-2 text-sm md:-mx-8 md:px-8 lg:hidden" aria-label="Workspace">
1290
+ {hasFrontMatter ? (
1291
+ <button
1292
+ className={cn(
1293
+ "shrink-0 rounded-md px-3 py-2 text-left transition active:translate-y-px",
1294
+ centerView === "front-matter" ? "bg-stone-200/80 text-stone-950 ring-1 ring-stone-300" : "text-stone-600 hover:bg-stone-100 hover:text-stone-950",
1295
+ )}
1296
+ onClick={onSelectFrontMatter}
1297
+ >
1298
+ <span className="block max-w-44 truncate font-medium">Front matter</span>
1299
+ <span className="mt-1 block text-xs opacity-70">Cover, title, legal</span>
1300
+ </button>
1301
+ ) : null}
1302
+ {chapters.map((chapter) => (
1303
+ <button
1304
+ key={chapter.id}
1305
+ className={cn(
1306
+ "shrink-0 rounded-md px-3 py-2 text-left transition active:translate-y-px",
1307
+ centerView === "manuscript" && chapter.id === activeChapterId ? "bg-stone-200/80 text-stone-950 ring-1 ring-stone-300" : "text-stone-600 hover:bg-stone-100 hover:text-stone-950",
1308
+ )}
1309
+ onClick={() => onSelectChapter(chapter.id)}
1310
+ >
1311
+ <span className="block max-w-44 truncate font-medium">{chapter.title}</span>
1312
+ <span className="mt-1 block text-xs opacity-70">Manuscript</span>
1313
+ </button>
1314
+ ))}
1315
+ {storyMaterialTabs.map((tab) => (
1316
+ <button
1317
+ key={tab.id}
1318
+ className={cn(
1319
+ "shrink-0 rounded-md px-3 py-2 text-left transition active:translate-y-px",
1320
+ centerView === tab.id && activeStoryTab === tab.id ? "bg-stone-200/80 text-stone-950 ring-1 ring-stone-300" : "text-stone-600 hover:bg-stone-100 hover:text-stone-950",
1321
+ )}
1322
+ onClick={() => onSelectStoryMaterial(tab.id)}
1323
+ >
1324
+ <span className="block max-w-48 truncate font-medium">{tab.label}</span>
1325
+ <span className="mt-1 block text-xs opacity-70">{storyMaterialEyebrow(tab.id, reportCount)}</span>
1326
+ </button>
1327
+ ))}
1328
+ </nav>
1329
+ );
1330
+ }
1331
+
1332
+ function StoryMaterialWorkspace({
1333
+ activeStoryTab,
1334
+ activeReport,
1335
+ characters,
1336
+ outline,
1337
+ reports,
1338
+ reportMarkdown,
1339
+ reportStatus,
1340
+ story,
1341
+ onCharacterChange,
1342
+ onOutlineChange,
1343
+ onReportSelect,
1344
+ onStoryChange,
1345
+ }: {
1346
+ activeStoryTab: StoryMaterialTab;
1347
+ activeReport: CowriterReport | null;
1348
+ characters: CharacterRecord[];
1349
+ outline: OutlineBeat[];
1350
+ reports: CowriterReport[];
1351
+ reportMarkdown: string;
1352
+ reportStatus: string;
1353
+ story: StoryBible;
1354
+ onCharacterChange: (id: string, patch: Partial<CharacterRecord>) => void;
1355
+ onOutlineChange: (id: string, patch: Partial<OutlineBeat>) => void;
1356
+ onReportSelect: (path: string) => void;
1357
+ onStoryChange: (key: keyof StoryBible, value: string) => void;
1358
+ }) {
1359
+ return (
1360
+ <div className="my-5 min-h-0 overflow-auto border-y border-stone-300 bg-[#fbfaf7]">
1361
+ <div className={cn("mx-auto w-full px-3 py-6 sm:px-4 md:px-6", activeStoryTab === "reports" ? "max-w-6xl" : "max-w-5xl")}>
1362
+ {activeStoryTab === "story" ? (
1363
+ <StoryFields story={story} onChange={onStoryChange} />
1364
+ ) : activeStoryTab === "characters" ? (
1365
+ <CharacterFields characters={characters} onChange={onCharacterChange} />
1366
+ ) : activeStoryTab === "outline" ? (
1367
+ <OutlineFields outline={outline} onChange={onOutlineChange} />
1368
+ ) : (
1369
+ <ReportFields reports={reports} activeReport={activeReport} markdown={reportMarkdown} status={reportStatus} onSelect={onReportSelect} />
1370
+ )}
1371
+ </div>
1372
+ </div>
1373
+ );
1374
+ }
1375
+
1376
+ function StoryFields({ story, onChange }: { story: StoryBible; onChange: (key: keyof StoryBible, value: string) => void }) {
1377
+ return (
1378
+ <div className="grid gap-4">
1379
+ <Field label="Title"><Input value={story.title} placeholder={storyPlaceholders.title} onChange={(event) => onChange("title", event.target.value)} /></Field>
1380
+ <Field label="Synopsis"><Textarea className={longMaterialTextareaClass} value={story.synopsis} placeholder={storyPlaceholders.synopsis} onChange={(event) => onChange("synopsis", event.target.value)} /></Field>
1381
+ <div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
1382
+ <Field label="Setting"><Textarea className={materialTextareaClass} value={story.setting} placeholder={storyPlaceholders.setting} onChange={(event) => onChange("setting", event.target.value)} /></Field>
1383
+ <Field label="Themes"><Textarea className={materialTextareaClass} value={story.themes} placeholder={storyPlaceholders.themes} onChange={(event) => onChange("themes", event.target.value)} /></Field>
1384
+ </div>
1385
+ <div className="grid grid-cols-1 gap-3 xl:grid-cols-3">
1386
+ <Field label="Genre"><StoryNoteTextarea value={story.genre} placeholder={storyPlaceholders.genre} onChange={(event) => onChange("genre", event.target.value)} /></Field>
1387
+ <Field label="Tone"><StoryNoteTextarea value={story.tone} placeholder={storyPlaceholders.tone} onChange={(event) => onChange("tone", event.target.value)} /></Field>
1388
+ <Field label="Perspective"><StoryNoteTextarea value={story.perspective} placeholder={storyPlaceholders.perspective} onChange={(event) => onChange("perspective", event.target.value)} /></Field>
1389
+ </div>
1390
+ <Field label="Continuity"><Textarea className={longMaterialTextareaClass} value={story.continuity} placeholder={storyPlaceholders.continuity} onChange={(event) => onChange("continuity", event.target.value)} /></Field>
1391
+ </div>
1392
+ );
1393
+ }
1394
+
1395
+ function StoryNoteTextarea({ value, className, minRows = 2, maxRows = 6, ...props }: ComponentProps<typeof Textarea> & { value: string; minRows?: number; maxRows?: number }) {
1396
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
1397
+
1398
+ useEffect(() => {
1399
+ const textarea = textareaRef.current;
1400
+ if (!textarea) return;
1401
+
1402
+ const style = window.getComputedStyle(textarea);
1403
+ const lineHeight = Number.parseFloat(style.lineHeight) || 20;
1404
+ const verticalPadding = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
1405
+ const verticalBorder = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
1406
+ const minHeight = lineHeight * minRows + verticalPadding + verticalBorder;
1407
+ const maxHeight = lineHeight * maxRows + verticalPadding + verticalBorder;
1408
+
1409
+ textarea.style.height = "auto";
1410
+ textarea.style.minHeight = `${minHeight}px`;
1411
+ textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
1412
+ textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
1413
+ }, [maxRows, minRows, value]);
1414
+
1415
+ return (
1416
+ <Textarea
1417
+ ref={textareaRef}
1418
+ className={cn("resize-none overflow-hidden text-[13px] leading-5 md:text-sm md:leading-6", className)}
1419
+ value={value}
1420
+ {...props}
1421
+ />
1422
+ );
1423
+ }
1424
+
1425
+ function CharacterFields({ characters, onChange }: { characters: CharacterRecord[]; onChange: (id: string, patch: Partial<CharacterRecord>) => void }) {
1426
+ if (!characters.length) {
1427
+ return <EmptyMaterialState title="No characters yet" body="Send Codex the synopsis and ask for a cast list, or add character files in the project folder." />;
1428
+ }
1429
+
1430
+ return (
1431
+ <div className="grid gap-4">
1432
+ {characters.map((character) => (
1433
+ <div key={character.id} className="grid gap-3 border-t border-stone-300 pt-3">
1434
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-[1fr_0.8fr]">
1435
+ <Field label="Name"><Input value={character.name} placeholder={characterPlaceholders.name} onChange={(event) => onChange(character.id, { name: event.target.value })} /></Field>
1436
+ <Field label="Role"><Input value={character.role} placeholder={characterPlaceholders.role} onChange={(event) => onChange(character.id, { role: event.target.value })} /></Field>
1437
+ </div>
1438
+ <div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
1439
+ <Field label="Desire"><Textarea className={materialTextareaClass} value={character.desire} placeholder={characterPlaceholders.desire} onChange={(event) => onChange(character.id, { desire: event.target.value })} /></Field>
1440
+ <Field label="Conflict"><Textarea className={materialTextareaClass} value={character.conflict} placeholder={characterPlaceholders.conflict} onChange={(event) => onChange(character.id, { conflict: event.target.value })} /></Field>
1441
+ </div>
1442
+ <Field label="Notes"><Textarea className={longMaterialTextareaClass} value={character.notes} placeholder={characterPlaceholders.notes} onChange={(event) => onChange(character.id, { notes: event.target.value })} /></Field>
1443
+ </div>
1444
+ ))}
1445
+ </div>
1446
+ );
1447
+ }
1448
+
1449
+ function OutlineFields({ outline, onChange }: { outline: OutlineBeat[]; onChange: (id: string, patch: Partial<OutlineBeat>) => void }) {
1450
+ if (!outline.length) {
1451
+ return <EmptyMaterialState title="No outline beats yet" body="Once the synopsis has a shape, ask Codex for a first structure pass." />;
1452
+ }
1453
+
1454
+ return (
1455
+ <div className="grid gap-4">
1456
+ {outline.map((beat) => (
1457
+ <div key={beat.id} className="grid gap-3 border-t border-stone-300 pt-3">
1458
+ <Field label="Beat"><Input value={beat.title} placeholder={outlinePlaceholders.title} onChange={(event) => onChange(beat.id, { title: event.target.value })} /></Field>
1459
+ <Field label="Summary"><Textarea className={longMaterialTextareaClass} value={beat.summary} placeholder={outlinePlaceholders.summary} onChange={(event) => onChange(beat.id, { summary: event.target.value })} /></Field>
1460
+ </div>
1461
+ ))}
1462
+ </div>
1463
+ );
1464
+ }
1465
+
1466
+ function ReportFields({
1467
+ activeReport,
1468
+ markdown,
1469
+ reports,
1470
+ status,
1471
+ onSelect,
1472
+ }: {
1473
+ activeReport: CowriterReport | null;
1474
+ markdown: string;
1475
+ reports: CowriterReport[];
1476
+ status: string;
1477
+ onSelect: (path: string) => void;
1478
+ }) {
1479
+ if (!reports.length) {
1480
+ return <EmptyMaterialState title="No reports yet" body="Approved prose diagnostics and review workflows can save Markdown reports here for later reading." />;
1481
+ }
1482
+
1483
+ const renderedMarkdown = markdownToHtml(stripReportFrontmatter(markdown));
1484
+
1485
+ return (
1486
+ <div className="grid min-h-[62dvh] gap-4 2xl:grid-cols-[minmax(240px,300px)_minmax(680px,1fr)]">
1487
+ <aside className="min-h-0 overflow-auto border border-stone-300 bg-[#fffdf8] 2xl:max-h-none" aria-label="Reports">
1488
+ <div className="flex gap-2 overflow-x-auto p-2 2xl:grid 2xl:gap-1 2xl:overflow-visible">
1489
+ {reports.map((report) => {
1490
+ const active = activeReport?.path === report.path;
1491
+ return (
1492
+ <button
1493
+ key={report.path}
1494
+ type="button"
1495
+ className={cn(
1496
+ "grid min-w-[min(22rem,82vw)] gap-2 rounded-[5px] px-3 py-3 text-left text-sm transition active:translate-y-px 2xl:min-w-0",
1497
+ active ? "bg-stone-200/80 text-stone-950 ring-1 ring-stone-300" : "text-stone-700 hover:bg-stone-100 hover:text-stone-950",
1498
+ )}
1499
+ aria-current={active ? "page" : undefined}
1500
+ onClick={() => onSelect(report.path)}
1501
+ >
1502
+ <span className="flex min-w-0 items-center justify-between gap-3">
1503
+ <span className="truncate font-medium">{report.title}</span>
1504
+ <span className={cn("shrink-0 rounded-[4px] px-1.5 py-0.5 font-mono text-[10px] uppercase", active ? "bg-[#fffdf8] text-stone-600" : "bg-stone-100 text-stone-500")}>{report.status}</span>
1505
+ </span>
1506
+ <span className={cn("grid gap-1 text-xs", active ? "text-stone-600" : "text-stone-500")}>
1507
+ <span className="truncate">{report.type}</span>
1508
+ {report.chapter ? <span className="truncate">{report.chapter}</span> : null}
1509
+ <span className="truncate">{formatReportDate(report.createdAt)}</span>
1510
+ </span>
1511
+ </button>
1512
+ );
1513
+ })}
1514
+ </div>
1515
+ </aside>
1516
+
1517
+ <section className="min-w-0 border border-stone-300 bg-[#fffdf8] text-stone-950">
1518
+ <header className="border-b border-stone-200 px-5 py-4 sm:px-7 lg:px-8">
1519
+ <div className="mx-auto w-full max-w-[76ch]">
1520
+ <p className="text-xs font-medium uppercase tracking-[0.12em] text-stone-500">{activeReport?.type ?? "Report"}</p>
1521
+ <h2 className="mt-1 text-xl font-semibold leading-tight text-stone-950">{activeReport?.title ?? "Select a report"}</h2>
1522
+ <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-stone-500">
1523
+ {activeReport?.chapter ? <span>{activeReport.chapter}</span> : null}
1524
+ {activeReport ? <span>{formatReportDate(activeReport.createdAt)}</span> : null}
1525
+ <span>{status}</span>
1526
+ </div>
1527
+ </div>
1528
+ </header>
1529
+ <article className="report-markdown mx-auto min-h-[52dvh] w-full max-w-[76ch] px-5 py-5 sm:px-7 lg:px-8" dangerouslySetInnerHTML={{ __html: renderedMarkdown || "<p>No report content.</p>" }} />
1530
+ </section>
1531
+ </div>
1532
+ );
1533
+ }
1534
+
1535
+ function EmptyMaterialState({ title, body }: { title: string; body: string }) {
1536
+ return (
1537
+ <div className="grid min-h-72 place-items-center border border-dashed border-stone-300 bg-[#fffdf8] px-6 text-center">
1538
+ <div>
1539
+ <h2 className="text-lg font-semibold text-stone-950">{title}</h2>
1540
+ <p className="mt-2 max-w-md text-sm leading-6 text-stone-600">{body}</p>
1541
+ </div>
1542
+ </div>
1543
+ );
1544
+ }
1545
+
1546
+ function StatusLine({ status, compact }: { status: string; compact?: boolean }) {
1547
+ return (
1548
+ <div className={cn("flex min-w-0 items-center gap-2 text-xs text-stone-500", compact && "justify-end")}>
1549
+ <span className="h-2 w-2 shrink-0 rounded-full bg-amber-700/70" />
1550
+ <span className="shrink-0">Local workspace</span>
1551
+ <span className="min-w-0 truncate">{status}</span>
1552
+ </div>
1553
+ );
1554
+ }