Files
vibn-agent-runner/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page.tsx
mawkone eb198e2d4d ship: project dashboard pages + sidebar/chat overhaul + log tooling
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).
2026-06-12 18:09:09 -07:00

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);
}