feat(docs): add custom documentation pages with AI beautify

User-created dynamic doc pages live at /docs/custom/:slug, persisted in the new
backend. The editor offers "Beautify with AI", which regenerates the page as
structured HTML with Mermaid diagrams and replaces the raw markdown source (the
beautified version becomes the page's canonical content and survives edits).

Adds a DocHtml renderer that lazily renders Mermaid blocks, a purple design
token, sidebar/topbar entries for custom pages, and routing.
This commit is contained in:
2026-06-16 15:44:16 -04:00
parent efdc34637e
commit c24a6106bf
11 changed files with 987 additions and 41 deletions

View File

@@ -0,0 +1,245 @@
import { useState } from 'react';
import { useCustomPages } from '../lib/customPagesStore';
import type { CustomPage } from '../lib/pagesApi';
/**
* Modal editor for a custom documentation page.
*
* Lets the author set title / icon / blurb and edit the raw content (markdown-
* ish). Two save actions:
* - **Save**: persists the raw content; the backend renders it to plain HTML.
* - **Beautify with AI**: sends the content to the backend's AI endpoint,
* which returns polished HTML with Mermaid diagrams. The beautified HTML
* replaces the page's source content, discarding the old markdown.
*
* Works for both create (`page === null`) and edit modes.
*/
interface CustomPageEditorProps {
page: CustomPage | null;
onClose: () => void;
onSaved?: (page: CustomPage) => void;
onDeleted?: () => void;
}
export function CustomPageEditor({ page, onClose, onSaved, onDeleted }: CustomPageEditorProps) {
const { create, update, remove, beautify } = useCustomPages();
const [title, setTitle] = useState(page?.title ?? '');
const [icon, setIcon] = useState(page?.icon ?? '◈');
const [blurb, setBlurb] = useState(page?.blurb ?? '');
const [content, setContent] = useState(page?.content ?? '');
const [isBeautified, setIsBeautified] = useState(page?.beautified ?? false);
const [saving, setSaving] = useState(false);
const [beautifying, setBeautifying] = useState(false);
const [err, setErr] = useState<string | null>(null);
const overlayStyle = {
position: 'fixed' as const,
inset: 0,
background: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(2px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50,
padding: 'var(--sp-4)',
};
const persist = async (): Promise<CustomPage | null> => {
setErr(null);
if (!title.trim()) {
setErr('Title is required.');
return null;
}
setSaving(true);
try {
const input = { title: title.trim(), icon: icon.trim() || '◈', blurb: blurb.trim(), content };
const saved = page ? await update(page.id, input) : await create(input);
return saved;
} catch (e) {
setErr(e instanceof Error ? e.message : 'Save failed');
return null;
} finally {
setSaving(false);
}
};
const handleSave = async () => {
const saved = await persist();
if (saved) onSaved?.(saved);
};
const handleBeautify = async () => {
setErr(null);
if (!content.trim()) {
setErr('Add some content before beautifying.');
return;
}
setBeautifying(true);
try {
// Ensure the page exists and has the latest content before beautifying.
let target = page;
if (!target) {
const created = await persist();
if (!created) return;
target = created;
} else {
const updated = await update(target.id, { title: title.trim(), icon: icon.trim() || '◈', blurb: blurb.trim(), content });
target = updated;
}
const beautified = await beautify(target.id);
// The beautified HTML now lives in `content`; sync the editor so it shows
// the replacement of the old markdown.
setTitle(beautified.title);
setIcon(beautified.icon);
setBlurb(beautified.blurb);
setContent(beautified.content);
setIsBeautified(beautified.beautified);
onSaved?.(beautified);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Beautify failed');
} finally {
setBeautifying(false);
}
};
const handleDelete = async () => {
if (!page) return;
if (!confirm(`Delete "${page.title}"? This cannot be undone.`)) return;
setSaving(true);
try {
await remove(page.id);
onDeleted?.();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Delete failed');
} finally {
setSaving(false);
}
};
const inputCls =
'w-full rounded-lg border border-border bg-bg px-3 py-2 text-[0.9rem] text-fg outline-none transition-colors focus:border-accent';
return (
<div style={overlayStyle} onClick={onClose}>
<div
className="max-h-[88vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-border bg-surface p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-5 flex items-center justify-between">
<h2 className="m-0 text-lg">{page ? 'Edit Page' : 'Create Custom Page'}</h2>
<button
className="cursor-pointer rounded border border-border bg-transparent px-2 py-0.5 text-muted hover:text-fg"
onClick={onClose}
>
</button>
</div>
<div className="mb-4 flex gap-3">
<label className="flex flex-col gap-1">
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-muted">Icon</span>
<input
className="w-16 rounded-lg border border-border bg-bg px-3 py-2 text-center text-fg outline-none focus:border-accent"
value={icon}
onChange={(e) => setIcon(e.target.value)}
maxLength={4}
/>
</label>
<label className="flex flex-1 flex-col gap-1">
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-muted">Title</span>
<input
className={inputCls}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Faction Diplomacy"
/>
</label>
</div>
<label className="mb-4 flex flex-col gap-1">
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-muted">Blurb</span>
<input
className={inputCls}
value={blurb}
onChange={(e) => setBlurb(e.target.value)}
placeholder="One-line summary shown above the page."
/>
</label>
<label className="flex flex-col gap-1">
<span className="font-mono text-[0.7rem] uppercase tracking-wider text-muted">
{isBeautified ? 'Content (HTML)' : 'Content (markdown)'}
</span>
<textarea
className={[inputCls, 'min-h-[240px] font-mono text-[0.82rem]'].join(' ')}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={
isBeautified
? '<h2>Heading</h2>\n\n<p>Beautified HTML lives here…</p>\n\n<div class="mermaid">\nflowchart LR\n A --> B\n</div>'
: '# Heading\n\nWrite your notes here. Use **bold**, *italic*, `code`,\n- bullet lists\n\n```mermaid\nflowchart LR\n A --> B\n```'
}
spellCheck={false}
/>
{!isBeautified && (
<span className="font-mono text-[0.7rem] text-muted">
Tip: include a <code>fenced ```mermaid</code> block to seed a diagram for the AI.
</span>
)}
{isBeautified && (
<span className="font-mono text-[0.7rem] text-muted">
This page is AI-beautified, so its content is stored as HTML — edit it directly.
</span>
)}
</label>
{err && (
<p className="mt-4 rounded-lg border border-red/25 bg-red/8 px-3 py-2 font-mono text-[0.78rem] text-red">
{err}
</p>
)}
<div className="mt-6 flex flex-wrap items-center gap-3 border-t border-border pt-4">
<button
className="cursor-pointer rounded-lg border border-border bg-surface-raised px-4 py-2 text-[0.85rem] text-fg-dim transition-colors hover:text-fg disabled:cursor-not-allowed disabled:opacity-50"
onClick={onClose}
disabled={saving || beautifying}
>
Cancel
</button>
{page && (
<button
className="cursor-pointer rounded-lg border border-red/25 bg-red/8 px-4 py-2 text-[0.85rem] text-red transition-colors hover:bg-red/16 disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleDelete}
disabled={saving || beautifying}
>
Delete
</button>
)}
<span className="ml-auto flex items-center gap-3">
<button
className="cursor-pointer rounded-lg border border-purple/30 bg-purple/12 px-4 py-2 text-[0.85rem] font-medium text-purple transition-colors hover:bg-purple/20 disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleBeautify}
disabled={saving || beautifying || !content.trim()}
title="Regenerate formatted HTML + diagrams from your content using AI"
>
{beautifying ? '✦ Beautifying…' : '✦ Beautify with AI'}
</button>
<button
className="cursor-pointer rounded-lg border border-accent/30 bg-accent/12 px-4 py-2 text-[0.85rem] font-medium text-accent transition-colors hover:bg-accent/20 disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleSave}
disabled={saving || beautifying || !title.trim()}
>
{saving ? 'Saving…' : 'Save'}
</button>
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { useEffect, useRef, useState, type CSSProperties } from 'react';
/**
* Renders an HTML documentation fragment produced by the API (either a raw
* markdown render or an AI-beautified page) and post-processes any
* `<div class="mermaid"> … </div>` blocks into rendered SVG diagrams.
*
* The content comes from our own backend, so it is treated as trusted author
* HTML and rendered with dangerouslySetInnerHTML. Mermaid is imported lazily
* (and only configured once) so the large diagram library stays out of the
* main bundle unless a page actually contains diagrams.
*/
interface DocHtmlProps {
html: string;
style?: CSSProperties;
className?: string;
}
let diagCounter = 0;
type MermaidRunner = { render: (id: string, src: string) => Promise<{ svg: string }> };
let mermaidPromise: Promise<MermaidRunner> | null = null;
async function loadMermaid(): Promise<MermaidRunner> {
if (!mermaidPromise) {
mermaidPromise = import('mermaid').then((mod) => {
const mermaid = mod.default;
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
securityLevel: 'strict',
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
});
return mermaid as unknown as MermaidRunner;
});
}
return mermaidPromise;
}
export function DocHtml({ html, style, className }: DocHtmlProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const root = containerRef.current;
if (!root) return;
const blocks = Array.from(root.querySelectorAll<HTMLElement>('div.mermaid'));
if (blocks.length === 0) return;
let cancelled = false;
setError(null);
(async () => {
let runner: MermaidRunner;
try {
runner = await loadMermaid();
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load diagram library');
return;
}
for (const block of blocks) {
const source = block.textContent ?? '';
if (!source.trim()) continue;
const id = `mmd-${diagCounter++}`;
try {
const { svg } = await runner.render(id, source);
if (cancelled) return;
block.innerHTML = svg;
} catch (err) {
if (cancelled) return;
const msg = err instanceof Error ? err.message : String(err);
block.innerHTML = `<pre style="color:var(--red, #f87171)">Mermaid error: ${msg}</pre>`;
setError(msg);
}
}
})();
return () => {
cancelled = true;
};
}, [html]);
return (
<>
{error && (
<div
className="mb-4 rounded-lg border border-red/25 bg-red/8 px-4 py-2 font-mono text-[0.75rem] text-red"
role="alert"
>
One or more diagrams failed to render: {error}
</div>
)}
<div
ref={containerRef}
className={['doc-prose', className].filter(Boolean).join(' ')}
style={style}
dangerouslySetInnerHTML={{ __html: html }}
/>
</>
);
}

View File

@@ -1,11 +1,15 @@
import { NavLink } from "react-router-dom";
import { navSections } from "../data/nav";
import { useCustomPages } from "../lib/customPagesStore";
import { Link } from "react-router-dom";
type SidebarProps = {
collapsed: boolean;
};
export function Sidebar({ collapsed }: SidebarProps) {
const { pages } = useCustomPages();
return (
<aside
className={[
@@ -62,6 +66,56 @@ export function Sidebar({ collapsed }: SidebarProps) {
))}
</div>
))}
{/* User-created dynamic pages */}
<div className="max-md:flex max-md:flex-none max-md:gap-2">
<div
className={[
"flex items-center justify-between overflow-hidden whitespace-nowrap px-3 pb-2 pt-4 font-mono text-[0.65rem] uppercase tracking-[0.1em] text-muted max-md:hidden",
collapsed ? "h-2 p-2 text-0" : "",
].join(" ")}
>
<span>Custom Pages</span>
<Link
to="/docs/custom/new"
className="text-accent no-underline hover:text-accent-hover"
title="Add a custom page"
onClick={(e) => e.stopPropagation()}
>
+
</Link>
</div>
<NavLink
end
className={({ isActive }) =>
[
"mb-0.5 flex select-none items-center gap-3 overflow-hidden whitespace-nowrap rounded-lg px-3 py-2 text-[0.85rem] text-fg-dim no-underline transition-all duration-150 hover:bg-surface-raised hover:text-fg-bright max-md:mb-0 max-md:justify-start",
collapsed ? "justify-center p-3 max-md:px-3 max-md:py-2" : "",
isActive ? "border border-accent/25 bg-accent/8 text-accent" : "",
].join(" ")
}
to="/docs/custom"
>
<span className="min-w-5 text-center text-base"></span>
<span className={collapsed ? "hidden max-md:inline" : ""}>Custom Pages</span>
</NavLink>
{!collapsed &&
pages.map((page) => (
<NavLink
key={page.id}
className={({ isActive }) =>
[
"mb-0.5 flex select-none items-center gap-3 overflow-hidden whitespace-nowrap rounded-lg px-3 py-2 pl-7 text-[0.82rem] text-fg-dim no-underline transition-all duration-150 hover:bg-surface-raised hover:text-fg-bright max-md:mb-0",
isActive ? "border border-accent/25 bg-accent/8 text-accent" : "",
].join(" ")
}
to={`/docs/custom/${page.slug}`}
>
<span className="min-w-5 text-center text-sm">{page.icon}</span>
<span className="truncate">{page.title}</span>
</NavLink>
))}
</div>
</nav>
</aside>
);

View File

@@ -10,7 +10,14 @@ export function TopBar({ collapsed, onToggle }: TopBarProps) {
const location = useLocation();
const meta = pageTitles.get(location.pathname);
const section = meta?.section?.includes("Demo") || meta?.section?.includes("Prototype") ? "Demos" : "Docs";
const title = meta?.title ?? "Overview";
let title = meta?.title ?? "Overview";
// Custom (dynamic) pages aren't in the static pageTitles registry.
if (location.pathname.startsWith("/docs/custom")) {
if (location.pathname === "/docs/custom") title = "Custom Pages";
else if (location.pathname.endsWith("/new")) title = "New Page";
else title = "Custom Page";
}
return (
<div className="flex h-topbar min-h-topbar min-w-0 items-center gap-4 border-b border-border bg-surface px-5 text-[0.8rem] max-md:gap-2 max-md:px-3">