From 8e2bbcab1a7b3b9005803d7ce3bfce7fa483a4d7 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Sat, 16 May 2026 18:04:17 +0100
Subject: fix(directives): tasks folder visibility, circular auto-save timer,
 unlock-to-edit (#133)

* feat: soryu - makima: Fix tasks/ folder visibility and rename for multi-contract directives

* feat: soryu - makima: Replace auto-save countdown text/bar with a circular timer

* feat: soryu - makima: Require Unlock before editing a locked contract body
---
 .../src/components/directives/DocumentEditor.tsx   | 282 +++++++++++++++++----
 1 file changed, 236 insertions(+), 46 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 661665c..0d6a391 100644
--- a/makima/frontend/src/components/directives/DocumentEditor.tsx
+++ b/makima/frontend/src/components/directives/DocumentEditor.tsx
@@ -69,8 +69,6 @@ import {
  */
 const COUNTDOWN_FRESH_MS = 60_000;
 const COUNTDOWN_RUNNING_MS = 10_000;
-/** The countdown bar only appears once we're inside this many ms from firing. */
-const BAR_VISIBLE_MS = 10_000;
 const SAVED_TOAST_MS = 1200;
 /**
  * Drafts are written synchronously to localStorage on every keystroke. We used
@@ -722,9 +720,66 @@ function EditorContextMenu({
 // Countdown bar
 // =============================================================================
 
+/**
+ * Compact circular countdown indicator. The ring fills (clockwise from 12
+ * o'clock) as `progress` approaches 1. Uses `currentColor` so the parent's
+ * text tone drives both the rest of the row and the ring.
+ */
+function CircularTimer({
+  progress,
+  size = 14,
+  stroke = 2,
+  className,
+  title,
+}: {
+  progress: number;
+  size?: number;
+  stroke?: number;
+  className?: string;
+  title?: string;
+}) {
+  const r = (size - stroke) / 2;
+  const c = 2 * Math.PI * r;
+  const dash = Math.max(0, Math.min(1, progress)) * c;
+  return (
+    <svg
+      width={size}
+      height={size}
+      viewBox={`0 0 ${size} ${size}`}
+      className={className}
+      aria-hidden
+    >
+      {title ? <title>{title}</title> : null}
+      <circle
+        cx={size / 2}
+        cy={size / 2}
+        r={r}
+        fill="none"
+        stroke="currentColor"
+        strokeOpacity={0.25}
+        strokeWidth={stroke}
+      />
+      <circle
+        cx={size / 2}
+        cy={size / 2}
+        r={r}
+        fill="none"
+        stroke="currentColor"
+        strokeWidth={stroke}
+        strokeDasharray={`${dash} ${c}`}
+        strokeLinecap="round"
+        transform={`rotate(-90 ${size / 2} ${size / 2})`}
+      />
+    </svg>
+  );
+}
+
 interface SaveCountdownBarProps {
   state: "idle" | "dirty" | "pending" | "saving" | "saved" | "error";
   remainingMs: number;
+  /** Total countdown window (varies by orchestrator state). Used to compute
+   *  the circular timer's fill ratio. */
+  countdownMs: number;
   liveStart: boolean;
   orchestratorRunning: boolean;
   draftSavedAt: number | null;
@@ -736,6 +791,7 @@ interface SaveCountdownBarProps {
 function SaveCountdownBar({
   state,
   remainingMs,
+  countdownMs,
   liveStart,
   orchestratorRunning,
   draftSavedAt,
@@ -747,46 +803,65 @@ function SaveCountdownBar({
   // observe save state at all times — and to have a "Save now" button they
   // can hit without waiting for the countdown.
 
-  let label: string;
-  let progressPct = 0;
   let tone = "border-[rgba(117,170,252,0.3)] text-[#9bc3ff]";
+  if (state === "saving" || state === "saved") {
+    tone = "border-emerald-700 text-emerald-300";
+  } else if (state === "error") {
+    tone = "border-red-700 text-red-300";
+  } else if (state === "idle") {
+    tone = "border-[rgba(117,170,252,0.2)] text-[#7788aa]";
+  }
 
+  // Left-side primary indicator. For `pending` and `saving`, the circular
+  // timer is the visual; the text next to it is short and neutral. For
+  // every other state, we render plain text only.
+  let primary: React.ReactNode;
   if (state === "pending") {
+    const ringProgress =
+      countdownMs > 0
+        ? Math.max(0, Math.min(1, 1 - remainingMs / countdownMs))
+        : 0;
     const seconds = Math.max(0, Math.ceil(remainingMs / 1000));
-    // Show ticking countdown in the last 10s, otherwise a quieter label.
-    if (remainingMs <= BAR_VISIBLE_MS) {
+    const tipBase = orchestratorRunning
+      ? `Replans in ${seconds}s — Esc/Undo cancels`
+      : `Auto-saves in ${seconds}s — Esc/Undo cancels`;
+    const ringLabel = orchestratorRunning ? "Replan" : "Auto-save";
+    primary = (
+      <span
+        className="flex items-center gap-1.5 text-[10px] font-mono flex-1 min-w-0 truncate"
+        title={tipBase}
+      >
+        <CircularTimer
+          progress={ringProgress}
+          className="shrink-0"
+          title={tipBase}
+        />
+        <span className="truncate">{ringLabel}</span>
+      </span>
+    );
+  } else if (state === "saving") {
+    primary = (
+      <span className="flex items-center gap-1.5 text-[10px] font-mono flex-1 min-w-0 truncate">
+        <CircularTimer progress={1} className="shrink-0 animate-pulse" />
+        <span className="truncate">Saving…</span>
+      </span>
+    );
+  } else {
+    let label: string;
+    if (state === "dirty") {
       label = orchestratorRunning
-        ? `Replanning in ${seconds}s — Esc/Undo cancels.`
-        : `Saving in ${seconds}s — Esc/Undo cancels.`;
-      progressPct = Math.max(
-        0,
-        Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100),
-      );
+        ? "Unsaved changes — saving will replan the contract."
+        : "Unsaved changes.";
+    } else if (state === "saved") {
+      label = "Saved";
+    } else if (state === "error") {
+      label = "Save failed — try again.";
     } else {
-      label = "Unsaved changes — auto-save soon.";
-      progressPct = 0;
+      label = "Up to date.";
     }
-  } else if (state === "dirty") {
-    label = orchestratorRunning
-      ? "Unsaved changes — saving will replan the contract."
-      : "Unsaved changes.";
-    progressPct = 0;
-  } else if (state === "saving") {
-    label = "Saving…";
-    progressPct = 100;
-    tone = "border-emerald-700 text-emerald-300";
-  } else if (state === "saved") {
-    label = "Saved";
-    progressPct = 100;
-    tone = "border-emerald-700 text-emerald-300";
-  } else if (state === "error") {
-    label = "Save failed — try again.";
-    progressPct = 100;
-    tone = "border-red-700 text-red-300";
-  } else {
-    label = "Up to date.";
-    progressPct = 0;
-    tone = "border-[rgba(117,170,252,0.2)] text-[#7788aa]";
+    primary = (
+      <span className="text-[10px] font-mono flex-1 truncate">{label}</span>
+    );
   }
 
   // Right-side "Draft saved Xs ago" stamp — re-renders on a 1Hz ticker so
@@ -801,14 +876,8 @@ function SaveCountdownBar({
       className={`shrink-0 border-t border-dashed ${tone} bg-[#0a1628]`}
       data-makima-countdown={state}
     >
-      <div className="h-0.5 bg-[#10203a]">
-        <div
-          className="h-full bg-[#75aafc] transition-[width] duration-100 ease-linear"
-          style={{ width: `${progressPct}%` }}
-        />
-      </div>
       <div className="flex items-center gap-3 px-4 py-1.5">
-        <span className="text-[10px] font-mono flex-1 truncate">{label}</span>
+        {primary}
 
         {draftLabel && (
           <span
@@ -877,6 +946,19 @@ export interface DocumentEditorProps {
   onCleanup: () => Promise<void> | void;
   onCreatePR: () => Promise<void> | void;
   onPickUpOrders: () => Promise<unknown> | unknown;
+  /**
+   * Whether the body content is currently editable. Locked (non-`draft`)
+   * contracts pass `false`, which puts Lexical into read-only mode, disables
+   * the autosave state machine, and surfaces the "Unlock to edit" affordance.
+   */
+  editable: boolean;
+  /**
+   * Invoked when the user clicks the inline "Unlock" affordance shown above
+   * the body for non-`draft` contracts. The caller is expected to flip the
+   * contract status back to `draft` (typically via `unlockDirectiveContract`)
+   * and refresh the document so this component receives `editable=true`.
+   */
+  onRequestUnlock: () => void | Promise<void>;
 }
 
 type SaveState = "idle" | "dirty" | "pending" | "saving" | "saved" | "error";
@@ -890,8 +972,14 @@ export function DocumentEditor({
   onCleanup,
   onCreatePR,
   onPickUpOrders,
+  editable,
+  onRequestUnlock,
 }: DocumentEditorProps) {
   // ---- Lexical config ----------------------------------------------------
+  // NOTE: `editable` is only read by Lexical at composer mount time. Since
+  // we re-key the composer by `documentId` (not status), we ALSO mirror the
+  // `editable` prop into the live editor via <EditorEditableSync> below on
+  // every change.
   const initialConfig = useMemo(
     () => ({
       // Re-key the composer when the directive id changes so we get a clean
@@ -904,8 +992,9 @@ export function DocumentEditor({
       },
       nodes: [HeadingNode, ListNode, ListItemNode, StepsBlockNode],
       theme: editorTheme,
-      editable: true,
+      editable,
     }),
+    // eslint-disable-next-line react-hooks/exhaustive-deps
     [documentId],
   );
 
@@ -1148,6 +1237,12 @@ export function DocumentEditor({
 
   const handleGoalChange = useCallback(
     (goal: string) => {
+      // When the body is locked (contract is not in `draft`), Lexical's
+      // setEditable(false) blocks user input, but the OnChangePlugin can
+      // still fire for programmatic seeds. Ignore everything in that case
+      // so we don't kick the autosave state machine.
+      if (!editable) return;
+
       pendingGoalRef.current = goal;
 
       // 1. Always persist work-in-progress to localStorage IMMEDIATELY so
@@ -1185,9 +1280,21 @@ export function DocumentEditor({
         setSaveState("dirty");
       }
     },
-    [body, documentId, liveStart, saveState, startOrExtendCountdown],
+    [body, documentId, editable, liveStart, saveState, startOrExtendCountdown],
   );
 
+  // When the body becomes locked (contract status leaves `draft`), tear
+  // down any in-flight autosave: timers off, state back to idle. The
+  // existing per-keystroke localStorage draft remains untouched — it'll
+  // re-hydrate the editor if/when the user unlocks again.
+  useEffect(() => {
+    if (!editable) {
+      cancelTimers();
+      setSaveState("idle");
+      setRemainingMs(countdownMs);
+    }
+  }, [editable, countdownMs]);
+
   // ---- Right-click context menu -----------------------------------------
   const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
 
@@ -1196,6 +1303,35 @@ export function DocumentEditor({
     setMenu({ x: e.clientX, y: e.clientY });
   }, []);
 
+  // ---- Unlock affordance state -----------------------------------------
+  // When the contract is locked (non-`draft`), the body shows an inline
+  // banner with an Unlock button that calls back into the parent. We
+  // surface any error from the unlock API inline rather than throwing —
+  // mirrors the inline error pattern in ContractHeader.
+  const [unlockBusy, setUnlockBusy] = useState(false);
+  const [unlockError, setUnlockError] = useState<string | null>(null);
+
+  // Clear any stale unlock error once the body becomes editable again.
+  useEffect(() => {
+    if (editable) {
+      setUnlockError(null);
+      setUnlockBusy(false);
+    }
+  }, [editable]);
+
+  const handleUnlockClick = useCallback(async () => {
+    if (unlockBusy) return;
+    setUnlockBusy(true);
+    setUnlockError(null);
+    try {
+      await onRequestUnlock();
+    } catch (e) {
+      setUnlockError(e instanceof Error ? e.message : "Unlock failed");
+    } finally {
+      setUnlockBusy(false);
+    }
+  }, [onRequestUnlock, unlockBusy]);
+
   // ---- Render ------------------------------------------------------------
   return (
     <div className="flex flex-col h-full overflow-hidden">
@@ -1203,12 +1339,19 @@ export function DocumentEditor({
         <LexicalComposer key={documentId} initialConfig={initialConfig}>
           {/* Capture the editor ref via a tiny inline plugin */}
           <EditorRefCapture editorRef={editorRef} />
+          {/* Keep Lexical's editable flag in sync with the parent prop. */}
+          <EditorEditableSync editable={editable} />
           <SeedContentPlugin
             documentId={documentId}
             title={title}
             body={body}
             onDraftRestored={(draft) => {
               pendingGoalRef.current = draft;
+              // Don't kick the autosave state machine if the body is
+              // currently locked — the lock-watching useEffect would just
+              // tear it back down on the next render, and we don't want
+              // a flash of "pending" state under a locked banner.
+              if (!editable) return;
               if (liveStart) {
                 startOrExtendCountdown();
               } else {
@@ -1228,10 +1371,33 @@ export function DocumentEditor({
           <FloatingFormatToolbar />
 
           <div
-            className="flex-1 overflow-auto"
+            className={`flex-1 overflow-auto ${!editable ? "bg-[rgba(120,80,0,0.04)]" : ""}`}
             onContextMenu={handleContextMenu}
           >
             <div className="max-w-3xl mx-auto px-8 py-10">
+              {!editable && (
+                <div
+                  className="mb-4 flex items-center gap-3 border border-amber-700/60 bg-amber-900/10 rounded px-3 py-2 text-[11px] font-mono text-amber-300"
+                  data-makima-locked-banner
+                >
+                  <span className="flex-1">
+                    Locked — unlock to edit.
+                  </span>
+                  {unlockError && (
+                    <span className="text-red-400" title={unlockError}>
+                      {unlockError}
+                    </span>
+                  )}
+                  <button
+                    type="button"
+                    onClick={() => void handleUnlockClick()}
+                    disabled={unlockBusy}
+                    className="px-2 py-0.5 border border-amber-700/60 rounded text-amber-200 hover:text-white hover:bg-amber-900/30 disabled:opacity-50 disabled:cursor-not-allowed uppercase tracking-wide"
+                  >
+                    {unlockBusy ? "Unlocking…" : "Unlock"}
+                  </button>
+                </div>
+              )}
               <RichTextPlugin
                 contentEditable={
                   <ContentEditable
@@ -1241,7 +1407,16 @@ export function DocumentEditor({
                         Describe the contract's goal…
                       </div>
                     }
-                    className="outline-none font-mono text-[13px] leading-relaxed text-[#dbe7ff] [&_.makima-doc-h1]:text-[24px] [&_.makima-doc-h1]:font-medium [&_.makima-doc-h1]:text-white [&_.makima-doc-h1]:mb-3 [&_.makima-doc-h1]:tracking-tight [&_.makima-doc-paragraph]:my-2 [&_.makima-doc-paragraph]:text-[13px] [&_.makima-doc-paragraph]:text-[#c0d0e0] relative"
+                    // When locked, dim the text and switch the caret to the
+                    // default arrow so the body visually reads as read-only.
+                    // Lexical's setEditable(false) already blocks input; this
+                    // is the cosmetic layer.
+                    style={!editable ? { cursor: "default" } : undefined}
+                    className={`outline-none font-mono text-[13px] leading-relaxed [&_.makima-doc-h1]:text-[24px] [&_.makima-doc-h1]:font-medium [&_.makima-doc-h1]:mb-3 [&_.makima-doc-h1]:tracking-tight [&_.makima-doc-paragraph]:my-2 [&_.makima-doc-paragraph]:text-[13px] relative ${
+                      editable
+                        ? "text-[#dbe7ff] [&_.makima-doc-h1]:text-white [&_.makima-doc-paragraph]:text-[#c0d0e0]"
+                        : "text-[#7a8aa0] [&_.makima-doc-h1]:text-[#a8b8d0] [&_.makima-doc-paragraph]:text-[#7a8aa0] select-text"
+                    }`}
                   />
                 }
                 ErrorBoundary={LexicalErrorBoundary}
@@ -1254,6 +1429,7 @@ export function DocumentEditor({
       <SaveCountdownBar
         state={saveState}
         remainingMs={remainingMs}
+        countdownMs={countdownMs}
         liveStart={liveStart}
         orchestratorRunning={orchestratorRunning}
         draftSavedAt={draftSavedAt}
@@ -1303,6 +1479,20 @@ function EditorRefCapture({
   return null;
 }
 
+/**
+ * Mirrors the `editable` prop onto the live Lexical editor instance. Lexical
+ * only reads `editable` from the initial config at mount; this plugin keeps
+ * the runtime state in sync as the parent's contract status flips between
+ * `draft` (editable) and the locked statuses (read-only).
+ */
+function EditorEditableSync({ editable }: { editable: boolean }) {
+  const [editor] = useLexicalComposerContext();
+  useEffect(() => {
+    editor.setEditable(editable);
+  }, [editor, editable]);
+  return null;
+}
+
 // Re-export the steps-block helpers so consumers can include the node class
 // in their own initial configs if needed.
 export { $createStepsBlockNode, $isStepsBlockNode, StepsBlockNode };
-- 
cgit v1.2.3

