Ships accumulated WIP that was sitting uncommitted: - New (home) dashboard route pages: overview, code, data/tables, hosting, infrastructure, services, domains, integrations, agents, analytics, api, automations, billing, logs, market, marketing(+seo/social), product, security, storage, users, settings(app/auth). - dashboard-sidebar, project-icon-rail, chat-panel updates; mcp + anatomy route changes; package.json/lock dependency bumps. - Coolify log tooling (scripts/fetch-app-logs.mjs + fetch-app-logs-ssh.mjs) and ai-new-thread.md "Fetching Production Logs" section. Excludes throwaway debug scripts and telemetry audit dumps (the latter contain live credentials and must not be committed).
990 lines
28 KiB
TypeScript
990 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { useState, useEffect } from "react";
|
|
import { useParams } from "next/navigation";
|
|
import {
|
|
Loader2,
|
|
AlertCircle,
|
|
Target,
|
|
BookOpen,
|
|
Layers,
|
|
GitBranch,
|
|
Pencil,
|
|
FileText,
|
|
Check,
|
|
ListTodo,
|
|
Palette,
|
|
CheckSquare,
|
|
Play,
|
|
} from "lucide-react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import useSWR from "swr";
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Types & Fetcher
|
|
// ──────────────────────────────────────────────────
|
|
|
|
type Plan = {
|
|
vision?: string;
|
|
blueprint?: Record<string, string>;
|
|
tasks: Array<{
|
|
id: string;
|
|
title: string;
|
|
description?: string;
|
|
status: string;
|
|
}>;
|
|
decisions: Array<{ id: string; title: string; choice: string; why?: string }>;
|
|
};
|
|
|
|
const fetcher = async (url: string) => {
|
|
const res = await fetch(url, { credentials: "include" });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
return data.plan;
|
|
};
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Navigation Config
|
|
// ──────────────────────────────────────────────────
|
|
|
|
const BLUEPRINT_DOCS = [
|
|
{ id: "stories", label: "User Stories", icon: <BookOpen /> },
|
|
{ id: "acceptance", label: "Acceptance Criteria", icon: <Target /> },
|
|
{ id: "success", label: "Success Metrics", icon: <Check /> },
|
|
{ id: "ui_design", label: "UI Design", icon: <Palette /> },
|
|
{ id: "tech_context", label: "Technical Context", icon: <Layers /> },
|
|
{ id: "data_model", label: "Data Model", icon: <GitBranch /> },
|
|
{ id: "file_structure", label: "File Structure", icon: <FileText /> },
|
|
{ id: "tasks", label: "Task Breakdown", icon: <ListTodo /> },
|
|
{ id: "checklist", label: "QA Checklist", icon: <CheckSquare /> },
|
|
];
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Main Page Component
|
|
// ──────────────────────────────────────────────────
|
|
|
|
export default function PlanTab() {
|
|
const params = useParams();
|
|
const projectId = (params?.projectId as string) || "";
|
|
|
|
const [selectedId, setSelectedId] = useState<string>("objective");
|
|
|
|
const {
|
|
data: plan,
|
|
error,
|
|
mutate: mutatePlan,
|
|
} = useSWR<Plan>(
|
|
projectId ? `/api/projects/${projectId}/plan` : null,
|
|
fetcher,
|
|
{ revalidateOnFocus: false, revalidateIfStale: false },
|
|
);
|
|
|
|
const setPlan = (newPlan: Plan) => {
|
|
mutatePlan(newPlan, false);
|
|
};
|
|
|
|
const showLoading = !plan && !error;
|
|
|
|
return (
|
|
<div style={pageWrap}>
|
|
{showLoading && (
|
|
<div style={centeredMsg}>
|
|
<Loader2
|
|
size={16}
|
|
className="animate-spin"
|
|
style={{ color: INK.muted }}
|
|
/>
|
|
<span style={{ color: INK.muted, fontSize: "0.85rem" }}>
|
|
Loading plan…
|
|
</span>
|
|
</div>
|
|
)}
|
|
{error && !showLoading && (
|
|
<div style={centeredMsg}>
|
|
<AlertCircle size={15} style={{ color: "#d93025" }} />
|
|
<span style={{ fontSize: "0.85rem", color: "#d93025" }}>
|
|
{error.message || "Failed to load plan"}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{plan && (
|
|
<div style={grid}>
|
|
{/* ── Left Rail (Master Index) ── */}
|
|
<section style={leftCol}>
|
|
<div style={railGroup}>
|
|
<div style={railGroupHeader}>
|
|
<h3 style={railGroupTitle}>Scope</h3>
|
|
</div>
|
|
<div style={railItems}>
|
|
<RailItem
|
|
id="objective"
|
|
label="Product Brief"
|
|
icon={<Target />}
|
|
selectedId={selectedId}
|
|
onClick={setSelectedId}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={railGroup}>
|
|
<div style={railGroupHeader}>
|
|
<h3 style={railGroupTitle}>Blueprint</h3>
|
|
</div>
|
|
<div style={railItems}>
|
|
{BLUEPRINT_DOCS.map((doc) => (
|
|
<RailItem
|
|
key={doc.id}
|
|
id={doc.id}
|
|
label={doc.label}
|
|
icon={doc.icon}
|
|
selectedId={selectedId}
|
|
onClick={setSelectedId}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={railGroup}>
|
|
<div style={railGroupHeader}>
|
|
<h3 style={railGroupTitle}>Delegate to AI</h3>
|
|
</div>
|
|
<div style={railItems}>
|
|
<RailItem
|
|
id="kanban"
|
|
label="Execution Plan"
|
|
icon={<Play />}
|
|
selectedId={selectedId}
|
|
onClick={setSelectedId}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Right Rail (Detail Viewer) ── */}
|
|
<section style={rightCol}>
|
|
{selectedId === "objective" && (
|
|
<ObjectivePanel
|
|
plan={plan}
|
|
projectId={projectId}
|
|
onChange={setPlan}
|
|
/>
|
|
)}
|
|
|
|
{BLUEPRINT_DOCS.some((d) => d.id === selectedId) && (
|
|
<DocumentPanel plan={plan} docId={selectedId} />
|
|
)}
|
|
|
|
{selectedId === "kanban" && (
|
|
<DelegatePanel plan={plan} projectId={projectId} />
|
|
)}
|
|
</section>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Left Rail Item Component
|
|
// ──────────────────────────────────────────────────
|
|
|
|
function RailItem({
|
|
id,
|
|
label,
|
|
icon,
|
|
selectedId,
|
|
onClick,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
icon: React.ReactElement;
|
|
selectedId: string;
|
|
onClick: (id: string) => void;
|
|
}) {
|
|
const isActive = selectedId === id;
|
|
return (
|
|
<button
|
|
onClick={() => onClick(id)}
|
|
style={{
|
|
...flatTile,
|
|
background: isActive ? INK.cardBg : "transparent",
|
|
borderColor: isActive ? INK.border : "transparent",
|
|
boxShadow: isActive ? "0 1px 3px rgba(0,0,0,0.02)" : "none",
|
|
color: isActive ? INK.ink : INK.muted,
|
|
}}
|
|
>
|
|
{React.cloneElement(icon, {
|
|
size: 15,
|
|
color: isActive ? INK.ink : INK.muted,
|
|
} as React.SVGProps<SVGSVGElement> & { size?: number | string })}
|
|
<span style={{ fontSize: "0.85rem", fontWeight: isActive ? 600 : 500 }}>
|
|
{label}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Right Rail: Objective Panel
|
|
// ──────────────────────────────────────────────────
|
|
|
|
function ObjectivePanel({
|
|
plan,
|
|
projectId,
|
|
onChange,
|
|
}: {
|
|
plan: Plan;
|
|
projectId: string;
|
|
onChange: (p: Plan) => void;
|
|
}) {
|
|
const [draft, setDraft] = useState(plan.vision ?? "");
|
|
const [saving, setSaving] = useState(false);
|
|
const [editing, setEditing] = useState(!plan.vision);
|
|
const [editorView, setEditorView] = useState<"write" | "preview">("write");
|
|
const [dirty, setDirty] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (plan.vision !== draft && !dirty) {
|
|
setDraft(plan.vision ?? "");
|
|
}
|
|
}, [plan.vision, dirty, draft]);
|
|
|
|
const save = async (text: string) => {
|
|
setSaving(true);
|
|
try {
|
|
const r = await fetch(`/api/projects/${projectId}/plan`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ kind: "vision", text }),
|
|
});
|
|
const d = await r.json();
|
|
if (d.plan) {
|
|
onChange(d.plan);
|
|
setDraft(d.plan.vision ?? "");
|
|
}
|
|
setDirty(false);
|
|
setEditing(false);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const cancel = () => {
|
|
setDraft(plan.vision ?? "");
|
|
setDirty(false);
|
|
setEditing(false);
|
|
};
|
|
|
|
return (
|
|
<div style={panel}>
|
|
<div style={panelHeader}>
|
|
<div>
|
|
<h2 style={panelTitle}>Product Brief</h2>
|
|
<p style={panelDesc}>
|
|
The high-level business case and elevator pitch.
|
|
</p>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
{saving && (
|
|
<span style={{ fontSize: "0.75rem", color: INK.muted }}>
|
|
Saving...
|
|
</span>
|
|
)}
|
|
{!editing && (
|
|
<button onClick={() => setEditing(true)} style={actionBtn}>
|
|
<Pencil size={14} /> Edit Objective
|
|
</button>
|
|
)}
|
|
{editing && (
|
|
<>
|
|
<button
|
|
onClick={() => save(draft)}
|
|
disabled={!dirty || saving}
|
|
className="btn-primary"
|
|
>
|
|
Save Changes
|
|
</button>
|
|
<button onClick={cancel} className="btn-ghost">
|
|
Cancel
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={editorContainer}>
|
|
{editing ? (
|
|
<>
|
|
<div style={editorTabs}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditorView("write")}
|
|
style={
|
|
editorView === "write" ? editorTabActive : editorTabInactive
|
|
}
|
|
>
|
|
<FileText size={14} /> Write
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditorView("preview")}
|
|
style={
|
|
editorView === "preview" ? editorTabActive : editorTabInactive
|
|
}
|
|
>
|
|
<BookOpen size={14} /> Preview
|
|
</button>
|
|
<div style={{ flex: 1 }} />
|
|
{dirty && (
|
|
<span
|
|
style={{
|
|
fontSize: "0.75rem",
|
|
color: "#f97316",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
● Unsaved
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{editorView === "write" ? (
|
|
<textarea
|
|
value={draft}
|
|
onChange={(e) => {
|
|
setDraft(e.target.value);
|
|
setDirty(true);
|
|
}}
|
|
style={textAreaStyle}
|
|
placeholder="Describe the business objective..."
|
|
spellCheck
|
|
/>
|
|
) : (
|
|
<div style={previewAreaStyle}>
|
|
{draft.trim() ? (
|
|
<div className="markdown-prose">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
{draft}
|
|
</ReactMarkdown>
|
|
</div>
|
|
) : (
|
|
<div style={{ color: INK.muted, fontStyle: "italic" }}>
|
|
Nothing to preview yet.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div style={previewAreaStyle}>
|
|
{draft.trim() ? (
|
|
<div className="markdown-prose">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
{draft}
|
|
</ReactMarkdown>
|
|
</div>
|
|
) : (
|
|
<div style={{ color: INK.muted, fontStyle: "italic" }}>
|
|
No objective set. Click Edit to add one.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Right Rail: Document Panel
|
|
// ──────────────────────────────────────────────────
|
|
|
|
function DocumentPanel({ plan, docId }: { plan: Plan; docId: string }) {
|
|
const docConfig = BLUEPRINT_DOCS.find((d) => d.id === docId);
|
|
if (!docConfig) return null;
|
|
|
|
// Render purely from the strictly typed blueprint object.
|
|
// We no longer fallback to the legacy decisions array.
|
|
const content = plan.blueprint?.[docId as keyof typeof plan.blueprint];
|
|
|
|
return (
|
|
<div style={panel}>
|
|
{content ? (
|
|
<div
|
|
className="markdown-prose"
|
|
style={{ overflowY: "auto", height: "100%", paddingRight: 16 }}
|
|
>
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div style={panelHeader}>
|
|
<div>
|
|
<h2 style={panelTitle}>{docConfig.label}</h2>
|
|
<p style={panelDesc}>This document is currently empty.</p>
|
|
</div>
|
|
</div>
|
|
<div style={emptyBox}>
|
|
{React.cloneElement(
|
|
docConfig.icon as React.ReactElement,
|
|
{
|
|
size: 32,
|
|
color: INK.muted,
|
|
style: { marginBottom: 16, opacity: 0.5 },
|
|
} as React.SVGProps<SVGSVGElement> & { size?: number | string },
|
|
)}
|
|
<p style={{ fontWeight: 500, margin: 0, color: INK.ink }}>
|
|
Not generated yet.
|
|
</p>
|
|
<p
|
|
style={{
|
|
fontSize: "0.85rem",
|
|
maxWidth: 300,
|
|
margin: "8px 0 0",
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
This document is generated and maintained by the AI agent. Chat
|
|
with your agent to update the scope and blueprint.
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Right Rail: Kanban Panel
|
|
// ──────────────────────────────────────────────────
|
|
|
|
function DelegatePanel({ plan, projectId }: { plan: Plan; projectId: string }) {
|
|
const [delegating, setDelegating] = useState(false);
|
|
|
|
const openTasks = plan.tasks.filter((t) => t.status === "open");
|
|
const activeTasks = plan.tasks.filter(
|
|
(t) => t.status === "in_progress" || t.status === "review",
|
|
);
|
|
const doneTasks = plan.tasks.filter((t) => t.status === "done");
|
|
|
|
const handleDelegate = async () => {
|
|
if (
|
|
!confirm(
|
|
"Start the background runner to build this feature? You can safely close your browser while it works.",
|
|
)
|
|
)
|
|
return;
|
|
setDelegating(true);
|
|
|
|
try {
|
|
const r = await fetch(`/api/projects/${projectId}/agent/sessions`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
appName: "frontend",
|
|
appPath: ".",
|
|
task: "Execute all open tasks in the Execution Plan sequentially.",
|
|
}),
|
|
});
|
|
if (!r.ok) {
|
|
const err = await r.json().catch(() => ({}));
|
|
throw new Error(err.error || `HTTP ${r.status}`);
|
|
}
|
|
alert("Background runner started successfully!");
|
|
} catch (err) {
|
|
alert(
|
|
`Failed to start runner: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
} finally {
|
|
setDelegating(false);
|
|
}
|
|
};
|
|
|
|
const TaskCard = ({ t }: { t: Plan["tasks"][number] }) => (
|
|
<div style={taskCard}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
gap: 12,
|
|
width: "100%",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
...taskStatusDot,
|
|
borderColor:
|
|
t.status === "done"
|
|
? "#10b981"
|
|
: t.status === "open"
|
|
? INK.muted
|
|
: "#f59e0b",
|
|
background: t.status === "done" ? "#10b981" : "transparent",
|
|
}}
|
|
>
|
|
{t.status === "done" && (
|
|
<Check size={10} color="#fff" strokeWidth={3} />
|
|
)}
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontWeight: 600,
|
|
fontSize: "0.95rem",
|
|
color: INK.ink,
|
|
textDecoration: t.status === "done" ? "line-through" : "none",
|
|
opacity: t.status === "done" ? 0.6 : 1,
|
|
wordWrap: "break-word",
|
|
overflowWrap: "break-word",
|
|
hyphens: "auto",
|
|
}}
|
|
>
|
|
{t.title}
|
|
</div>
|
|
{t.description && (
|
|
<div
|
|
style={{
|
|
fontSize: "0.8rem",
|
|
color: INK.muted,
|
|
marginTop: 4,
|
|
lineHeight: 1.4,
|
|
wordWrap: "break-word",
|
|
overflowWrap: "break-word",
|
|
hyphens: "auto",
|
|
}}
|
|
>
|
|
{t.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<span style={taskBadge}>
|
|
{t.status === "open"
|
|
? "Queued"
|
|
: t.status === "done"
|
|
? "Completed"
|
|
: "In Progress"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
...panel,
|
|
background: "transparent",
|
|
border: "none",
|
|
boxShadow: "none",
|
|
padding: 0,
|
|
}}
|
|
>
|
|
<div style={panelHeader}>
|
|
<div>
|
|
<h2 style={panelTitle}>Execution Plan</h2>
|
|
<p style={panelDesc}>
|
|
The prioritized roadmap for the AI background runner to execute.
|
|
</p>
|
|
</div>
|
|
<button
|
|
className="btn-primary"
|
|
onClick={handleDelegate}
|
|
disabled={delegating || openTasks.length === 0}
|
|
style={{ background: INK.ink, color: "#fff" }}
|
|
>
|
|
{delegating ? "Starting Jarvis..." : "Delegate Build"}
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 24,
|
|
alignItems: "flex-start",
|
|
overflowX: "auto",
|
|
paddingBottom: 16,
|
|
height: "100%",
|
|
}}
|
|
>
|
|
{/* TO DO COLUMN */}
|
|
<div style={kanbanCol}>
|
|
<div style={kanbanColHeader}>
|
|
<h3 style={kanbanColTitle}>To Do</h3>
|
|
<span style={kanbanCount}>{openTasks.length}</span>
|
|
</div>
|
|
<div style={kanbanList}>
|
|
{openTasks.length > 0 ? (
|
|
openTasks.map((t) => <TaskCard key={t.id} t={t} />)
|
|
) : (
|
|
<div style={emptyBoxSmall}>
|
|
<p>No tasks queued.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* IN PROGRESS COLUMN */}
|
|
<div style={kanbanCol}>
|
|
<div style={kanbanColHeader}>
|
|
<h3 style={kanbanColTitle}>In Progress</h3>
|
|
<span style={kanbanCount}>{activeTasks.length}</span>
|
|
</div>
|
|
<div style={kanbanList}>
|
|
{activeTasks.length > 0 ? (
|
|
activeTasks.map((t) => <TaskCard key={t.id} t={t} />)
|
|
) : (
|
|
<div style={emptyBoxSmall}>
|
|
<p>No active tasks.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* DONE COLUMN */}
|
|
<div style={kanbanCol}>
|
|
<div style={kanbanColHeader}>
|
|
<h3 style={kanbanColTitle}>Done</h3>
|
|
<span style={kanbanCount}>{doneTasks.length}</span>
|
|
</div>
|
|
<div style={kanbanList}>
|
|
{doneTasks.length > 0 ? (
|
|
doneTasks.map((t) => <TaskCard key={t.id} t={t} />)
|
|
) : (
|
|
<div style={emptyBoxSmall}>
|
|
<p>No completed tasks.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Styles (Mapped to infrastructure/product design language)
|
|
// ──────────────────────────────────────────────────
|
|
|
|
const INK = {
|
|
ink: "#1a1a1a",
|
|
mid: "#5f5e5a",
|
|
muted: "#a09a90",
|
|
border: "#e8e4dc",
|
|
borderSoft: "#efebe1",
|
|
cardBg: "#fff",
|
|
bgHover: "#fafaf6",
|
|
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
|
} as const;
|
|
|
|
const pageWrap: React.CSSProperties = {
|
|
padding: "28px 48px 48px",
|
|
fontFamily: INK.fontSans,
|
|
color: INK.ink,
|
|
};
|
|
|
|
const grid: React.CSSProperties = {
|
|
display: "grid",
|
|
gridTemplateColumns: "minmax(200px, 280px) minmax(0, 1fr)",
|
|
gap: 28,
|
|
maxWidth: 1400,
|
|
margin: "0 auto",
|
|
alignItems: "stretch",
|
|
};
|
|
|
|
const leftCol: React.CSSProperties = {
|
|
minWidth: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 24,
|
|
};
|
|
|
|
const rightCol: React.CSSProperties = {
|
|
minWidth: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
};
|
|
|
|
const centeredMsg: React.CSSProperties = {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
padding: "24px 0",
|
|
justifyContent: "center",
|
|
};
|
|
|
|
const railGroup: React.CSSProperties = {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
};
|
|
const railGroupHeader: React.CSSProperties = {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: "0 4px 8px",
|
|
};
|
|
const railGroupTitle: React.CSSProperties = {
|
|
fontSize: "0.68rem",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.12em",
|
|
textTransform: "uppercase",
|
|
color: INK.muted,
|
|
};
|
|
const railItems: React.CSSProperties = {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 4,
|
|
};
|
|
|
|
const flatTile: React.CSSProperties = {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
width: "100%",
|
|
padding: "10px 14px",
|
|
borderRadius: 8,
|
|
cursor: "pointer",
|
|
font: "inherit",
|
|
transition: "background 0.1s, border-color 0.1s, box-shadow 0.1s",
|
|
border: "1px solid transparent",
|
|
textAlign: "left",
|
|
};
|
|
|
|
const panel: React.CSSProperties = {
|
|
background: INK.cardBg,
|
|
border: `1px solid ${INK.border}`,
|
|
borderRadius: 10,
|
|
padding: 32,
|
|
flex: 1,
|
|
minHeight: "calc(100vh - 150px)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
boxShadow: "0 1px 3px rgba(0,0,0,0.02)",
|
|
};
|
|
|
|
const panelHeader: React.CSSProperties = {
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginBottom: 24,
|
|
flexShrink: 0,
|
|
};
|
|
const panelTitle: React.CSSProperties = {
|
|
fontSize: "1.2rem",
|
|
fontWeight: 600,
|
|
margin: 0,
|
|
color: INK.ink,
|
|
};
|
|
const panelDesc: React.CSSProperties = {
|
|
color: INK.muted,
|
|
fontSize: "0.85rem",
|
|
margin: "4px 0 0",
|
|
};
|
|
|
|
const actionBtn: React.CSSProperties = {
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
padding: "8px 16px",
|
|
border: `1px solid ${INK.border}`,
|
|
borderRadius: 8,
|
|
background: "#fff",
|
|
cursor: "pointer",
|
|
font: "inherit",
|
|
fontSize: "0.85rem",
|
|
fontWeight: 500,
|
|
color: INK.ink,
|
|
boxShadow: "0 1px 2px rgba(0,0,0,0.02)",
|
|
};
|
|
|
|
const editorContainer: React.CSSProperties = {
|
|
border: `1px solid ${INK.border}`,
|
|
borderRadius: 10,
|
|
overflow: "hidden",
|
|
background: INK.cardBg,
|
|
boxShadow: "0 1px 3px rgba(0,0,0,0.02)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
flex: 1,
|
|
minHeight: 0,
|
|
};
|
|
const editorTabs: React.CSSProperties = {
|
|
display: "flex",
|
|
background: INK.bgHover,
|
|
borderBottom: `1px solid ${INK.borderSoft}`,
|
|
padding: "8px 16px",
|
|
gap: 16,
|
|
flexShrink: 0,
|
|
};
|
|
const editorTabActive: React.CSSProperties = {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
padding: "6px 12px",
|
|
borderRadius: 6,
|
|
background: INK.cardBg,
|
|
border: `1px solid ${INK.borderSoft}`,
|
|
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
|
|
color: INK.ink,
|
|
fontWeight: 500,
|
|
fontSize: "0.85rem",
|
|
cursor: "pointer",
|
|
};
|
|
const editorTabInactive: React.CSSProperties = {
|
|
...editorTabActive,
|
|
background: "transparent",
|
|
border: "1px solid transparent",
|
|
boxShadow: "none",
|
|
color: INK.muted,
|
|
};
|
|
|
|
const textAreaStyle: React.CSSProperties = {
|
|
width: "100%",
|
|
flex: 1,
|
|
minHeight: 0,
|
|
padding: 40,
|
|
fontSize: "0.85rem",
|
|
lineHeight: 1.6,
|
|
border: "none",
|
|
outline: "none",
|
|
resize: "none",
|
|
fontFamily: "var(--font-sans)",
|
|
color: INK.ink,
|
|
display: "block",
|
|
boxSizing: "border-box",
|
|
margin: 0,
|
|
};
|
|
const previewAreaStyle: React.CSSProperties = {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
padding: 40,
|
|
boxSizing: "border-box",
|
|
overflowY: "auto",
|
|
};
|
|
|
|
const emptyBox: React.CSSProperties = {
|
|
border: `1px dashed ${INK.borderSoft}`,
|
|
borderRadius: 10,
|
|
padding: "48px 32px",
|
|
textAlign: "center",
|
|
color: INK.muted,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
margin: "auto 0",
|
|
};
|
|
const emptyBoxSmall: React.CSSProperties = {
|
|
padding: 32,
|
|
textAlign: "center",
|
|
border: `1px dashed ${INK.border}`,
|
|
borderRadius: 8,
|
|
color: INK.muted,
|
|
fontSize: "0.85rem",
|
|
};
|
|
|
|
const kanbanCol: React.CSSProperties = {
|
|
flex: 1,
|
|
minWidth: 300,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
height: "100%",
|
|
};
|
|
const kanbanColHeader: React.CSSProperties = {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
marginBottom: 12,
|
|
};
|
|
const kanbanColTitle: React.CSSProperties = {
|
|
fontSize: "0.95rem",
|
|
fontWeight: 600,
|
|
color: INK.ink,
|
|
margin: 0,
|
|
};
|
|
const kanbanCount: React.CSSProperties = {
|
|
fontSize: "0.75rem",
|
|
background: INK.borderSoft,
|
|
padding: "2px 8px",
|
|
borderRadius: 12,
|
|
color: INK.muted,
|
|
fontWeight: 600,
|
|
};
|
|
const kanbanList: React.CSSProperties = {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 12,
|
|
overflowY: "auto",
|
|
paddingRight: 4,
|
|
paddingBottom: 24,
|
|
};
|
|
|
|
const taskCard: React.CSSProperties = {
|
|
border: `1px solid ${INK.border}`,
|
|
borderRadius: 8,
|
|
padding: 16,
|
|
background: INK.cardBg,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 8,
|
|
boxShadow: "0 1px 2px rgba(0,0,0,0.02)",
|
|
};
|
|
const taskStatusDot: React.CSSProperties = {
|
|
width: 16,
|
|
height: 16,
|
|
borderRadius: "50%",
|
|
marginTop: 2,
|
|
flexShrink: 0,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
};
|
|
const taskBadge: React.CSSProperties = {
|
|
fontSize: "0.75rem",
|
|
color: INK.muted,
|
|
background: INK.bgHover,
|
|
padding: "4px 8px",
|
|
borderRadius: 4,
|
|
fontWeight: 500,
|
|
};
|
|
|
|
// Global styles
|
|
const styleTag = `
|
|
.btn-primary, .btn-secondary, .btn-ghost {
|
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; font-weight: 500;
|
|
cursor: pointer; transition: all 0.15s ease; border: 1px solid transparent;
|
|
}
|
|
.btn-primary { background: #1a1a1a; color: white; }
|
|
.btn-primary:hover { background: #333; }
|
|
.btn-secondary { background: #fff; border-color: #e8e4dc; color: #1a1a1a; }
|
|
.btn-secondary:hover { background: #fafaf6; }
|
|
.btn-ghost { background: transparent; color: #a09a90; }
|
|
.btn-ghost:hover { background: #fafaf6; color: #1a1a1a; }
|
|
|
|
.markdown-prose { font-size: 0.85rem; color: #1a1a1a; }
|
|
.markdown-prose h1 { font-size: 1.25rem; font-weight: 700; margin-top: 0; }
|
|
.markdown-prose h2 { font-size: 1.15rem; font-weight: 600; margin-top: 1.5rem; }
|
|
.markdown-prose h3 { font-size: 1.05rem; font-weight: 600; margin-top: 1.25rem; }
|
|
.markdown-prose p { margin-top: 0.5rem; margin-bottom: 1rem; line-height: 1.6; }
|
|
.markdown-prose ul { padding-left: 1.5rem; margin-bottom: 1rem; }
|
|
.markdown-prose li { margin-bottom: 0.5rem; line-height: 1.6; }
|
|
.markdown-prose strong { font-weight: 600; }
|
|
`;
|
|
|
|
if (
|
|
typeof document !== "undefined" &&
|
|
!document.getElementById("plan-v2-styles")
|
|
) {
|
|
const style = document.createElement("style");
|
|
style.id = "plan-v2-styles";
|
|
style.innerHTML = styleTag;
|
|
document.head.appendChild(style);
|
|
}
|