From e4f1622a0f0ac74707cc1c9810e0b99e948d1319 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Fri, 8 May 2026 13:43:17 +0100
Subject: refactor(frontend): DocumentEditor takes explicit
 body/title/documentId props (#131)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Stops shadowing directive.goal with the contract body via a synthesised
directive object. DocumentEditor now accepts:

  * documentId — scopes the localStorage draft key per contract so
    switching contracts under the same directive doesn't clobber the
    other's unsaved edits.
  * title      — the contract title rendered as the H1.
  * body       — the contract body, used to seed the editor.
  * onUpdateBody (was onUpdateGoal)

The `directive` prop stays for orchestrator state + embedded steps
panel. document-directives.tsx drops the directiveAsDocument synthesis
hack and passes body/title from the contract directly.

This is the prep-work for dropping `directives.goal` from the schema —
once nothing reads it, the column can be dropped in a follow-up
without touching the editor.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .../src/components/directives/DocumentEditor.tsx   | 147 ++++++++++++---------
 1 file changed, 88 insertions(+), 59 deletions(-)

(limited to 'makima/frontend/src/components/directives/DocumentEditor.tsx')

diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx
index 981cab1..661665c 100644
--- a/makima/frontend/src/components/directives/DocumentEditor.tsx
+++ b/makima/frontend/src/components/directives/DocumentEditor.tsx
@@ -79,7 +79,14 @@ const SAVED_TOAST_MS = 1200;
  * the pending timer before it could flush. localStorage.setItem on a small
  * string is sub-millisecond, so debouncing was a premature optimisation.
  */
-const DRAFT_KEY = (directiveId: string) => `makima:directive-goal-draft:${directiveId}`;
+/**
+ * Per-document draft key. Contracts under the same directive must not
+ * share localStorage, otherwise switching contracts would clobber the
+ * other's unsaved edits. The key is the document id; we keep the
+ * `directive-goal-draft` prefix for backwards compatibility with
+ * existing entries (the prefix string is opaque storage).
+ */
+const DRAFT_KEY = (documentId: string) => `makima:directive-goal-draft:${documentId}`;
 const LIVE_START_KEY = "makima:liveStartEnabled";
 
 // =============================================================================
@@ -87,7 +94,7 @@ const LIVE_START_KEY = "makima:liveStartEnabled";
 //
 // The directive goal is a single paragraph node in the editor (children[1]).
 // We support inline formatting (bold, italic, underline, code, strikethrough)
-// and persist it as inline markdown in `directive.goal`. We deliberately do
+// and persist it as inline markdown in `body`. We deliberately do
 // NOT handle headings, lists, or blocks here — those would change the document
 // shape and the goal column is just TEXT on the backend.
 //
@@ -283,33 +290,37 @@ const editorTheme = {
  * happens mid-keystroke.
  */
 function SeedContentPlugin({
-  directive,
+  documentId,
+  title,
+  body,
   onDraftRestored,
 }: {
-  directive: DirectiveWithSteps;
+  documentId: string;
+  title: string;
+  body: string;
   onDraftRestored: (draft: string) => void;
 }) {
   const [editor] = useLexicalComposerContext();
   const seededIdRef = useRef<string | null>(null);
 
   useEffect(() => {
-    if (seededIdRef.current === directive.id) return;
-    seededIdRef.current = directive.id;
-
-    // If a localStorage draft exists for this directive, prefer it over the
-    // persisted goal so the user does not lose unsaved work after navigating
-    // away. The parent is told about the restored draft so its state machine
-    // can transition to "dirty" or "pending".
-    let initialGoal = directive.goal;
+    if (seededIdRef.current === documentId) return;
+    seededIdRef.current = documentId;
+
+    // If a localStorage draft exists for this document, prefer it over
+    // the persisted body so the user does not lose unsaved work after
+    // navigating away. The parent is told about the restored draft so
+    // its state machine can transition to "dirty" or "pending".
+    let initialBody = body;
     let restoredDraft: string | null = null;
     try {
-      const stored = window.localStorage.getItem(DRAFT_KEY(directive.id));
-      if (stored !== null && stored !== directive.goal) {
-        initialGoal = stored;
+      const stored = window.localStorage.getItem(DRAFT_KEY(documentId));
+      if (stored !== null && stored !== body) {
+        initialBody = stored;
         restoredDraft = stored;
       }
     } catch {
-      /* localStorage may be unavailable; fall back to persisted goal */
+      /* localStorage may be unavailable; fall back to persisted body */
     }
 
     editor.update(
@@ -319,17 +330,17 @@ function SeedContentPlugin({
 
         // H1: title (read-only — see ReadOnlyTitlePlugin).
         const heading = $createHeadingNode("h1");
-        heading.append($createTextNode(directive.title));
+        heading.append($createTextNode(title));
         root.append(heading);
 
-        // Goal body. The persisted goal may contain multiple paragraphs
+        // Body content. The persisted body may contain multiple paragraphs
         // (separated by blank lines) and inline markdown — split by blank
         // lines so each block becomes its own ParagraphNode, and parse the
         // inline formatting per paragraph. Always emit at least one
-        // paragraph so users have a place to type even when the goal is
+        // paragraph so users have a place to type even when the body is
         // empty.
         const blocks =
-          initialGoal.length > 0 ? initialGoal.split(/\n{2,}/) : [""];
+          initialBody.length > 0 ? initialBody.split(/\n{2,}/) : [""];
         for (const block of blocks) {
           const p = $createParagraphNode();
           if (block.length > 0) {
@@ -342,7 +353,7 @@ function SeedContentPlugin({
         root.append($createStepsBlockNode());
 
         // Trailing empty paragraph so the cursor has somewhere to land below
-        // the steps block. The trailing area is also captured as goal
+        // the steps block. The trailing area is also captured as body
         // content by serializeGoalFromRoot, so any typing here is preserved.
         root.append($createParagraphNode());
       },
@@ -354,7 +365,7 @@ function SeedContentPlugin({
       // content (avoids the GoalChangePlugin firing first and double-tracking).
       queueMicrotask(() => onDraftRestored(restoredDraft!));
     }
-  }, [editor, directive.id, directive.title, directive.goal, onDraftRestored]);
+  }, [editor, documentId, title, body, onDraftRestored]);
 
   return null;
 }
@@ -848,8 +859,21 @@ function SaveCountdownBar({
 // =============================================================================
 
 export interface DocumentEditorProps {
+  /** The parent directive, used for orchestrator state, repo metadata, and
+   *  the embedded steps panel. The editor body content does NOT come from
+   *  this — pass `body` and `title` explicitly so contracts under the
+   *  same directive can have independent specs. */
   directive: DirectiveWithSteps;
-  onUpdateGoal: (goal: string) => Promise<void> | void;
+  /** Document id — scopes the localStorage draft key so per-contract
+   *  drafts don't collide. */
+  documentId: string;
+  /** The contract's title, rendered as the H1 heading at the top of the
+   *  editor (read-only). */
+  title: string;
+  /** The contract's body markdown, used to seed the editor on mount and
+   *  whenever the document id changes. */
+  body: string;
+  onUpdateBody: (body: string) => Promise<void> | void;
   onCleanup: () => Promise<void> | void;
   onCreatePR: () => Promise<void> | void;
   onPickUpOrders: () => Promise<unknown> | unknown;
@@ -859,7 +883,10 @@ type SaveState = "idle" | "dirty" | "pending" | "saving" | "saved" | "error";
 
 export function DocumentEditor({
   directive,
-  onUpdateGoal,
+  documentId,
+  title,
+  body,
+  onUpdateBody,
   onCleanup,
   onCreatePR,
   onPickUpOrders,
@@ -870,7 +897,7 @@ export function DocumentEditor({
       // Re-key the composer when the directive id changes so we get a clean
       // editor state per document. We do this via the `key` prop on
       // <LexicalComposer> below as well.
-      namespace: `makima-doc-${directive.id}`,
+      namespace: `makima-doc-${documentId}`,
       onError: (err: Error) => {
         // eslint-disable-next-line no-console
         console.error("[DocumentEditor]", err);
@@ -879,7 +906,7 @@ export function DocumentEditor({
       theme: editorTheme,
       editable: true,
     }),
-    [directive.id],
+    [documentId],
   );
 
   // ---- Live-start setting (localStorage-backed) -------------------------
@@ -897,14 +924,14 @@ export function DocumentEditor({
 
   const [saveState, setSaveState] = useState<SaveState>("idle");
   const [remainingMs, setRemainingMs] = useState(countdownMs);
-  const pendingGoalRef = useRef<string>(directive.goal);
-  const persistedGoalRef = useRef<string>(directive.goal);
+  const pendingGoalRef = useRef<string>(body);
+  const persistedGoalRef = useRef<string>(body);
   const timerRef = useRef<number | null>(null);
   const tickRef = useRef<number | null>(null);
   const deadlineRef = useRef<number>(0);
   const editorRef = useRef<LexicalEditor | null>(null);
   // Tracks the most recent value the backend was asked to save. Once a poll
-  // confirms `directive.goal === lastSavedValueRef.current` AND
+  // confirms `body === lastSavedValueRef.current` AND
   // `pendingGoalRef.current` still matches (i.e. user hasn't typed more),
   // the draft is safe to drop from localStorage. Until then we keep the
   // draft so an interrupted save doesn't lose user content.
@@ -916,8 +943,8 @@ export function DocumentEditor({
   // Track the persisted goal in a ref so beforeunload handlers can do their
   // own freshness comparison without a stale closure.
   useEffect(() => {
-    persistedGoalRef.current = directive.goal;
-  }, [directive.goal]);
+    persistedGoalRef.current = body;
+  }, [body]);
 
   function cancelTimers() {
     if (timerRef.current != null) {
@@ -932,30 +959,30 @@ export function DocumentEditor({
 
   // Reset state when switching directives.
   useEffect(() => {
-    pendingGoalRef.current = directive.goal;
+    pendingGoalRef.current = body;
     cancelTimers();
     setSaveState("idle");
     setRemainingMs(countdownMs);
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [directive.id]);
+  }, [documentId]);
 
   // If the persisted goal updated externally and matches the pending goal,
   // settle the bar.
   useEffect(() => {
     if (
       (saveState === "pending" || saveState === "dirty") &&
-      pendingGoalRef.current === directive.goal
+      pendingGoalRef.current === body
     ) {
       cancelTimers();
       setSaveState("idle");
       try {
-        window.localStorage.removeItem(DRAFT_KEY(directive.id));
+        window.localStorage.removeItem(DRAFT_KEY(documentId));
       } catch {
         /* localStorage may be unavailable; ignore */
       }
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [directive.goal]);
+  }, [body]);
 
   const fireSave = useCallback(async () => {
     // Read DIRECTLY from the editor so we don't trust pendingGoalRef alone.
@@ -974,7 +1001,7 @@ export function DocumentEditor({
     // the backend. If the save errors, the page closes mid-flight, or the
     // network drops, the user's content survives in the draft.
     try {
-      window.localStorage.setItem(DRAFT_KEY(directive.id), next);
+      window.localStorage.setItem(DRAFT_KEY(documentId), next);
       setDraftSavedAt(Date.now());
     } catch (err) {
       // eslint-disable-next-line no-console
@@ -984,11 +1011,11 @@ export function DocumentEditor({
     cancelTimers();
     setSaveState("saving");
     try {
-      await onUpdateGoal(next);
+      await onUpdateBody(next);
       lastSavedValueRef.current = next;
       setSaveState("saved");
       // NOTE: we deliberately do NOT clear localStorage here. The roundtrip
-      // effect below clears it once the polled directive.goal confirms our
+      // effect below clears it once the polled body confirms our
       // save persisted AND the user hasn't kept typing past it.
       window.setTimeout(() => {
         // Only fade if no new edit has reopened a pending state in the meantime.
@@ -1002,27 +1029,27 @@ export function DocumentEditor({
         setSaveState((s) => (s === "error" ? "idle" : s));
       }, 4000);
     }
-  }, [onUpdateGoal, directive.id]);
+  }, [onUpdateBody, documentId]);
 
   // Roundtrip-confirmed draft cleanup. Only drops the localStorage draft
-  // when the polled directive.goal matches what we just saved AND the
+  // when the polled body matches what we just saved AND the
   // user hasn't typed anything new in the meantime. Keeps the draft alive
   // through every "we hit save but the page reloads before the poll lands"
   // edge case.
   useEffect(() => {
     if (
       lastSavedValueRef.current !== null &&
-      directive.goal === lastSavedValueRef.current &&
+      body === lastSavedValueRef.current &&
       pendingGoalRef.current === lastSavedValueRef.current
     ) {
       try {
-        window.localStorage.removeItem(DRAFT_KEY(directive.id));
+        window.localStorage.removeItem(DRAFT_KEY(documentId));
       } catch {
         /* ignore */
       }
       lastSavedValueRef.current = null;
     }
-  }, [directive.goal, directive.id]);
+  }, [body, documentId]);
 
   const startOrExtendCountdown = useCallback(() => {
     cancelTimers();
@@ -1045,11 +1072,11 @@ export function DocumentEditor({
   const cancelCountdown = useCallback(() => {
     if (saveState !== "pending" && saveState !== "dirty") return;
     cancelTimers();
-    pendingGoalRef.current = directive.goal; // reset pending edit
+    pendingGoalRef.current = body; // reset pending edit
     setSaveState("idle");
     setRemainingMs(countdownMs);
     try {
-      window.localStorage.removeItem(DRAFT_KEY(directive.id));
+      window.localStorage.removeItem(DRAFT_KEY(documentId));
     } catch {
       /* ignore */
     }
@@ -1065,14 +1092,14 @@ export function DocumentEditor({
           const goalNode = root.getChildren()[1];
           if (!goalNode || !$isElementNode(goalNode)) return;
           goalNode.getChildren().forEach((c) => c.remove());
-          if (directive.goal.length > 0) {
-            appendInlineMarkdownTo(goalNode, directive.goal);
+          if (body.length > 0) {
+            appendInlineMarkdownTo(goalNode, body);
           }
         },
         { tag: "history-merge" },
       );
     }
-  }, [directive.goal, directive.id, saveState, countdownMs]);
+  }, [body, documentId, saveState, countdownMs]);
 
   // Cleanup on unmount.
   useEffect(() => {
@@ -1091,7 +1118,7 @@ export function DocumentEditor({
       try {
         const value = pendingGoalRef.current;
         const persisted = persistedGoalRef.current;
-        const key = DRAFT_KEY(directive.id);
+        const key = DRAFT_KEY(documentId);
         if (value === persisted) {
           window.localStorage.removeItem(key);
         } else {
@@ -1117,7 +1144,7 @@ export function DocumentEditor({
       // Final flush on React unmount (route navigation within the SPA).
       flush();
     };
-  }, [directive.id]);
+  }, [documentId]);
 
   const handleGoalChange = useCallback(
     (goal: string) => {
@@ -1129,10 +1156,10 @@ export function DocumentEditor({
       //    before it flushed — losing the most recent edits exactly when
       //    we needed them most.
       try {
-        if (goal === directive.goal) {
-          window.localStorage.removeItem(DRAFT_KEY(directive.id));
+        if (goal === body) {
+          window.localStorage.removeItem(DRAFT_KEY(documentId));
         } else {
-          window.localStorage.setItem(DRAFT_KEY(directive.id), goal);
+          window.localStorage.setItem(DRAFT_KEY(documentId), goal);
           setDraftSavedAt(Date.now());
         }
       } catch (err) {
@@ -1141,7 +1168,7 @@ export function DocumentEditor({
       }
 
       // 2. State-machine.
-      if (goal === directive.goal) {
+      if (goal === body) {
         // Edit reverted — cancel the countdown (if any).
         if (saveState === "pending" || saveState === "dirty") {
           cancelTimers();
@@ -1158,7 +1185,7 @@ export function DocumentEditor({
         setSaveState("dirty");
       }
     },
-    [directive.goal, directive.id, liveStart, saveState, startOrExtendCountdown],
+    [body, documentId, liveStart, saveState, startOrExtendCountdown],
   );
 
   // ---- Right-click context menu -----------------------------------------
@@ -1173,11 +1200,13 @@ export function DocumentEditor({
   return (
     <div className="flex flex-col h-full overflow-hidden">
       <StepsBlockContextProvider value={{ directive }}>
-        <LexicalComposer key={directive.id} initialConfig={initialConfig}>
+        <LexicalComposer key={documentId} initialConfig={initialConfig}>
           {/* Capture the editor ref via a tiny inline plugin */}
           <EditorRefCapture editorRef={editorRef} />
           <SeedContentPlugin
-            directive={directive}
+            documentId={documentId}
+            title={title}
+            body={body}
             onDraftRestored={(draft) => {
               pendingGoalRef.current = draft;
               if (liveStart) {
@@ -1187,7 +1216,7 @@ export function DocumentEditor({
               }
             }}
           />
-          <ReadOnlyTitlePlugin title={directive.title} />
+          <ReadOnlyTitlePlugin title={title} />
           <HistoryPlugin />
           <GoalChangePlugin onGoalChange={handleGoalChange} />
           <CountdownKeyBridge onEsc={cancelCountdown} onUndo={cancelCountdown} />
-- 
cgit v1.2.3

