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

@@ -13,6 +13,7 @@
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.17.10",
"@void-nav/ui": "workspace:*",
"mermaid": "^11.15.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1",

View File

@@ -1,6 +1,7 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { DocsLayout } from "./layouts/DocsLayout";
import { NotFound } from "./components/NotFound";
import { CustomPagesProvider } from "./lib/customPagesStore";
import { OverviewPage } from "./pages/docs/OverviewPage";
import { ArchitecturePage } from "./pages/docs/ArchitecturePage";
import { TechStackPage } from "./pages/docs/TechStackPage";
@@ -18,6 +19,8 @@ import { GapAnalysisPage } from "./pages/docs/GapAnalysisPage";
import { VerticalSliceEvaluationPage } from "./pages/docs/VerticalSliceEvaluationPage";
import { DesignDocPage } from "./pages/docs/DesignDocPage";
import { KanbanBoardPage } from "./pages/docs/KanbanBoardPage";
import { CustomPagesIndexPage } from "./pages/docs/CustomPagesIndexPage";
import { CustomPageView } from "./pages/docs/CustomPageView";
import { StarMapDemo } from "./prototypes/existing-demos/StarMapDemo";
import { ShipMovementDemo } from "./prototypes/existing-demos/ShipMovementDemo";
import { WarpTravelDemo } from "./prototypes/existing-demos/WarpTravelDemo";
@@ -36,6 +39,7 @@ import { GameHudPrototype } from "./prototypes/standalone-huds/GameHudPrototype"
export function App() {
return (
<CustomPagesProvider>
<Routes>
<Route path="/" element={<Navigate to="/docs" replace />} />
@@ -59,6 +63,11 @@ export function App() {
<Route path="design-doc" element={<DesignDocPage />} />
<Route path="kanban-board" element={<KanbanBoardPage />} />
{/* User-created dynamic documentation pages */}
<Route path="custom" element={<CustomPagesIndexPage />} />
<Route path="custom/new" element={<CustomPageView mode="create" />} />
<Route path="custom/:slug" element={<CustomPageView mode="view" />} />
<Route path="demos/starmap" element={<StarMapDemo />} />
<Route path="demos/game-loop" element={<GameLoopSliceDemo />} />
<Route path="demos/movement" element={<ShipMovementDemo />} />
@@ -80,5 +89,6 @@ export function App() {
<Route path="*" element={<NotFound />} />
</Routes>
</CustomPagesProvider>
);
}

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">

View File

@@ -0,0 +1,102 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { pagesApi, type CustomPage, type CustomPageInput } from './pagesApi';
/**
* Live store of user-created documentation pages.
*
* Custom pages are persisted in the @void-nav/api backend and surfaced both in
* the sidebar (under a "Custom Pages" section) and as dynamic routes
* (`/docs/custom/:slug`). This context keeps the sidebar, the dynamic page
* renderer, and the editor in sync, and exposes mutations that optimistically
* update the in-memory list.
*/
interface CustomPagesValue {
pages: CustomPage[];
loading: boolean;
error: string | null;
/** True while a refresh is in-flight (initial load or after a mutation). */
refreshing: boolean;
refresh: () => Promise<void>;
create: (input: CustomPageInput) => Promise<CustomPage>;
update: (id: string, input: Partial<CustomPageInput>) => Promise<CustomPage>;
remove: (id: string) => Promise<void>;
beautify: (id: string) => Promise<CustomPage>;
getBySlug: (slug: string) => CustomPage | undefined;
}
const CustomPagesContext = createContext<CustomPagesValue | null>(null);
export function CustomPagesProvider({ children }: { children: ReactNode }) {
const [pages, setPages] = useState<CustomPage[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setRefreshing(true);
setError(null);
try {
const list = await pagesApi.list();
setPages(list);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load custom pages');
} finally {
setRefreshing(false);
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const create = useCallback(async (input: CustomPageInput) => {
const page = await pagesApi.create(input);
setPages((prev) => [...prev, page]);
return page;
}, []);
const update = useCallback(async (id: string, input: Partial<CustomPageInput>) => {
const page = await pagesApi.update(id, input);
setPages((prev) => prev.map((p) => (p.id === id ? page : p)));
return page;
}, []);
const remove = useCallback(async (id: string) => {
await pagesApi.remove(id);
setPages((prev) => prev.filter((p) => p.id !== id));
}, []);
const beautify = useCallback(async (id: string) => {
const page = await pagesApi.beautify(id);
setPages((prev) => prev.map((p) => (p.id === id ? page : p)));
return page;
}, []);
const getBySlug = useCallback(
(slug: string) => pages.find((p) => p.slug === slug),
[pages],
);
const value = useMemo<CustomPagesValue>(
() => ({ pages, loading, error, refreshing, refresh, create, update, remove, beautify, getBySlug }),
[pages, loading, error, refreshing, refresh, create, update, remove, beautify, getBySlug],
);
return <CustomPagesContext.Provider value={value}>{children}</CustomPagesContext.Provider>;
}
export function useCustomPages(): CustomPagesValue {
const ctx = useContext(CustomPagesContext);
if (!ctx) throw new Error('useCustomPages must be used within a CustomPagesProvider');
return ctx;
}

View File

@@ -0,0 +1,74 @@
/**
* Typed client for the @void-nav/api custom-pages backend.
*
* Custom pages are user-created documentation pages that live in the API's
* SQLite store. They are rendered dynamically from stored HTML and appear in
* the docs sidebar under "Custom Pages".
*/
const BASE = '/api/pages';
export interface CustomPage {
id: string;
slug: string;
title: string;
icon: string;
blurb: string;
content: string;
html: string;
beautified: boolean;
createdAt: string;
updatedAt: string;
}
export interface CustomPageInput {
title: string;
icon?: string;
blurb?: string;
content?: string;
}
class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = 'ApiError';
}
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
});
if (!res.ok) {
let detail = `${res.status} ${res.statusText}`;
try {
const body = await res.json();
if (body?.error) detail = body.error;
} catch {
/* non-JSON error body; keep status text */
}
throw new ApiError(detail, res.status);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const pagesApi = {
list: () => req<CustomPage[]>(''),
get: (slug: string) => req<CustomPage>(`/${encodeURIComponent(slug)}`),
create: (input: CustomPageInput) =>
req<CustomPage>('', { method: 'POST', body: JSON.stringify(input) }),
update: (id: string, input: Partial<CustomPageInput>) =>
req<CustomPage>(`/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: string) => req<void>(`/${id}`, { method: 'DELETE' }),
beautify: (id: string) =>
req<CustomPage>(`/${id}/beautify`, { method: 'POST' }),
};

View File

@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useCustomPages } from '../../lib/customPagesStore';
import { DocHtml } from '../../components/DocHtml';
import { CustomPageEditor } from '../../components/CustomPageEditor';
/**
* Renders a single user-created documentation page at /docs/custom/:slug.
*
* Loads the page from the custom-pages store, renders its HTML body via
* <DocHtml> (which also renders any Mermaid diagrams), and exposes an inline
* editor + "Beautify with AI" action. Supports create-new (no slug) and
* delete.
*/
export function CustomPageView({ mode }: { mode: 'view' | 'create' }) {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const { pages, loading, error, getBySlug, refresh, refreshing } = useCustomPages();
const [editing, setEditing] = useState(mode === 'create');
const [notFound, setNotFound] = useState(false);
const page = slug ? getBySlug(slug) : undefined;
// If we are viewing a slug that isn't in the store yet (e.g. directly
// navigating to a URL or the store is mid-refresh), try refreshing once.
useEffect(() => {
if (mode === 'view' && slug && !page && !loading && !notFound) {
void refresh().then(() => {
if (!getBySlug(slug)) setNotFound(true);
});
}
}, [mode, slug, page, loading, notFound, refresh, getBySlug]);
if (mode === 'view' && notFound && !loading) {
return (
<div className="mx-auto max-w-content">
<h1 className="mb-2">Page not found</h1>
<p className="text-fg-dim">
No custom page exists at <code className="font-mono text-accent">/docs/custom/{slug}</code>.
</p>
<div className="mt-6">
<Link className="text-cyan underline" to="/docs/custom">
Back to custom pages
</Link>
</div>
</div>
);
}
if (mode === 'view' && !page) {
return (
<div className="mx-auto max-w-content font-mono text-sm text-muted">
Loading
</div>
);
}
if (mode === 'view' && page) {
return (
<div className="mx-auto max-w-content">
<div className="mb-6 flex flex-wrap items-center gap-3 border-b border-border pb-4">
<span className="text-2xl">{page.icon}</span>
<h1 className="m-0">{page.title}</h1>
<span className="ml-auto flex items-center gap-2">
{page.beautified && (
<span className="rounded-full border border-purple/25 bg-purple/8 px-2.5 py-0.5 font-mono text-[0.7rem] text-purple">
AI-Beautified
</span>
)}
<button
className="cursor-pointer rounded-lg border border-border bg-surface px-3 py-1.5 font-mono text-[0.75rem] text-fg-dim transition-colors hover:border-border-light hover:text-fg"
onClick={() => setEditing(true)}
>
Edit
</button>
</span>
</div>
{page.blurb && (
<p className="mb-6 max-w-[680px] text-[0.95rem] text-fg-dim">{page.blurb}</p>
)}
<DocHtml html={page.html} />
{editing && (
<CustomPageEditor
page={page}
onClose={() => setEditing(false)}
onDeleted={() => navigate('/docs/custom')}
/>
)}
</div>
);
}
// mode === 'create'
return (
<div className="mx-auto max-w-content">
<div className="mb-6 flex items-center gap-3 border-b border-border pb-4">
<h1 className="m-0">New Custom Page</h1>
</div>
<CustomPageEditor
page={null}
onClose={() => navigate('/docs/custom')}
onSaved={(saved) => navigate(`/docs/custom/${saved.slug}`)}
/>
{error && (
<p className="mt-4 font-mono text-[0.8rem] text-red">{error}</p>
)}
{refreshing && (
<p className="mt-4 font-mono text-[0.8rem] text-muted">Refreshing</p>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { Link } from 'react-router-dom';
import { useCustomPages } from '../../lib/customPagesStore';
/**
* Landing page for user-created documentation at /docs/custom.
*
* Lists every custom page with an "Add Page" action. Empty state explains the
* feature and links to the creator. Custom pages are persisted in the backend
* (SQLite via @void-nav/api) and rendered dynamically; the "Beautify with AI"
* action lives inside each page's editor.
*/
export function CustomPagesIndexPage() {
const { pages, loading, error } = useCustomPages();
return (
<div className="mx-auto max-w-content">
<div className="mb-6 flex flex-wrap items-center gap-3 border-b border-border pb-4">
<h1 className="m-0">Custom Pages</h1>
<span className="rounded-full border border-accent/25 bg-accent/8 px-2.5 py-0.5 font-mono text-[0.7rem] text-accent">
DYNAMIC
</span>
<Link
className="ml-auto cursor-pointer rounded-lg border border-accent/30 bg-accent/12 px-4 py-2 text-[0.85rem] font-medium text-accent no-underline transition-colors hover:bg-accent/20"
to="/docs/custom/new"
>
+ Add Page
</Link>
</div>
<p className="mb-8 max-w-[680px] text-[0.95rem] text-fg-dim">
Documentation pages you create on the fly. Write rough notes in the editor,
then hit <strong className="text-purple"> Beautify with AI</strong> to
regenerate the page as structured HTML with generated diagrams.
</p>
{error && (
<p className="mb-4 rounded-lg border border-red/25 bg-red/8 px-3 py-2 font-mono text-[0.8rem] text-red">
{error}
</p>
)}
{loading ? (
<p className="font-mono text-sm text-muted">Loading</p>
) : pages.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-surface p-10 text-center">
<p className="m-0 mb-1 text-fg-dim">No custom pages yet.</p>
<p className="m-0 text-[0.85rem] text-muted">
Click <span className="text-accent">+ Add Page</span> to create your first one.
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4 max-[900px]:grid-cols-1">
{pages.map((page) => (
<Link
key={page.id}
to={`/docs/custom/${page.slug}`}
className="block rounded-xl border border-border bg-surface p-5 no-underline transition-colors hover:border-accent/30 hover:bg-surface-raised"
>
<div className="mb-2 flex items-center gap-3">
<span className="text-xl">{page.icon}</span>
<h3 className="m-0 text-fg-bright">{page.title}</h3>
{page.beautified && (
<span className="ml-auto rounded-full border border-purple/25 bg-purple/8 px-2 py-0.5 font-mono text-[0.65rem] text-purple">
AI
</span>
)}
</div>
<p className="m-0 text-[0.85rem] text-fg-dim">
{page.blurb || <span className="italic text-muted">No description</span>}
</p>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -197,4 +197,161 @@
opacity: 0.4;
}
}
/* ------------------------------------------------------------------
* .doc-prose — styling for AI-generated / rendered HTML documentation.
* Used by <DocHtml>. Targets bare semantic tags (no classes needed on
* the generated markup) and keeps the dark sci-fi look of the rest of
* the docs site.
* ------------------------------------------------------------------ */
.doc-prose {
color: var(--color-fg);
font-size: 0.95rem;
line-height: 1.7;
}
.doc-prose > :first-child {
margin-top: 0;
}
.doc-prose h1 {
font-size: 1.6rem;
font-weight: 700;
color: var(--color-fg-bright);
margin: 0 0 var(--sp-4) 0;
letter-spacing: -0.01em;
}
.doc-prose h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-accent);
margin: var(--sp-6) 0 var(--sp-3) 0;
padding-bottom: var(--sp-2);
border-bottom: 1px solid var(--color-border);
}
.doc-prose h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--color-cyan);
margin: var(--sp-5) 0 var(--sp-2) 0;
}
.doc-prose p {
margin: 0 0 var(--sp-3) 0;
max-width: 72ch;
}
.doc-prose ul,
.doc-prose ol {
margin: 0 0 var(--sp-3) 0;
padding-left: 1.4rem;
}
.doc-prose ul { list-style: disc; }
.doc-prose ol { list-style: decimal; }
.doc-prose li {
margin: var(--sp-1) 0;
}
.doc-prose a {
color: var(--color-cyan);
text-decoration: underline;
text-underline-offset: 2px;
}
.doc-prose a:hover {
color: var(--color-accent-hover);
}
.doc-prose strong { color: var(--color-fg-bright); }
.doc-prose em { color: var(--color-fg); }
.doc-prose code {
font-family: var(--font-mono);
font-size: 0.85em;
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.1rem 0.35rem;
color: var(--color-accent);
}
.doc-prose pre {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: var(--sp-3) var(--sp-4);
overflow-x: auto;
margin: 0 0 var(--sp-4) 0;
}
.doc-prose pre code {
background: none;
border: none;
padding: 0;
color: var(--color-fg);
font-size: 0.85rem;
}
.doc-prose blockquote {
border-left: 3px solid var(--color-accent);
background: var(--color-surface);
margin: 0 0 var(--sp-3) 0;
padding: var(--sp-2) var(--sp-4);
color: var(--color-fg-dim);
border-radius: 0 8px 8px 0;
}
.doc-prose hr {
border: none;
border-top: 1px solid var(--color-border);
margin: var(--sp-6) 0;
}
.doc-prose table {
width: 100%;
border-collapse: collapse;
margin: 0 0 var(--sp-4) 0;
font-size: 0.88rem;
}
.doc-prose th,
.doc-prose td {
border-bottom: 1px solid var(--color-border);
padding: var(--sp-2) var(--sp-3);
text-align: left;
}
.doc-prose th {
font-family: var(--font-mono);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-muted);
}
.doc-prose td { color: var(--color-fg); }
.doc-prose tr:hover td {
background: var(--color-surface-raised);
}
.doc-prose div.mermaid {
display: flex;
justify-content: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: var(--sp-4);
margin: 0 0 var(--sp-4) 0;
overflow-x: auto;
}
.doc-prose div.mermaid svg {
max-width: 100%;
height: auto;
}
}