summaryrefslogtreecommitdiff
path: root/makima/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src')
-rw-r--r--makima/frontend/src/components/directives/DocumentEditor.tsx664
-rw-r--r--makima/frontend/src/components/directives/StepsBlockNode.tsx281
-rw-r--r--makima/frontend/src/hooks/useUserSettings.ts115
-rw-r--r--makima/frontend/src/lib/api.ts46
-rw-r--r--makima/frontend/src/routes/directives.tsx33
-rw-r--r--makima/frontend/src/routes/document-directives.tsx394
-rw-r--r--makima/frontend/src/routes/settings.tsx64
7 files changed, 1597 insertions, 0 deletions
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx
new file mode 100644
index 0000000..40fccf1
--- /dev/null
+++ b/makima/frontend/src/components/directives/DocumentEditor.tsx
@@ -0,0 +1,664 @@
+/**
+ * DocumentEditor — the Lexical-based document body for Document Mode.
+ *
+ * Layout (top to bottom):
+ * - Read-only H1 with the directive's title.
+ * - Editable paragraph with the directive's goal. Editing the goal triggers
+ * a 3-second countdown bar at the bottom of the editor; if the timer
+ * expires, we call updateGoal(). Esc / ⌘Z cancels; further typing extends.
+ * - A custom non-editable StepsBlock decorator node showing each step.
+ *
+ * Right-click anywhere in the editor opens a custom context menu offering
+ * the three directive-level actions: Clean Up, Update PR, Plan Orders.
+ *
+ * Live updates:
+ * - The directive prop is updated by the parent's useDirective polling.
+ * - StepsBlockContextProvider wraps the LexicalComposer so that the steps
+ * block (a Lexical DecoratorNode) sees fresh data on every render
+ * without us having to mutate the editor state.
+ */
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $isElementNode,
+ COMMAND_PRIORITY_LOW,
+ KEY_ESCAPE_COMMAND,
+ UNDO_COMMAND,
+ type LexicalEditor,
+} from "lexical";
+import { $createHeadingNode, HeadingNode } from "@lexical/rich-text";
+import { ListNode, ListItemNode } from "@lexical/list";
+import { LexicalComposer } from "@lexical/react/LexicalComposer";
+import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
+import { ContentEditable } from "@lexical/react/LexicalContentEditable";
+import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
+import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
+import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
+import type { DirectiveWithSteps } from "../../lib/api";
+import {
+ $createStepsBlockNode,
+ $isStepsBlockNode,
+ StepsBlockNode,
+ StepsBlockContextProvider,
+} from "./StepsBlockNode";
+
+// =============================================================================
+// Constants
+// =============================================================================
+
+const SAVE_COUNTDOWN_MS = 3000;
+const SAVED_TOAST_MS = 1200;
+
+// =============================================================================
+// Editor theme — minimal, just enough so the rich-text plugin has something to
+// hang class names on. We rely on our own typography otherwise.
+// =============================================================================
+
+const editorTheme = {
+ paragraph: "makima-doc-paragraph",
+ heading: {
+ h1: "makima-doc-h1",
+ h2: "makima-doc-h2",
+ },
+ text: {
+ bold: "font-bold",
+ italic: "italic",
+ underline: "underline",
+ },
+};
+
+// =============================================================================
+// Plugins
+// =============================================================================
+
+/**
+ * (Re)builds the editor's root content from the directive whenever the
+ * directive ID changes. We keep this controlled so that switching documents
+ * resets the editor cleanly.
+ *
+ * We deliberately do NOT re-seed on every directive update — only on id
+ * change — so the user's in-flight goal edits aren't trampled by a poll that
+ * happens mid-keystroke.
+ */
+function SeedContentPlugin({
+ directive,
+}: {
+ directive: DirectiveWithSteps;
+}) {
+ const [editor] = useLexicalComposerContext();
+ const seededIdRef = useRef<string | null>(null);
+
+ useEffect(() => {
+ if (seededIdRef.current === directive.id) return;
+ seededIdRef.current = directive.id;
+
+ editor.update(
+ () => {
+ const root = $getRoot();
+ root.clear();
+
+ // H1: title (read-only — see ReadOnlyTitlePlugin).
+ const heading = $createHeadingNode("h1");
+ heading.append($createTextNode(directive.title));
+ root.append(heading);
+
+ // Paragraph: goal (editable).
+ const goalPara = $createParagraphNode();
+ if (directive.goal.length > 0) {
+ goalPara.append($createTextNode(directive.goal));
+ }
+ root.append(goalPara);
+
+ // Steps block (decorator — non-editable).
+ root.append($createStepsBlockNode());
+
+ // Trailing empty paragraph so the cursor has somewhere to land below
+ // the steps block.
+ root.append($createParagraphNode());
+ },
+ { tag: "history-merge" },
+ );
+ }, [editor, directive.id, directive.title, directive.goal]);
+
+ return null;
+}
+
+/**
+ * Prevents edits to the H1 (title) node. The title is meant to feel like a
+ * file name — clicking it shows a caret, but typing is no-op'd. We watch the
+ * editor's update stream and, if the H1's text drifts from the seed value,
+ * revert it on the next microtask so the user sees the change get rejected
+ * rather than silently swallowed.
+ */
+function ReadOnlyTitlePlugin({ title }: { title: string }) {
+ const [editor] = useLexicalComposerContext();
+
+ useEffect(() => {
+ let reverting = false;
+ return editor.registerUpdateListener(({ editorState }) => {
+ if (reverting) return;
+ editorState.read(() => {
+ const root = $getRoot();
+ const first = root.getFirstChild();
+ if (!first || first.getType() !== "heading") return;
+ const text = first.getTextContent();
+ if (text === title) return;
+ reverting = true;
+ queueMicrotask(() => {
+ editor.update(
+ () => {
+ const r = $getRoot();
+ const h = r.getFirstChild();
+ if (!h || h.getType() !== "heading") {
+ reverting = false;
+ return;
+ }
+ if ($isElementNode(h) && h.getTextContent() !== title) {
+ h.getChildren().forEach((c) => c.remove());
+ if (title.length > 0) {
+ h.append($createTextNode(title));
+ }
+ }
+ reverting = false;
+ },
+ { tag: "history-merge" },
+ );
+ });
+ });
+ });
+ }, [editor, title]);
+
+ return null;
+}
+
+/**
+ * Watches the goal paragraph (the second top-level child) and reports its
+ * current text to the parent on every change. Kept separate from the
+ * countdown bar so the bar is purely a UI concern.
+ */
+function GoalChangePlugin({
+ onGoalChange,
+}: {
+ onGoalChange: (goal: string) => void;
+}) {
+ return (
+ <OnChangePlugin
+ ignoreSelectionChange
+ onChange={(editorState) => {
+ editorState.read(() => {
+ const root = $getRoot();
+ const children = root.getChildren();
+ // The goal lives at index 1 (after the H1 title).
+ const goalNode = children[1];
+ if (!goalNode) return;
+ onGoalChange(goalNode.getTextContent());
+ });
+ }}
+ />
+ );
+}
+
+/**
+ * Wires Lexical commands into UI callbacks. We forward Esc presses (used to
+ * cancel the countdown) and Undo (⌘/Ctrl-Z) without intercepting them.
+ */
+function CountdownKeyBridge({
+ onEsc,
+ onUndo,
+}: {
+ onEsc: () => void;
+ onUndo: () => void;
+}) {
+ const [editor] = useLexicalComposerContext();
+ useEffect(() => {
+ const unEsc = editor.registerCommand(
+ KEY_ESCAPE_COMMAND,
+ () => {
+ onEsc();
+ return false; // don't consume; let other handlers run
+ },
+ COMMAND_PRIORITY_LOW,
+ );
+ const unUndo = editor.registerCommand(
+ UNDO_COMMAND,
+ () => {
+ onUndo();
+ return false;
+ },
+ COMMAND_PRIORITY_LOW,
+ );
+ return () => {
+ unEsc();
+ unUndo();
+ };
+ }, [editor, onEsc, onUndo]);
+ return null;
+}
+
+// =============================================================================
+// Right-click context menu
+// =============================================================================
+
+interface EditorContextMenuProps {
+ x: number;
+ y: number;
+ onClose: () => void;
+ onCleanup: () => void;
+ onUpdatePR: () => void;
+ onPlanOrders: () => void;
+}
+
+function EditorContextMenu({
+ x,
+ y,
+ onClose,
+ onCleanup,
+ onUpdatePR,
+ onPlanOrders,
+}: EditorContextMenuProps) {
+ const ref = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ const handleClick = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) onClose();
+ };
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ document.addEventListener("mousedown", handleClick);
+ document.addEventListener("keydown", handleKey);
+ return () => {
+ document.removeEventListener("mousedown", handleClick);
+ document.removeEventListener("keydown", handleKey);
+ };
+ }, [onClose]);
+
+ // Clamp into viewport.
+ useEffect(() => {
+ if (!ref.current) return;
+ const rect = ref.current.getBoundingClientRect();
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ if (rect.right > vw) ref.current.style.left = `${x - rect.width}px`;
+ if (rect.bottom > vh) ref.current.style.top = `${y - rect.height}px`;
+ }, [x, y]);
+
+ const item =
+ "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2";
+
+ return (
+ <div
+ ref={ref}
+ className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]"
+ style={{ left: x, top: y }}
+ >
+ <div className="px-3 py-1.5 text-[10px] font-mono text-[#556677] uppercase border-b border-[rgba(117,170,252,0.2)]">
+ Document
+ </div>
+ <button
+ type="button"
+ className={item}
+ onClick={() => {
+ onCleanup();
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">⎚</span>
+ Clean Up
+ </button>
+ <button
+ type="button"
+ className={item}
+ onClick={() => {
+ onUpdatePR();
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">↗</span>
+ Update PR
+ </button>
+ <button
+ type="button"
+ className={item}
+ onClick={() => {
+ onPlanOrders();
+ onClose();
+ }}
+ >
+ <span className="text-[#c084fc]">◆</span>
+ Plan Orders
+ </button>
+ </div>
+ );
+}
+
+// =============================================================================
+// Countdown bar
+// =============================================================================
+
+interface SaveCountdownBarProps {
+ state: "idle" | "pending" | "saving" | "saved" | "error";
+ remainingMs: number;
+ totalMs: number;
+ onCancel: () => void;
+}
+
+function SaveCountdownBar({
+ state,
+ remainingMs,
+ totalMs,
+ onCancel,
+}: SaveCountdownBarProps) {
+ if (state === "idle") return null;
+
+ let label: string;
+ let progressPct = 0;
+ let tone = "border-[rgba(117,170,252,0.3)] text-[#9bc3ff]";
+
+ if (state === "pending") {
+ const seconds = Math.max(0, Math.ceil(remainingMs / 1000));
+ label = `Saving goal in ${seconds}s — press Esc or Undo to cancel.`;
+ progressPct = Math.max(0, Math.min(100, (1 - remainingMs / totalMs) * 100));
+ } 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 {
+ label = "Save failed — try again.";
+ progressPct = 100;
+ tone = "border-red-700 text-red-300";
+ }
+
+ return (
+ <div
+ 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 justify-between px-4 py-1.5">
+ <span className="text-[10px] font-mono">{label}</span>
+ {state === "pending" && (
+ <button
+ type="button"
+ onClick={onCancel}
+ className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5"
+ >
+ Cancel
+ </button>
+ )}
+ </div>
+ </div>
+ );
+}
+
+// =============================================================================
+// Main component
+// =============================================================================
+
+export interface DocumentEditorProps {
+ directive: DirectiveWithSteps;
+ onUpdateGoal: (goal: string) => Promise<void> | void;
+ onCleanup: () => Promise<void> | void;
+ onCreatePR: () => Promise<void> | void;
+ onPickUpOrders: () => Promise<unknown> | unknown;
+}
+
+type SaveState = "idle" | "pending" | "saving" | "saved" | "error";
+
+export function DocumentEditor({
+ directive,
+ onUpdateGoal,
+ onCleanup,
+ onCreatePR,
+ onPickUpOrders,
+}: DocumentEditorProps) {
+ // ---- Lexical config ----------------------------------------------------
+ const initialConfig = useMemo(
+ () => ({
+ // 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}`,
+ onError: (err: Error) => {
+ // eslint-disable-next-line no-console
+ console.error("[DocumentEditor]", err);
+ },
+ nodes: [HeadingNode, ListNode, ListItemNode, StepsBlockNode],
+ theme: editorTheme,
+ editable: true,
+ }),
+ [directive.id],
+ );
+
+ // ---- Goal auto-save state machine --------------------------------------
+ const [saveState, setSaveState] = useState<SaveState>("idle");
+ const [remainingMs, setRemainingMs] = useState(SAVE_COUNTDOWN_MS);
+ const pendingGoalRef = useRef<string>(directive.goal);
+ const timerRef = useRef<number | null>(null);
+ const tickRef = useRef<number | null>(null);
+ const deadlineRef = useRef<number>(0);
+ const editorRef = useRef<LexicalEditor | null>(null);
+
+ // Reset state when switching directives.
+ useEffect(() => {
+ pendingGoalRef.current = directive.goal;
+ cancelTimers();
+ setSaveState("idle");
+ setRemainingMs(SAVE_COUNTDOWN_MS);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [directive.id]);
+
+ // If the persisted goal updated externally and matches the pending goal,
+ // settle the bar.
+ useEffect(() => {
+ if (saveState === "pending" && pendingGoalRef.current === directive.goal) {
+ cancelTimers();
+ setSaveState("idle");
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [directive.goal]);
+
+ function cancelTimers() {
+ if (timerRef.current != null) {
+ window.clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ if (tickRef.current != null) {
+ window.clearInterval(tickRef.current);
+ tickRef.current = null;
+ }
+ }
+
+ const fireSave = useCallback(async () => {
+ const next = pendingGoalRef.current;
+ cancelTimers();
+ setSaveState("saving");
+ try {
+ await onUpdateGoal(next);
+ setSaveState("saved");
+ window.setTimeout(() => {
+ // Only fade if no new edit has reopened a pending state in the meantime.
+ setSaveState((s) => (s === "saved" ? "idle" : s));
+ }, SAVED_TOAST_MS);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error("Failed to save goal", e);
+ setSaveState("error");
+ window.setTimeout(() => {
+ setSaveState((s) => (s === "error" ? "idle" : s));
+ }, 2500);
+ }
+ }, [onUpdateGoal]);
+
+ const startOrExtendCountdown = useCallback(() => {
+ cancelTimers();
+ deadlineRef.current = Date.now() + SAVE_COUNTDOWN_MS;
+ setSaveState("pending");
+ setRemainingMs(SAVE_COUNTDOWN_MS);
+ tickRef.current = window.setInterval(() => {
+ const remaining = Math.max(0, deadlineRef.current - Date.now());
+ setRemainingMs(remaining);
+ if (remaining <= 0 && tickRef.current != null) {
+ window.clearInterval(tickRef.current);
+ tickRef.current = null;
+ }
+ }, 100);
+ timerRef.current = window.setTimeout(() => {
+ void fireSave();
+ }, SAVE_COUNTDOWN_MS);
+ }, [fireSave]);
+
+ const cancelCountdown = useCallback(() => {
+ if (saveState !== "pending") return;
+ cancelTimers();
+ pendingGoalRef.current = directive.goal; // reset pending edit
+ setSaveState("idle");
+ setRemainingMs(SAVE_COUNTDOWN_MS);
+ // Also revert the editor's goal paragraph back to the persisted value, so
+ // the user sees the rollback.
+ const editor = editorRef.current;
+ if (editor) {
+ editor.update(
+ () => {
+ const root = $getRoot();
+ const goalNode = root.getChildren()[1];
+ if (!goalNode || !$isElementNode(goalNode)) return;
+ goalNode.getChildren().forEach((c) => c.remove());
+ if (directive.goal.length > 0) {
+ goalNode.append($createTextNode(directive.goal));
+ }
+ },
+ { tag: "history-merge" },
+ );
+ }
+ }, [directive.goal, saveState]);
+
+ // Cleanup on unmount.
+ useEffect(() => {
+ return cancelTimers;
+ }, []);
+
+ const handleGoalChange = useCallback(
+ (goal: string) => {
+ pendingGoalRef.current = goal;
+ if (goal === directive.goal) {
+ // Edit reverted — cancel the countdown (if any).
+ if (saveState === "pending") {
+ cancelTimers();
+ setSaveState("idle");
+ }
+ return;
+ }
+ startOrExtendCountdown();
+ },
+ [directive.goal, saveState, startOrExtendCountdown],
+ );
+
+ // ---- Right-click context menu -----------------------------------------
+ const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
+
+ const handleContextMenu = useCallback((e: React.MouseEvent) => {
+ e.preventDefault();
+ setMenu({ x: e.clientX, y: e.clientY });
+ }, []);
+
+ // ---- Render ------------------------------------------------------------
+ return (
+ <div className="flex flex-col h-full overflow-hidden">
+ <StepsBlockContextProvider value={{ directive }}>
+ <LexicalComposer key={directive.id} initialConfig={initialConfig}>
+ {/* Capture the editor ref via a tiny inline plugin */}
+ <EditorRefCapture editorRef={editorRef} />
+ <SeedContentPlugin directive={directive} />
+ <ReadOnlyTitlePlugin title={directive.title} />
+ <HistoryPlugin />
+ <GoalChangePlugin onGoalChange={handleGoalChange} />
+ <CountdownKeyBridge onEsc={cancelCountdown} onUndo={cancelCountdown} />
+
+ <div
+ className="flex-1 overflow-auto"
+ onContextMenu={handleContextMenu}
+ >
+ <div className="max-w-3xl mx-auto px-8 py-10">
+ <RichTextPlugin
+ contentEditable={
+ <ContentEditable
+ aria-placeholder="Describe the directive's goal…"
+ placeholder={
+ <div className="pointer-events-none absolute text-[#445566] font-mono text-[13px] mt-2">
+ Describe the directive'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"
+ />
+ }
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ </div>
+ </div>
+ </LexicalComposer>
+ </StepsBlockContextProvider>
+
+ <SaveCountdownBar
+ state={saveState}
+ remainingMs={remainingMs}
+ totalMs={SAVE_COUNTDOWN_MS}
+ onCancel={cancelCountdown}
+ />
+
+ {menu && (
+ <EditorContextMenu
+ x={menu.x}
+ y={menu.y}
+ onClose={() => setMenu(null)}
+ onCleanup={() => {
+ void onCleanup();
+ }}
+ onUpdatePR={() => {
+ void onCreatePR();
+ }}
+ onPlanOrders={() => {
+ void onPickUpOrders();
+ }}
+ />
+ )}
+ </div>
+ );
+}
+
+/**
+ * Tiny plugin that stashes the LexicalEditor instance on a ref so the parent
+ * component can issue updates from outside (e.g. to revert the goal on cancel).
+ */
+function EditorRefCapture({
+ editorRef,
+}: {
+ editorRef: React.MutableRefObject<LexicalEditor | null>;
+}) {
+ const [editor] = useLexicalComposerContext();
+ useEffect(() => {
+ editorRef.current = editor;
+ return () => {
+ if (editorRef.current === editor) {
+ editorRef.current = null;
+ }
+ };
+ }, [editor, editorRef]);
+ 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 };
diff --git a/makima/frontend/src/components/directives/StepsBlockNode.tsx b/makima/frontend/src/components/directives/StepsBlockNode.tsx
new file mode 100644
index 0000000..ab3d7da
--- /dev/null
+++ b/makima/frontend/src/components/directives/StepsBlockNode.tsx
@@ -0,0 +1,281 @@
+/**
+ * StepsBlockNode — a Lexical DecoratorNode that renders the directive's steps
+ * as an in-document, non-editable diagram.
+ *
+ * The actual data (steps, orchestratorTaskId, etc.) does NOT live on the node
+ * itself — that would require us to dispatch a Lexical update on every poll,
+ * which is wasteful and fights against Lexical's content-equality model. The
+ * node is a marker that says "render the steps block here", and the React
+ * component pulls live data from a context provided by DocumentEditor. So when
+ * `useDirective` polls and produces new steps, the StepsBlock re-renders
+ * automatically without touching the editor state at all.
+ */
+import {
+ DecoratorNode,
+ type LexicalNode,
+ type NodeKey,
+ type SerializedLexicalNode,
+ type Spread,
+} from "lexical";
+import { createContext, useContext, type JSX } from "react";
+import type { DirectiveStep, DirectiveWithSteps, StepStatus } from "../../lib/api";
+
+// =============================================================================
+// Context provided by DocumentEditor — the StepsBlock reads live directive data
+// =============================================================================
+
+interface StepsBlockContextValue {
+ directive: DirectiveWithSteps | null;
+}
+
+const StepsBlockContext = createContext<StepsBlockContextValue>({ directive: null });
+
+export const StepsBlockContextProvider = StepsBlockContext.Provider;
+
+// =============================================================================
+// Status palette (matches StepNode.tsx for consistency)
+// =============================================================================
+
+const STATUS_COLORS: Record<StepStatus, { bg: string; border: string; text: string; pill: string }> = {
+ pending: {
+ bg: "bg-[#1a2540]",
+ border: "border-[#2a3a5a]",
+ text: "text-[#7788aa]",
+ pill: "bg-[#0f1a30] text-[#7788aa] border-[#2a3a5a]",
+ },
+ ready: {
+ bg: "bg-[#2a2a10]",
+ border: "border-[#4a4a20]",
+ text: "text-yellow-400",
+ pill: "bg-[#1a1a08] text-yellow-300 border-[#4a4a20]",
+ },
+ running: {
+ bg: "bg-[#0a2a1a]",
+ border: "border-[#1a5a3a]",
+ text: "text-green-400",
+ pill: "bg-[#062014] text-green-300 border-[#1a5a3a]",
+ },
+ completed: {
+ bg: "bg-[#0a2a2a]",
+ border: "border-[#1a5a5a]",
+ text: "text-emerald-400",
+ pill: "bg-[#062424] text-emerald-300 border-[#1a5a5a]",
+ },
+ failed: {
+ bg: "bg-[#2a1a1a]",
+ border: "border-[#5a2a2a]",
+ text: "text-red-400",
+ pill: "bg-[#241010] text-red-300 border-[#5a2a2a]",
+ },
+ skipped: {
+ bg: "bg-[#1a1a2a]",
+ border: "border-[#2a2a4a]",
+ text: "text-[#7788aa]",
+ pill: "bg-[#101020] text-[#7788aa] border-[#2a2a4a]",
+ },
+};
+
+const STATUS_LABEL: Record<StepStatus, string> = {
+ pending: "PENDING",
+ ready: "READY",
+ running: "RUNNING",
+ completed: "DONE",
+ failed: "FAILED",
+ skipped: "SKIP",
+};
+
+// =============================================================================
+// React component rendered inside the editor body
+// =============================================================================
+
+function StepCard({ step }: { step: DirectiveStep }) {
+ const colors = STATUS_COLORS[step.status] ?? STATUS_COLORS.pending;
+ const label = STATUS_LABEL[step.status] ?? step.status.toUpperCase();
+ return (
+ <div
+ className={`${colors.bg} border ${colors.border} rounded px-3 py-2 flex items-start gap-3`}
+ >
+ <span
+ className="text-[10px] font-mono text-[#556677] shrink-0 w-5 text-right"
+ aria-hidden
+ >
+ {String(step.orderIndex + 1).padStart(2, "0")}
+ </span>
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center justify-between gap-2">
+ <span className="text-[12px] font-mono text-white truncate">
+ {step.name}
+ </span>
+ <span
+ className={`text-[9px] font-mono uppercase tracking-wide border rounded px-1.5 py-0.5 shrink-0 ${colors.pill}`}
+ >
+ {label}
+ </span>
+ </div>
+ {step.description && (
+ <p className="text-[10px] font-mono text-[#7788aa] truncate mt-0.5">
+ {step.description}
+ </p>
+ )}
+ </div>
+ </div>
+ );
+}
+
+function StepsBlock(): JSX.Element {
+ const { directive } = useContext(StepsBlockContext);
+
+ // While the directive is loading or absent, render a quiet placeholder so the
+ // editor body still has something visible — but make sure it has the same
+ // outline as the loaded view so the document doesn't reflow.
+ if (!directive) {
+ return (
+ <div
+ contentEditable={false}
+ className="my-3 border border-dashed border-[rgba(117,170,252,0.2)] rounded px-3 py-4 select-none"
+ >
+ <div className="text-[10px] font-mono text-[#556677] uppercase tracking-wide">
+ steps · loading
+ </div>
+ </div>
+ );
+ }
+
+ const steps = [...directive.steps].sort((a, b) => a.orderIndex - b.orderIndex);
+ const isOrchestratorRunning = !!directive.orchestratorTaskId;
+ const completed = steps.filter((s) => s.status === "completed").length;
+ const total = steps.length;
+ const caption = isOrchestratorRunning
+ ? "makima is editing this document"
+ : total === 0
+ ? "no steps yet"
+ : total === 1
+ ? "1 step"
+ : `${total} steps`;
+
+ return (
+ <div
+ // contentEditable={false} keeps Lexical from treating this as editable
+ // content — the user can't put a caret inside it.
+ contentEditable={false}
+ // Use a small data attribute so external CSS / tests can target it.
+ data-makima-block="steps"
+ className="my-3 border border-[rgba(117,170,252,0.2)] rounded bg-[#091428] select-none"
+ >
+ {/* Caption */}
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-dashed border-[rgba(117,170,252,0.15)]">
+ <div className="flex items-center gap-1.5">
+ {isOrchestratorRunning && (
+ <span
+ className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse"
+ aria-hidden
+ />
+ )}
+ <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ {caption}
+ </span>
+ </div>
+ {total > 0 && (
+ <span className="text-[10px] font-mono text-[#556677]">
+ {completed}/{total} done
+ </span>
+ )}
+ </div>
+
+ {/* Step diagram */}
+ {steps.length === 0 ? (
+ <div className="px-3 py-4 text-[11px] font-mono text-[#556677] italic">
+ {isOrchestratorRunning
+ ? "Planner is generating steps…"
+ : "No steps yet — start the directive or plan orders to populate."}
+ </div>
+ ) : (
+ <ol className="px-3 py-3 flex flex-col gap-1.5">
+ {steps.map((step, idx) => (
+ <li key={step.id} className="relative">
+ <StepCard step={step} />
+ {idx < steps.length - 1 && (
+ <div
+ className="absolute left-[18px] -bottom-1 h-1 w-px bg-[rgba(117,170,252,0.2)]"
+ aria-hidden
+ />
+ )}
+ </li>
+ ))}
+ </ol>
+ )}
+ </div>
+ );
+}
+
+// =============================================================================
+// Lexical decorator node
+// =============================================================================
+
+export type SerializedStepsBlockNode = Spread<
+ { /* No fields — the block is a marker; live data comes from context. */ },
+ SerializedLexicalNode
+>;
+
+export class StepsBlockNode extends DecoratorNode<JSX.Element> {
+ static getType(): string {
+ return "makima-steps-block";
+ }
+
+ static clone(node: StepsBlockNode): StepsBlockNode {
+ return new StepsBlockNode(node.__key);
+ }
+
+ constructor(key?: NodeKey) {
+ super(key);
+ }
+
+ createDOM(): HTMLElement {
+ const el = document.createElement("div");
+ el.className = "makima-steps-block-host";
+ return el;
+ }
+
+ updateDOM(): false {
+ return false;
+ }
+
+ static importJSON(_serializedNode: SerializedStepsBlockNode): StepsBlockNode {
+ return $createStepsBlockNode();
+ }
+
+ exportJSON(): SerializedStepsBlockNode {
+ return {
+ type: StepsBlockNode.getType(),
+ version: 1,
+ };
+ }
+
+ isInline(): boolean {
+ return false;
+ }
+
+ isIsolated(): boolean {
+ // Isolated decorator nodes can't be partially selected — the user can only
+ // click into them, not drag a selection into them. That's what we want.
+ return true;
+ }
+
+ isKeyboardSelectable(): boolean {
+ return true;
+ }
+
+ decorate(): JSX.Element {
+ return <StepsBlock />;
+ }
+}
+
+export function $createStepsBlockNode(): StepsBlockNode {
+ return new StepsBlockNode();
+}
+
+export function $isStepsBlockNode(
+ node: LexicalNode | null | undefined,
+): node is StepsBlockNode {
+ return node instanceof StepsBlockNode;
+}
diff --git a/makima/frontend/src/hooks/useUserSettings.ts b/makima/frontend/src/hooks/useUserSettings.ts
new file mode 100644
index 0000000..b39244d
--- /dev/null
+++ b/makima/frontend/src/hooks/useUserSettings.ts
@@ -0,0 +1,115 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ getUserSettings,
+ updateUserSettings,
+ type UserSettings,
+} from "../lib/api";
+
+const DEFAULT_SETTINGS: UserSettings = { documentModeEnabled: false };
+
+// Module-level cache + pub-sub so multiple components mounting the hook stay
+// in sync without a full provider/context. Toggling the flag in <SettingsPage>
+// will reactively update <DirectivesPage> if it's mounted, and vice versa.
+let cachedSettings: UserSettings | null = null;
+let inflight: Promise<void> | null = null;
+const subscribers = new Set<(s: UserSettings | null) => void>();
+
+function notify() {
+ for (const sub of subscribers) sub(cachedSettings);
+}
+
+function loadOnce(): Promise<void> {
+ if (inflight) return inflight;
+ inflight = getUserSettings()
+ .then((s) => {
+ cachedSettings = s;
+ notify();
+ })
+ .catch((err) => {
+ // Swallow but log — fall back to safe defaults so the existing UI keeps
+ // rendering even if /settings endpoint is unavailable.
+ console.error("Failed to load user settings:", err);
+ cachedSettings = DEFAULT_SETTINGS;
+ notify();
+ })
+ .finally(() => {
+ inflight = null;
+ });
+ return inflight;
+}
+
+export interface UseUserSettingsResult {
+ /** Loaded settings, or null while loading for the first time. */
+ settings: UserSettings | null;
+ /** True while the initial GET is in flight. */
+ loading: boolean;
+ /** Update one or more settings; persists via PUT and updates the cache. */
+ update: (patch: Partial<UserSettings>) => Promise<UserSettings>;
+ /** Force a refresh from the server (e.g. after sign-in). */
+ refresh: () => Promise<void>;
+}
+
+/**
+ * React hook for the per-user settings record (feature flags).
+ *
+ * Calls GET /api/v1/users/me/settings on first mount and caches the result.
+ * Subsequent mounts read from the cache. `update()` PUTs to the server and
+ * notifies all live subscribers so UI gates reactively flip without a reload.
+ */
+export function useUserSettings(): UseUserSettingsResult {
+ const [settings, setSettings] = useState<UserSettings | null>(cachedSettings);
+ const [loading, setLoading] = useState<boolean>(cachedSettings === null);
+
+ useEffect(() => {
+ let mounted = true;
+ const sub = (s: UserSettings | null) => {
+ if (!mounted) return;
+ setSettings(s);
+ setLoading(false);
+ };
+ subscribers.add(sub);
+
+ if (cachedSettings === null) {
+ loadOnce();
+ } else {
+ // Already cached — make sure local state matches.
+ setSettings(cachedSettings);
+ setLoading(false);
+ }
+
+ return () => {
+ mounted = false;
+ subscribers.delete(sub);
+ };
+ }, []);
+
+ const update = useCallback(
+ async (patch: Partial<UserSettings>): Promise<UserSettings> => {
+ const base = cachedSettings ?? DEFAULT_SETTINGS;
+ const merged: UserSettings = { ...base, ...patch };
+ // Optimistic update so the toggle flips immediately.
+ cachedSettings = merged;
+ notify();
+ try {
+ const result = await updateUserSettings(merged);
+ cachedSettings = result;
+ notify();
+ return result;
+ } catch (err) {
+ // Roll back to last-known-good on failure.
+ cachedSettings = base;
+ notify();
+ throw err;
+ }
+ },
+ [],
+ );
+
+ const refresh = useCallback(async () => {
+ cachedSettings = null;
+ notify();
+ await loadOnce();
+ }, []);
+
+ return { settings, loading, update, refresh };
+}
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index d597b44..8896f2c 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -1696,6 +1696,52 @@ export async function deleteAccount(
}
// =============================================================================
+// User Settings (per-user feature flags)
+// =============================================================================
+
+/** Per-user settings / feature flags. */
+export interface UserSettings {
+ /** Whether the new "document mode" UI is enabled for this user. */
+ documentModeEnabled: boolean;
+}
+
+/** Request body for updating user settings. */
+export interface UpdateUserSettingsRequest {
+ documentModeEnabled: boolean;
+}
+
+/**
+ * Get the authenticated user's settings (feature flags).
+ */
+export async function getUserSettings(): Promise<UserSettings> {
+ const res = await authFetch(`${API_BASE}/api/v1/users/me/settings`);
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => null);
+ const errorMessage = errorData?.message || res.statusText;
+ throw new Error(errorMessage);
+ }
+ return res.json();
+}
+
+/**
+ * Replace the authenticated user's settings (feature flags).
+ */
+export async function updateUserSettings(
+ req: UpdateUserSettingsRequest
+): Promise<UserSettings> {
+ const res = await authFetch(`${API_BASE}/api/v1/users/me/settings`, {
+ method: "PUT",
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => null);
+ const errorMessage = errorData?.message || res.statusText;
+ throw new Error(errorMessage);
+ }
+ return res.json();
+}
+
+// =============================================================================
// Contract Types for Workflow Management
// =============================================================================
diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx
index 8de0335..895c86a 100644
--- a/makima/frontend/src/routes/directives.tsx
+++ b/makima/frontend/src/routes/directives.tsx
@@ -5,10 +5,43 @@ import { DirectiveList } from "../components/directives/DirectiveList";
import { DirectiveDetail } from "../components/directives/DirectiveDetail";
import { useDirectives, useDirective } from "../hooks/useDirectives";
import { useDogs } from "../hooks/useDogs";
+import { useUserSettings } from "../hooks/useUserSettings";
import { useAuth } from "../contexts/AuthContext";
+import DocumentDirectivesPage from "./document-directives";
import { getRepositorySuggestions, startDirective, pauseDirective, updateDirective, type RepositoryHistoryEntry, type DirectiveSummary } from "../lib/api";
+/**
+ * Top-level /directives route. Gates between the legacy tabular UI and the
+ * Document Mode (POC) UI based on the user's settings flag.
+ *
+ * Both code paths support /directives/:id deep links — the param is read by
+ * each branch independently via useParams.
+ */
export default function DirectivesPage() {
+ const { settings, loading: settingsLoading } = useUserSettings();
+
+ // While settings are loading for the very first time, render nothing inside
+ // a Masthead-wrapped shell so we don't briefly flash the legacy UI just to
+ // swap to document mode a moment later.
+ if (settingsLoading && !settings) {
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
+ </main>
+ </div>
+ );
+ }
+
+ if (settings?.documentModeEnabled) {
+ return <DocumentDirectivesPage />;
+ }
+
+ return <LegacyDirectivesPage />;
+}
+
+function LegacyDirectivesPage() {
const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
const navigate = useNavigate();
const { id: selectedId } = useParams<{ id: string }>();
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
new file mode 100644
index 0000000..42e6a69
--- /dev/null
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -0,0 +1,394 @@
+import { useEffect, useMemo, useState } from "react";
+import { useNavigate, useParams } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { useDirective, useDirectives } from "../hooks/useDirectives";
+import { useAuth } from "../contexts/AuthContext";
+import { DocumentEditor } from "../components/directives/DocumentEditor";
+import type { DirectiveSummary, DirectiveStatus } from "../lib/api";
+
+// Status dot color, matching the existing tabular UI's badge palette so the
+// document mode feels like a sibling of the existing list, not a foreign UI.
+const STATUS_DOT: Record<DirectiveStatus, string> = {
+ draft: "bg-[#556677]",
+ active: "bg-green-400",
+ idle: "bg-yellow-400",
+ paused: "bg-orange-400",
+ archived: "bg-[#3a4a6a]",
+};
+
+// =============================================================================
+// Sidebar grouping — group directives by lifecycle stage so the file tree
+// reads like a folder per status. We collapse the noisy ones (Archived) by
+// default and keep Active / Idle expanded.
+// =============================================================================
+
+type SidebarGroup = "active" | "idle" | "archived";
+
+const GROUP_LABEL: Record<SidebarGroup, string> = {
+ active: "active",
+ idle: "idle",
+ archived: "archived",
+};
+
+function bucketOf(status: DirectiveStatus): SidebarGroup {
+ if (status === "active" || status === "paused") return "active";
+ if (status === "archived") return "archived";
+ // draft + idle land in the idle bucket (i.e. "not currently running").
+ return "idle";
+}
+
+// =============================================================================
+// Sidebar icons (inline SVG, no new deps)
+// =============================================================================
+
+function FolderIcon({ open = false }: { open?: boolean }) {
+ return (
+ <svg
+ viewBox="0 0 16 16"
+ width={12}
+ height={12}
+ className="shrink-0"
+ aria-hidden
+ >
+ {open ? (
+ <path
+ d="M1.5 3.5a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V6H1.5V3.5z M1 6.5h13.382a.5.5 0 0 1 .49.598l-.9 5A.5.5 0 0 1 13.482 12.5H2.518a.5.5 0 0 1-.49-.402l-.9-5A.5.5 0 0 1 1.62 6.5H1z"
+ fill="#75aafc"
+ opacity="0.85"
+ />
+ ) : (
+ <path
+ d="M1.5 4a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4z"
+ fill="#75aafc"
+ opacity="0.65"
+ />
+ )}
+ </svg>
+ );
+}
+
+function FileIcon() {
+ return (
+ <svg
+ viewBox="0 0 16 16"
+ width={12}
+ height={12}
+ className="shrink-0"
+ aria-hidden
+ >
+ <path
+ d="M3 1.5h6.293a1 1 0 0 1 .707.293l3 3A1 1 0 0 1 13.293 5.5H13V14a.5.5 0 0 1-.5.5h-9A.5.5 0 0 1 3 14V1.5z"
+ fill="none"
+ stroke="#9bc3ff"
+ strokeWidth="1"
+ />
+ <path
+ d="M9.5 1.5v3h3"
+ fill="none"
+ stroke="#9bc3ff"
+ strokeWidth="1"
+ />
+ </svg>
+ );
+}
+
+function Caret({ open }: { open: boolean }) {
+ return (
+ <svg
+ viewBox="0 0 8 8"
+ width={8}
+ height={8}
+ className={`shrink-0 transition-transform ${open ? "rotate-90" : ""}`}
+ aria-hidden
+ >
+ <path d="M2 1l4 3-4 3z" fill="#7788aa" />
+ </svg>
+ );
+}
+
+// =============================================================================
+// Sidebar
+// =============================================================================
+
+interface SidebarProps {
+ directives: DirectiveSummary[];
+ loading: boolean;
+ selectedId: string | null;
+ onSelect: (id: string) => void;
+}
+
+function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarProps) {
+ const groups: Record<SidebarGroup, DirectiveSummary[]> = useMemo(() => {
+ const out: Record<SidebarGroup, DirectiveSummary[]> = {
+ active: [],
+ idle: [],
+ archived: [],
+ };
+ for (const d of directives) {
+ out[bucketOf(d.status)].push(d);
+ }
+ // Sort each group alphabetically so it feels like a stable file tree.
+ (Object.keys(out) as SidebarGroup[]).forEach((k) => {
+ out[k].sort((a, b) =>
+ a.title.localeCompare(b.title, undefined, { sensitivity: "base" }),
+ );
+ });
+ return out;
+ }, [directives]);
+
+ // Default-collapsed state per folder. Archived is collapsed by default
+ // (it's history); the other two are open so users see their work.
+ const [openGroups, setOpenGroups] = useState<Record<SidebarGroup, boolean>>({
+ active: true,
+ idle: true,
+ archived: false,
+ });
+
+ const toggleGroup = (g: SidebarGroup) =>
+ setOpenGroups((prev) => ({ ...prev, [g]: !prev[g] }));
+
+ return (
+ <div className="flex flex-col h-full">
+ {/* Sidebar header */}
+ <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)]">
+ <span className="text-[11px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ Documents
+ </span>
+ <span className="text-[10px] font-mono text-[#556677]">
+ {directives.length}
+ </span>
+ </div>
+
+ {/* Top-level "directives/" folder */}
+ <div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono text-[#9bc3ff]">
+ <FolderIcon open />
+ <span>directives/</span>
+ </div>
+
+ {/* Body */}
+ <div className="flex-1 overflow-y-auto pb-4">
+ {loading && directives.length === 0 ? (
+ <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
+ Loading...
+ </div>
+ ) : directives.length === 0 ? (
+ <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
+ No directives yet
+ </div>
+ ) : (
+ (Object.keys(groups) as SidebarGroup[]).map((group) => {
+ const list = groups[group];
+ if (list.length === 0) return null;
+ const open = openGroups[group];
+ return (
+ <div key={group} className="select-none">
+ {/* Group header (sub-folder) */}
+ <button
+ type="button"
+ onClick={() => toggleGroup(group)}
+ className="w-full flex items-center gap-1.5 pl-4 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
+ >
+ <Caret open={open} />
+ <FolderIcon open={open} />
+ <span>{GROUP_LABEL[group]}/</span>
+ <span className="ml-auto text-[10px] text-[#556677]">
+ {list.length}
+ </span>
+ </button>
+
+ {/* Files inside the group */}
+ {open && (
+ <ul className="py-0.5">
+ {list.map((d) => {
+ const isSelected = d.id === selectedId;
+ const dot = STATUS_DOT[d.status] ?? STATUS_DOT.draft;
+ const slug = d.title
+ .trim()
+ .replace(/\s+/g, "-")
+ .replace(/[^a-zA-Z0-9._-]/g, "")
+ .toLowerCase();
+ const fileName =
+ slug.length > 0 ? `${slug}.md` : `${d.id.slice(0, 8)}.md`;
+ const orchestratorRunning = !!d.orchestratorTaskId;
+ return (
+ <li key={d.id}>
+ <button
+ type="button"
+ onClick={() => onSelect(d.id)}
+ title={d.title}
+ className={`w-full text-left flex items-center gap-1.5 pl-9 pr-3 py-1 font-mono text-[11px] transition-colors ${
+ isSelected
+ ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
+ : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
+ }`}
+ >
+ <FileIcon />
+ <span
+ className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`}
+ aria-hidden
+ />
+ <span className="truncate flex-1">{fileName}</span>
+ {orchestratorRunning && (
+ <span
+ className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse"
+ title="Orchestrator running"
+ aria-label="Orchestrator running"
+ />
+ )}
+ </button>
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </div>
+ );
+ })
+ )}
+ </div>
+ </div>
+ );
+}
+
+// =============================================================================
+// Editor shell — wraps DocumentEditor and handles the "no document selected"
+// and loading states.
+// =============================================================================
+
+interface EditorShellProps {
+ selectedId: string | undefined;
+ hasDirectives: boolean;
+ listLoading: boolean;
+}
+
+function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProps) {
+ const {
+ directive,
+ loading,
+ updateGoal,
+ cleanup,
+ createPR,
+ pickUpOrders,
+ } = useDirective(selectedId);
+
+ if (!selectedId) {
+ return (
+ <div className="flex-1 flex items-center justify-center h-full">
+ <p className="text-[#556677] font-mono text-[12px]">
+ {listLoading
+ ? "Loading documents..."
+ : hasDirectives
+ ? "Select a document from the sidebar"
+ : "No documents yet — create one from the legacy UI"}
+ </p>
+ </div>
+ );
+ }
+
+ if (loading && !directive) {
+ return (
+ <div className="flex-1 flex items-center justify-center h-full">
+ <p className="text-[#556677] font-mono text-[12px]">Loading document...</p>
+ </div>
+ );
+ }
+
+ if (!directive) {
+ return (
+ <div className="flex-1 flex items-center justify-center h-full">
+ <p className="text-[#7788aa] font-mono text-[12px]">Document not found</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex-1 flex flex-col h-full overflow-hidden">
+ {/* Document header — breadcrumb-like, mirrors a code editor's tab bar */}
+ <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
+ <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
+ <FileIcon />
+ <span>directives /</span>
+ <span className="text-[#9bc3ff]">{directive.id.slice(0, 8)}</span>
+ {!!directive.orchestratorTaskId && (
+ <span className="ml-2 inline-flex items-center gap-1 text-yellow-400">
+ <span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
+ orchestrator running
+ </span>
+ )}
+ </div>
+ </div>
+
+ {/* Lexical editor body */}
+ <DocumentEditor
+ directive={directive}
+ onUpdateGoal={async (goal) => {
+ await updateGoal(goal);
+ }}
+ onCleanup={async () => {
+ await cleanup();
+ }}
+ onCreatePR={async () => {
+ await createPR();
+ }}
+ onPickUpOrders={async () => {
+ await pickUpOrders();
+ }}
+ />
+ </div>
+ );
+}
+
+// =============================================================================
+// Page
+// =============================================================================
+
+export default function DocumentDirectivesPage() {
+ const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const { id: selectedId } = useParams<{ id: string }>();
+ const { directives, loading: listLoading } = useDirectives();
+
+ useEffect(() => {
+ if (!authLoading && isAuthConfigured && !isAuthenticated) {
+ navigate("/login");
+ }
+ }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
+
+ if (authLoading) {
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
+ </main>
+ </div>
+ );
+ }
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main
+ className="flex-1 flex overflow-hidden"
+ style={{ height: "calc(100vh - 80px)" }}
+ >
+ {/* Left: file-tree sidebar */}
+ <div className="w-[240px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]">
+ <DocumentSidebar
+ directives={directives}
+ loading={listLoading}
+ selectedId={selectedId ?? null}
+ onSelect={(id) => navigate(`/directives/${id}`)}
+ />
+ </div>
+
+ {/* Right: Lexical editor */}
+ <EditorShell
+ selectedId={selectedId}
+ hasDirectives={directives.length > 0}
+ listLoading={listLoading}
+ />
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx
index 73537bd..a77ad95 100644
--- a/makima/frontend/src/routes/settings.tsx
+++ b/makima/frontend/src/routes/settings.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect, type FormEvent } from "react";
import { useAuth } from "../contexts/AuthContext";
import { useNavigate } from "react-router";
import { Masthead } from "../components/Masthead";
+import { useUserSettings } from "../hooks/useUserSettings";
import {
getApiKey,
createApiKey,
@@ -267,6 +268,11 @@ export default function SettingsPage() {
const { user, isAuthConfigured, signOut } = useAuth();
const navigate = useNavigate();
+ // User settings (feature flags) state
+ const { settings: userSettings, loading: userSettingsLoading, update: updateUserSettings } = useUserSettings();
+ const [featureFlagSaving, setFeatureFlagSaving] = useState(false);
+ const [featureFlagError, setFeatureFlagError] = useState<string | null>(null);
+
// API Key state
const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
const [newKey, setNewKey] = useState<string | null>(null);
@@ -490,6 +496,21 @@ export default function SettingsPage() {
}
};
+ // Feature flag toggle handlers
+ const handleToggleDocumentMode = async () => {
+ if (featureFlagSaving) return;
+ setFeatureFlagError(null);
+ setFeatureFlagSaving(true);
+ try {
+ const next = !(userSettings?.documentModeEnabled ?? false);
+ await updateUserSettings({ documentModeEnabled: next });
+ } catch (err) {
+ setFeatureFlagError(err instanceof Error ? err.message : "Failed to update setting");
+ } finally {
+ setFeatureFlagSaving(false);
+ }
+ };
+
const passwordStrength = getPasswordStrength(passwordForm.newPassword);
return (
@@ -789,6 +810,49 @@ export default function SettingsPage() {
</section>
)}
+ {/* Feature Flags (POC) */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Feature Flags (POC)</SectionHeader>
+ {featureFlagError && <ErrorAlert>{featureFlagError}</ErrorAlert>}
+ <div className="flex items-start gap-3">
+ <button
+ type="button"
+ role="switch"
+ aria-checked={userSettings?.documentModeEnabled ?? false}
+ aria-label="Document Mode for directives"
+ onClick={handleToggleDocumentMode}
+ disabled={userSettingsLoading || featureFlagSaving}
+ className={`relative shrink-0 mt-0.5 w-10 h-5 border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
+ userSettings?.documentModeEnabled
+ ? "bg-[#3f6fb3] border-[#75aafc]"
+ : "bg-[#0a1628] border-[rgba(117,170,252,0.35)]"
+ }`}
+ >
+ <span
+ className={`absolute top-0.5 left-0.5 w-3.5 h-3.5 transition-transform ${
+ userSettings?.documentModeEnabled
+ ? "translate-x-5 bg-white"
+ : "translate-x-0 bg-[#9bc3ff]"
+ }`}
+ />
+ </button>
+ <div className="flex-1 min-w-0">
+ <div className="font-mono text-xs text-[#9bc3ff] mb-1">
+ Document Mode for directives
+ </div>
+ <p className="font-mono text-[10px] text-[#7788aa] leading-snug">
+ Replaces the tabular directives UI with a Lexical-based interactive
+ document editor. Proof of concept; expect rough edges.
+ </p>
+ {(userSettingsLoading || featureFlagSaving) && (
+ <p className="font-mono text-[10px] text-[#556677] mt-1">
+ {userSettingsLoading ? "Loading..." : "Saving..."}
+ </p>
+ )}
+ </div>
+ </div>
+ </section>
+
{/* Danger Zone */}
{isAuthConfigured && user && (
<section className="border border-red-900/50 bg-[#0d1b2d] p-4">