CODITECT Standard: Unified Web Publishing Component System
Standard ID: CODITECT-STD-012 Version: 1.0.0 Status: Active Governance: ADR-195, ADR-197
1. Purpose
This standard defines the complete, reproducible blueprint for building CODITECT web publishing viewers. It includes:
- 8 JSX component blueprints with full source code
- Complete CSS design token system (light + dark themes)
- Mermaid diagram theming (60+ variables, pre-render pattern)
- Vite build configuration with Tailwind CSS v4
- Manifest generation for document discovery
- Assembly instructions showing how all parts compose
Any CODITECT project can deploy a fully-featured document viewer by assembling these components. The /web-publish command automates this assembly.
Reference Implementation: submodules/dev/coditect-biosciences-qms-platform/
2. Architecture Overview
project-root/
├── index.html # Entry point (Section 4)
├── viewer.jsx # Application shell (Section 5.1)
├── styles.css # Design tokens + prose styles (Section 6)
├── vite.config.js # Build config (Section 7)
├── package.json # Dependencies (Section 8)
├── components/ # Modular UI components
│ ├── MarkdownRenderer.jsx # Section 5.2 (CORE)
│ ├── Sidebar.jsx # Section 5.3
│ ├── SearchPanel.jsx # Section 5.4
│ ├── Breadcrumbs.jsx # Section 5.5
│ ├── TableOfContents.jsx # Section 5.6
│ ├── PresentationMode.jsx # Section 5.7
│ └── CategoryLanding.jsx # Section 5.8
├── scripts/
│ └── generate-publish-manifest.js # Section 9
├── public/
│ ├── publish.json # Generated manifest
│ └── coditect-logo.png # Brand asset
├── docs/ # Markdown content
│ └── {category}/
│ └── *.md
└── dashboards/ # JSX interactive components
└── {category}/
└── *.jsx
Component Dependency Graph
viewer.jsx (Shell)
├── MarkdownRenderer.jsx ─── mermaid, unified/remark/rehype pipeline
├── Sidebar.jsx ──────────── lucide-react icons
├── SearchPanel.jsx ──────── minisearch
├── Breadcrumbs.jsx ──────── lucide-react icons
├── TableOfContents.jsx ──── IntersectionObserver (browser API)
├── PresentationMode.jsx ─── MarkdownRenderer, extractSections
└── CategoryLanding.jsx ──── lucide-react icons
Data Flow
publish.json (manifest)
│
▼
viewer.jsx → fetch("/publish.json")
│
├── Sidebar receives documents[]
├── SearchPanel indexes documents[] via MiniSearch
│
└── User clicks document
│
├── type: "markdown" → fetch(doc.path) → MarkdownRenderer
│ └── unified pipeline → mermaid pre-render → setHtml()
│
└── type: "dashboard" → lazy(() => import(doc.path)) → React.Suspense
3. Markdown Processing Pipeline (CANONICAL)
The unified/remark/rehype pipeline MUST use these plugins in this exact order. Changing the order breaks rendering.
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkFrontmatter from "remark-frontmatter";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkRehype from "remark-rehype";
import rehypeRaw from "rehype-raw";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeHighlight from "rehype-highlight";
import rehypeKatex from "rehype-katex";
import rehypeStringify from "rehype-stringify";
function createProcessor() {
return unified()
.use(remarkParse) // 1. Parse markdown AST
.use(remarkFrontmatter, ["yaml"]) // 2. Extract YAML frontmatter
.use(remarkGfm) // 3. GFM tables, strikethrough, task lists
.use(remarkMath) // 4. LaTeX math blocks
.use(remarkRehype, { allowDangerousHtml: true }) // 5. Convert to HTML AST
.use(rehypeRaw) // 6. Parse raw HTML in markdown
.use(rehypeSlug) // 7. Add IDs to headings
.use(rehypeAutolinkHeadings, { behavior: "wrap" }) // 8. Wrap headings in links
.use(rehypeHighlight, { ignoreMissing: true }) // 9. Syntax highlighting
.use(rehypeKatex) // 10. Render math to KaTeX
.use(rehypeStringify); // 11. Serialize to HTML string
}
Critical: remarkRehype with allowDangerousHtml: true MUST precede rehypeRaw. Without this, HTML in markdown is stripped.
4. HTML Entry Point Blueprint
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{PROJECT_NAME}} Document Viewer</title>
<style>
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/viewer.jsx"></script>
</body>
</html>
Template Variables:
{{PROJECT_NAME}}- Project display name (e.g., "BIO-QMS Dashboard Viewer")
5. Component Blueprints
5.1 Viewer Shell (viewer.jsx)
The application shell orchestrates all components. Customization points are marked with {{TEMPLATE}} variables.
/**
* {{PROJECT_NAME}} Document Viewer
* Renders markdown docs + JSX dashboards with search, navigation, presentation mode.
*/
import React, { useState, lazy, Suspense, useEffect, useCallback, useRef } from "react";
import { createRoot } from "react-dom/client";
import { Sun, Moon, Search, Presentation, HelpCircle } from "lucide-react";
import "katex/dist/katex.min.css";
import "highlight.js/styles/github.css";
import "./styles.css";
import MarkdownRenderer from "./components/MarkdownRenderer.jsx";
import Sidebar from "./components/Sidebar.jsx";
import SearchPanel, { initSearch } from "./components/SearchPanel.jsx";
import Breadcrumbs from "./components/Breadcrumbs.jsx";
import TableOfContents from "./components/TableOfContents.jsx";
import PresentationMode from "./components/PresentationMode.jsx";
import CategoryLanding from "./components/CategoryLanding.jsx";
const logo = "/coditect-logo.png";
// Lazy-loaded dashboard components — add project-specific dashboards here
const dashboardModules = {
// "dashboards-category-name": lazy(() => import("./dashboards/category/name.jsx")),
};
/** Load markdown content from file path via fetch */
async function loadMarkdown(path) {
const resp = await fetch(`/${path}`);
if (!resp.ok) throw new Error(`Failed to load ${path}`);
return resp.text();
}
/** Parse hash route -> { type, doc, category } */
function parseHash(documents) {
const hash = window.location.hash.slice(2);
if (!hash) return { type: "home" };
if (hash.startsWith("category/")) {
return { type: "category", category: decodeURIComponent(hash.slice(9)) };
}
const doc = documents.find((d) => d.id === hash);
return doc ? { type: "doc", doc } : { type: "home" };
}
/** Keyboard shortcuts help modal */
function ShortcutsModal({ onClose }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-surface rounded-xl shadow-2xl border border-line p-6 max-w-md" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold text-heading mb-4">Keyboard Shortcuts</h3>
<table className="w-full text-sm">
<tbody>
{[
["/ or Ctrl+K", "Open search"],
["Escape", "Close search / Exit presentation"],
["P", "Enter presentation mode"],
["?", "Show this help"],
["\u2190 \u2192", "Previous / Next slide (presentation)"],
["T", "Toggle timer (presentation)"],
["O", "Section overview (presentation)"],
].map(([key, desc]) => (
<tr key={key} className="border-t border-line-soft">
<td className="py-2 pr-4">
<kbd className="px-1.5 py-0.5 rounded border border-line bg-surface-dim text-xs font-mono">{key}</kbd>
</td>
<td className="py-2 text-muted">{desc}</td>
</tr>
))}
</tbody>
</table>
<button onClick={onClose} className="mt-4 px-4 py-2 rounded-lg bg-sky-tint text-sky-fg text-sm font-medium hover:bg-sky-line transition-colors">
Close
</button>
</div>
</div>
);
}
function Viewer() {
const [documents, setDocuments] = useState([]);
const [activeDoc, setActiveDoc] = useState(null);
const [activeCategory, setActiveCategory] = useState(null);
const [markdownContent, setMarkdownContent] = useState("");
const [headings, setHeadings] = useState([]);
const [searchOpen, setSearchOpen] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [presentationMode, setPresentationMode] = useState(false);
const [loading, setLoading] = useState(true);
const [dark, setDark] = useState(() => {
if (typeof window !== "undefined") {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
return false;
});
useEffect(() => { document.documentElement.classList.toggle("dark", dark); }, [dark]);
useEffect(() => {
fetch("/publish.json")
.then((r) => r.json())
.then((manifest) => {
setDocuments(manifest.documents);
initSearch(manifest.documents);
setLoading(false);
const route = parseHash(manifest.documents);
if (route.type === "doc") navigateToDoc(route.doc);
else if (route.type === "category") navigateToCategory(route.category);
})
.catch(() => setLoading(false));
}, []);
useEffect(() => {
const onHash = () => {
const route = parseHash(documents);
if (route.type === "doc") navigateToDoc(route.doc);
else if (route.type === "category") navigateToCategory(route.category);
else { setActiveDoc(null); setActiveCategory(null); setMarkdownContent(""); }
};
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, [documents]);
const navigateToDoc = useCallback(async (doc) => {
setActiveDoc(doc); setActiveCategory(null); setHeadings([]);
window.location.hash = `/${doc.id}`;
if (doc.type === "markdown") {
setMarkdownContent("");
try { setMarkdownContent(await loadMarkdown(doc.path)); }
catch (e) { setMarkdownContent(`# Error\n\nFailed to load: ${doc.path}\n\n${e.message}`); }
}
}, []);
const navigateToCategory = useCallback((name) => {
setActiveDoc(null); setActiveCategory(name); setMarkdownContent(""); setHeadings([]);
window.location.hash = `/category/${encodeURIComponent(name)}`;
}, []);
const navigateHome = useCallback(() => {
setActiveDoc(null); setActiveCategory(null); setMarkdownContent("");
window.location.hash = "";
}, []);
useEffect(() => {
const handleKey = (e) => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
if (presentationMode) return;
if (e.key === "/" || (e.key === "k" && (e.ctrlKey || e.metaKey))) {
e.preventDefault(); setSearchOpen(true);
} else if (e.key === "?" && !e.shiftKey) { setShowHelp(true); }
else if ((e.key === "p" || e.key === "P") && activeDoc?.type === "markdown") { setPresentationMode(true); }
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [activeDoc, presentationMode]);
if (presentationMode && markdownContent) {
return <PresentationMode content={markdownContent} title={activeDoc?.title || ""} onExit={() => setPresentationMode(false)} />;
}
const DashboardComponent = activeDoc?.type === "dashboard" ? dashboardModules[activeDoc.id] : null;
return (
<div className="flex flex-col h-screen bg-surface text-body">
{/* Header — customize project name, subtitle, compliance badges */}
<header className="flex items-center justify-between px-5 py-3 border-b border-line bg-surface shrink-0">
<div className="flex items-center gap-3 cursor-pointer" onClick={navigateHome}>
<img src={logo} alt="CODITECT" className="h-9 w-9 rounded-lg" />
<div>
<h1 className="text-base font-bold tracking-tight leading-tight text-heading">
{{PROJECT_TITLE}}
</h1>
<p className="text-xs text-muted">{{PROJECT_SUBTITLE}}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={() => setSearchOpen(true)} className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-surface-dim hover:bg-surface-inset transition-colors text-sm text-muted" title="Search (/ or Ctrl+K)">
<Search size={15} /><span className="hidden sm:inline">Search</span>
<kbd className="hidden sm:inline ml-1 px-1 py-0.5 rounded border border-line text-[10px] font-mono">/</kbd>
</button>
{activeDoc?.type === "markdown" && (
<button onClick={() => setPresentationMode(true)} className="p-2 rounded-lg transition-colors bg-surface-dim hover:bg-surface-inset" title="Presentation mode (P)">
<Presentation size={18} className="text-label" />
</button>
)}
<button onClick={() => setShowHelp(true)} className="p-2 rounded-lg transition-colors bg-surface-dim hover:bg-surface-inset" title="Keyboard shortcuts (?)">
<HelpCircle size={18} className="text-label" />
</button>
<button onClick={() => setDark(!dark)} className="p-2 rounded-lg transition-colors bg-surface-dim hover:bg-surface-inset" title={dark ? "Switch to light mode" : "Switch to dark mode"}>
{dark ? <Sun size={18} className="text-amber-400" /> : <Moon size={18} className="text-label" />}
</button>
</div>
</header>
<div className="flex flex-1 overflow-hidden">
<Sidebar documents={documents} activeDocId={activeDoc?.id} activeCategory={activeCategory} onSelect={navigateToDoc} onSelectCategory={navigateToCategory} />
<main className="flex-1 overflow-y-auto bg-surface-alt">
{activeDoc ? (
<div className="flex">
<div className="flex-1 min-w-0 p-6 max-w-4xl mx-auto">
<Breadcrumbs doc={activeDoc} onNavigateHome={navigateHome} onNavigateCategory={navigateToCategory} />
{activeDoc.type === "markdown" ? (
markdownContent ? <MarkdownRenderer content={markdownContent} onHeadings={setHeadings} /> : <div className="py-10 text-muted">Loading document...</div>
) : DashboardComponent ? (
<Suspense fallback={<div className="py-10 text-muted">Loading dashboard...</div>}><DashboardComponent /></Suspense>
) : <div className="py-10 text-muted">Dashboard not found: {activeDoc.id}</div>}
</div>
{activeDoc.type === "markdown" && <TableOfContents headings={headings} />}
</div>
) : activeCategory ? (
<div className="p-6">
<Breadcrumbs category={activeCategory} onNavigateHome={navigateHome} onNavigateCategory={navigateToCategory} />
<CategoryLanding category={activeCategory} documents={documents.filter((d) => d.category === activeCategory)} onSelectDoc={navigateToDoc} />
</div>
) : (
<div className="text-center mt-24 px-6">
<img src={logo} alt="CODITECT" className="h-20 w-20 mx-auto mb-6 rounded-2xl opacity-60" />
<h2 className="text-2xl font-bold mb-2 text-heading">{{PROJECT_NAME}} Document Viewer</h2>
<p className="text-muted mb-1">{documents.length} artifacts across {new Set(documents.map((d) => d.category)).size} categories</p>
<p className="text-sm text-dim mb-8">{documents.filter((d) => d.type === "markdown").length} documents · {documents.filter((d) => d.type === "dashboard").length} interactive dashboards</p>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 max-w-3xl mx-auto text-left">
{Object.entries(documents.reduce((acc, d) => { acc[d.category] = (acc[d.category] || 0) + 1; return acc; }, {})).sort(([a], [b]) => a.localeCompare(b)).map(([cat, count]) => (
<div key={cat} className="px-4 py-3 rounded-lg border border-line bg-surface hover:bg-surface-dim cursor-pointer transition-colors" onClick={() => navigateToCategory(cat)}>
<div className="text-sm font-semibold text-heading">{cat}</div>
<div className="text-xs text-muted">{count} artifact{count !== 1 ? "s" : ""}</div>
</div>
))}
</div>
<p className="text-xs text-dim mt-8">
Press <kbd className="px-1.5 py-0.5 rounded border border-line font-mono">/</kbd> to search · <kbd className="px-1.5 py-0.5 rounded border border-line font-mono">?</kbd> for shortcuts
</p>
</div>
)}
</main>
</div>
{/* Footer — customize copyright and project identifier */}
<footer className="flex items-center justify-between px-5 py-2.5 border-t border-line text-xs text-muted bg-surface shrink-0">
<span>{{COPYRIGHT}}</span>
<span>{{DEVELOPER}}</span>
<span>{{PROJECT_ID}} | Internal</span>
</footer>
<SearchPanel documents={documents} isOpen={searchOpen} onClose={() => setSearchOpen(false)} onSelect={navigateToDoc} />
{showHelp && <ShortcutsModal onClose={() => setShowHelp(false)} />}
</div>
);
}
createRoot(document.getElementById("root")).render(<Viewer />);
Template Variables for Viewer Shell:
| Variable | Description | Example |
|---|---|---|
{{PROJECT_TITLE}} | Header title | CODITECT Bioscience Quality Management System |
{{PROJECT_SUBTITLE}} | Header subtitle | AI-Driven Work Order Platform -- FDA 21 CFR Part 11 |
{{PROJECT_NAME}} | Short project name | BIO-QMS |
{{COPYRIGHT}} | Footer copyright | Copyright 2026 AZ1.AI Inc. All rights reserved. |
{{DEVELOPER}} | Footer developer | Developer: Hal Casteel, CEO/CTO |
{{PROJECT_ID}} | Footer project ID | CODITECT-BIO-QMS |
5.2 MarkdownRenderer (components/MarkdownRenderer.jsx) -- CORE
This is the most critical component. It handles the unified pipeline, frontmatter extraction, Mermaid pre-rendering, syntax highlighting, math equations, and copy buttons.
/**
* MarkdownRenderer — Core rendering component.
* Unified/remark/rehype pipeline with Mermaid pre-render pattern (ADR-197 D3).
*/
import React, { useState, useEffect, useMemo } from "react";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkFrontmatter from "remark-frontmatter";
import remarkMath from "remark-math";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeHighlight from "rehype-highlight";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import mermaid from "mermaid";
/**
* Browser-compatible frontmatter extraction.
* Replaces gray-matter (Node.js only -- uses Buffer which crashes in browsers).
* Parses YAML frontmatter between --- delimiters into a plain object.
*/
function parseFrontmatter(src) {
const match = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { data: {}, content: src };
const yaml = match[1];
const content = match[2];
const data = {};
let currentKey = null;
let currentList = null;
for (const line of yaml.split("\n")) {
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)$/);
if (kvMatch) {
if (currentKey && currentList) data[currentKey] = currentList;
currentList = null;
const [, key, rawVal] = kvMatch;
currentKey = key;
const val = rawVal.replace(/^['"]|['"]$/g, "").trim();
if (val === "" || val === "|" || val === ">") { data[key] = ""; }
else { data[key] = val; }
} else if (line.match(/^\s+-\s+(.*)$/)) {
const item = line.match(/^\s+-\s+(.*)$/)[1].replace(/^['"]|['"]$/g, "").trim();
if (!currentList) currentList = [];
currentList.push(item);
}
}
if (currentKey && currentList) data[currentKey] = currentList;
return { data, content };
}
/** Build the unified processor (singleton) */
function createProcessor() {
return unified()
.use(remarkParse)
.use(remarkFrontmatter, ["yaml"])
.use(remarkGfm)
.use(remarkMath)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeSlug)
.use(rehypeAutolinkHeadings, { behavior: "wrap" })
.use(rehypeHighlight, { ignoreMissing: true })
.use(rehypeKatex)
.use(rehypeStringify);
}
const processor = createProcessor();
/** Audience badge colors */
const AUDIENCE_COLORS = {
user: "bg-blue-tint text-blue-fg border border-blue-line",
executive: "bg-purple-tint text-purple-fg border border-purple-line",
technical: "bg-emerald-tint text-emerald-fg border border-emerald-line",
contributor: "bg-amber-tint text-amber-fg border border-amber-line",
compliance: "bg-red-tint text-red-fg border border-red-line",
};
/** Metadata header bar */
function MetadataBar({ frontmatter, wordCount }) {
if (!frontmatter) return null;
const audience = frontmatter.audience || "technical";
const badgeClass = AUDIENCE_COLORS[audience] || AUDIENCE_COLORS.technical;
return (
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5 mb-6 rounded-lg bg-surface-dim border border-line text-xs">
{frontmatter.title && <span className="font-semibold text-heading text-sm">{frontmatter.title}</span>}
<span className={`px-2 py-0.5 rounded-full text-[11px] font-medium ${badgeClass}`}>{audience}</span>
{frontmatter.category && <span className="px-2 py-0.5 rounded-full bg-surface-inset text-label text-[11px]">{frontmatter.category}</span>}
{frontmatter.status && <span className="text-muted">{frontmatter.status}</span>}
{wordCount > 0 && <span className="text-dim ml-auto">{wordCount.toLocaleString()} words</span>}
</div>
);
}
/** Copy button for code blocks (skip mermaid blocks) */
function addCopyButtons(container) {
if (!container) return;
container.querySelectorAll("pre > code").forEach((codeBlock) => {
if (codeBlock.classList.contains("language-mermaid")) return;
const pre = codeBlock.parentElement;
if (pre.querySelector(".copy-btn")) return;
const btn = document.createElement("button");
btn.className = "copy-btn";
btn.textContent = "Copy";
btn.onclick = () => {
navigator.clipboard.writeText(codeBlock.textContent).then(() => {
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = "Copy"; }, 2000);
});
};
pre.style.position = "relative";
pre.appendChild(btn);
});
}
/**
* Initialize Mermaid with CODITECT dark theme (ADR-197 D4).
* Uses theme: "base" with 60+ custom themeVariables.
* MUST be called before any mermaid.render() call.
*/
let mermaidInitialized = false;
function initMermaid() {
if (mermaidInitialized) return;
mermaid.initialize({
startOnLoad: false,
theme: "base",
themeVariables: {
// Background
background: "transparent",
darkMode: true,
fontFamily: "Inter, system-ui, sans-serif",
fontSize: "13px",
// Primary palette -- nodes, default shapes
primaryColor: "#1e3a5f",
primaryTextColor: "#f1f5f9",
primaryBorderColor: "#38bdf8",
// Secondary -- alt nodes, conditions
secondaryColor: "#2d2250",
secondaryTextColor: "#f1f5f9",
secondaryBorderColor: "#818cf8",
// Tertiary -- third color in rotation
tertiaryColor: "#1a3636",
tertiaryTextColor: "#f1f5f9",
tertiaryBorderColor: "#2dd4bf",
// Lines and edges
lineColor: "#e2e8f0",
textColor: "#f1f5f9",
// Nodes
mainBkg: "#1e3a5f",
nodeBorder: "#38bdf8",
nodeTextColor: "#f1f5f9",
// Clusters / subgraphs
clusterBkg: "#0f172a80",
clusterBorder: "#475569",
// Labels
titleColor: "#f1f5f9",
edgeLabelBackground: "#0f172a",
labelTextColor: "#f1f5f9",
labelBackground: "#0f172a",
// Sequence diagrams
actorBkg: "#1e293b",
actorBorder: "#60a5fa",
actorTextColor: "#f1f5f9",
actorLineColor: "#e2e8f0",
signalColor: "#e2e8f0",
signalTextColor: "#f1f5f9",
labelBoxBkgColor: "#1e293b",
labelBoxBorderColor: "#64748b",
loopTextColor: "#f1f5f9",
activationBorderColor: "#60a5fa",
activationBkgColor: "#1e3a5f",
sequenceNumberColor: "#f1f5f9",
noteBkgColor: "#334155",
noteBorderColor: "#64748b",
noteTextColor: "#f1f5f9",
// Class diagrams
classText: "#f1f5f9",
// Flowchart fill rotation
fillType0: "#1e3a5f",
fillType1: "#2d2250",
fillType2: "#1a3636",
fillType3: "#3b1d1d",
fillType4: "#1e3a5f",
fillType5: "#2d2250",
fillType6: "#1a3636",
fillType7: "#3b1d1d",
// State / ER
labelColor: "#f1f5f9",
altBackground: "#1e293b",
compositeBackground: "#0f172a80",
compositeBorder: "#475569",
compositeTitleBackground: "#1e293b",
// Relations and arrows
relationColor: "#e2e8f0",
relationLabelBackground: "#0f172a",
relationLabelColor: "#f1f5f9",
},
flowchart: { htmlLabels: true, curve: "basis" },
sequence: { mirrorActors: false },
});
mermaidInitialized = true;
}
/**
* MAIN COMPONENT: Renders markdown with Mermaid pre-render pattern.
*
* The Mermaid pre-render pattern (ADR-197 D3):
* 1. Process markdown through unified pipeline -> HTML string
* 2. Find all <pre><code class="language-mermaid"> blocks via regex
* 3. For each: strip HTML tags, decode entities, call mermaid.render()
* 4. Replace code blocks with rendered SVG divs
* 5. Strip SVG background (force transparent)
* 6. Set final HTML into React state
*
* This pattern is required because:
* - DOM mutation after dangerouslySetInnerHTML gets overwritten by re-renders
* - useEffect-based rendering races with React's paint cycle
* - Pre-rendering into the string BEFORE setHtml() is the only reliable approach
*/
export default function MarkdownRenderer({ content, onHeadings }) {
const [html, setHtml] = useState("");
const [frontmatter, setFrontmatter] = useState(null);
const [wordCount, setWordCount] = useState(0);
const contentRef = React.useRef(null);
useEffect(() => {
if (!content) return;
const { data, content: body } = parseFrontmatter(content);
setFrontmatter(data);
setWordCount(body.split(/\s+/).filter(Boolean).length);
processor.process(body).then(async (result) => {
let htmlStr = String(result);
initMermaid();
const mermaidRegex = /<pre><code class="[^"]*language-mermaid[^"]*">([\s\S]*?)<\/code><\/pre>/g;
const replacements = [];
let match;
while ((match = mermaidRegex.exec(htmlStr)) !== null) {
const raw = match[1].replace(/<[^>]*>/g, "");
const decoder = document.createElement("textarea");
decoder.innerHTML = raw;
const source = decoder.textContent;
try {
const id = `mermaid-${Math.random().toString(36).slice(2, 10)}`;
let { svg } = await mermaid.render(id, source);
svg = svg.replace(/(<svg[^>]*?)style="([^"]*)"/, (m, pre, style) => {
const cleaned = style.replace(/background[^;]*(;|$)/g, "background:transparent;");
return `${pre}style="${cleaned}"`;
});
replacements.push({ original: match[0], replacement: `<div class="mermaid-diagram">${svg}</div>` });
} catch (err) {
replacements.push({
original: match[0],
replacement: `<div class="mermaid-error">Mermaid error: ${err.message}</div>${match[0]}`,
});
}
}
for (const { original, replacement } of replacements) {
htmlStr = htmlStr.replace(original, replacement);
}
setHtml(htmlStr);
});
}, [content]);
useEffect(() => {
if (!contentRef.current || !onHeadings) return;
const headings = [];
contentRef.current.querySelectorAll("h2, h3").forEach((el) => {
headings.push({ id: el.id, text: el.textContent, level: parseInt(el.tagName[1]) });
});
onHeadings(headings);
}, [html, onHeadings]);
useEffect(() => { addCopyButtons(contentRef.current); }, [html]);
return (
<div>
<MetadataBar frontmatter={frontmatter} wordCount={wordCount} />
<div ref={contentRef} className="markdown-body" dangerouslySetInnerHTML={{ __html: html }} />
</div>
);
}
/** Extract H2 sections from markdown content for presentation mode */
export function extractSections(content) {
if (!content) return [];
const { content: body } = parseFrontmatter(content);
const sections = [];
let current = { title: "Introduction", content: "" };
for (const line of body.split("\n")) {
if (line.startsWith("## ")) {
if (current.content.trim()) sections.push(current);
current = { title: line.replace(/^## /, ""), content: "" };
} else { current.content += line + "\n"; }
}
if (current.content.trim()) sections.push(current);
return sections;
}
5.3 Sidebar (components/Sidebar.jsx)
/**
* Sidebar navigation tree from publish.json categories with collapse/expand.
*/
import React, { useState } from "react";
import { ChevronRight, ChevronDown, FileText, BarChart3, FolderOpen, Folder } from "lucide-react";
export default function Sidebar({ documents, activeDocId, activeCategory, onSelect, onSelectCategory }) {
const [collapsed, setCollapsed] = useState({});
const categories = {};
for (const doc of documents) {
if (!categories[doc.category]) categories[doc.category] = [];
categories[doc.category].push(doc);
}
const toggle = (cat) => setCollapsed((prev) => ({ ...prev, [cat]: !prev[cat] }));
const sortedCats = Object.keys(categories).sort();
return (
<nav className="w-64 min-w-[256px] bg-surface border-r border-line overflow-y-auto text-[13px] py-3 shrink-0">
<div className="px-4 pb-2 text-xs font-semibold uppercase tracking-wider text-sky-fg">Documents</div>
<div className="px-4 pb-3 text-[11px] text-dim">{documents.length} artifacts</div>
{sortedCats.map((cat) => {
const items = categories[cat];
const isCollapsed = collapsed[cat];
const hasActive = items.some((d) => d.id === activeDocId);
const isCatActive = activeCategory === cat;
return (
<div key={cat}>
<div className={`flex items-center gap-2 px-4 py-1.5 text-[11px] font-bold uppercase tracking-wider transition-colors ${hasActive || isCatActive ? "text-sky-fg" : "text-dim hover:text-label"}`}>
<button onClick={() => toggle(cat)} className="shrink-0">
{isCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</button>
{isCollapsed ? <Folder size={14} className="shrink-0" /> : <FolderOpen size={14} className="shrink-0" />}
<span className={`cursor-pointer ${isCatActive ? "underline" : "hover:underline"}`} onClick={() => onSelectCategory && onSelectCategory(cat)}>
{cat}
</span>
<span className="ml-auto text-[10px] font-normal text-dim">({items.length})</span>
</div>
{!isCollapsed && items.map((doc) => {
const isActive = doc.id === activeDocId;
const Icon = doc.type === "dashboard" ? BarChart3 : FileText;
return (
<div key={doc.id} onClick={() => onSelect(doc)}
className={`flex items-center gap-2 px-4 pl-10 py-1.5 cursor-pointer border-l-[3px] transition-colors text-[12px] ${isActive ? "border-sky-400 bg-sky-tint text-sky-fg font-medium" : "border-transparent text-label hover:text-heading hover:bg-surface-alt"}`}
title={doc.summary || doc.title}>
<Icon size={13} className="shrink-0 opacity-60" />
<span className="truncate">{doc.title}</span>
</div>
);
})}
</div>
);
})}
</nav>
);
}
5.4 SearchPanel (components/SearchPanel.jsx)
/**
* Full-text search with MiniSearch, keyboard navigation, categorized results.
*/
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Search, X, FileText, BarChart3 } from "lucide-react";
import MiniSearch from "minisearch";
let searchIndex = null;
/** Initialize MiniSearch from publish.json documents */
export function initSearch(documents) {
searchIndex = new MiniSearch({
fields: ["title", "keywords_text", "summary", "category"],
storeFields: ["title", "path", "type", "category", "audience", "summary"],
searchOptions: { boost: { title: 3, keywords_text: 2, summary: 1.5, category: 1 }, fuzzy: 0.2, prefix: true },
});
searchIndex.addAll(documents.map((doc) => ({ ...doc, keywords_text: (doc.keywords || []).join(" ") })));
}
export default function SearchPanel({ documents, onSelect, isOpen, onClose }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [selectedIdx, setSelectedIdx] = useState(0);
const inputRef = useRef(null);
useEffect(() => { if (isOpen && inputRef.current) { inputRef.current.focus(); setQuery(""); setResults([]); setSelectedIdx(0); } }, [isOpen]);
useEffect(() => { if (!query.trim() || !searchIndex) { setResults([]); return; } setResults(searchIndex.search(query, { limit: 15 })); setSelectedIdx(0); }, [query]);
const handleKeyDown = useCallback((e) => {
if (e.key === "Escape") onClose();
else if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIdx((i) => Math.min(i + 1, results.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIdx((i) => Math.max(i - 1, 0)); }
else if (e.key === "Enter" && results[selectedIdx]) { onSelect(results[selectedIdx]); onClose(); }
}, [results, selectedIdx, onSelect, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/40" onClick={onClose}>
<div className="w-full max-w-xl bg-surface rounded-xl shadow-2xl border border-line overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3 px-4 py-3 border-b border-line">
<Search size={18} className="text-muted shrink-0" />
<input ref={inputRef} type="text" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={handleKeyDown}
placeholder="Search documents..." className="flex-1 bg-transparent text-heading outline-none placeholder:text-dim" />
<kbd className="hidden sm:inline px-1.5 py-0.5 rounded border border-line text-[10px] text-dim font-mono">ESC</kbd>
</div>
<div className="max-h-80 overflow-y-auto">
{results.length === 0 && query.trim() && <div className="px-4 py-8 text-center text-muted text-sm">No results for "{query}"</div>}
{results.map((hit, i) => (
<div key={hit.id} className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${i === selectedIdx ? "bg-sky-tint" : "hover:bg-surface-alt"}`}
onClick={() => { onSelect(hit); onClose(); }} onMouseEnter={() => setSelectedIdx(i)}>
{hit.type === "dashboard" ? <BarChart3 size={16} className="text-purple-fg shrink-0" /> : <FileText size={16} className="text-sky-fg shrink-0" />}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-heading truncate">{hit.title}</div>
<div className="text-xs text-muted truncate">{hit.category} · {hit.audience}</div>
</div>
<span className="text-[10px] text-dim uppercase tracking-wider">{hit.type}</span>
</div>
))}
</div>
{results.length > 0 && (
<div className="px-4 py-2 border-t border-line text-[11px] text-dim flex items-center gap-4">
<span>{results.length} result{results.length !== 1 ? "s" : ""}</span>
<span className="ml-auto"><kbd className="px-1 rounded border border-line font-mono">↑</kbd><kbd className="px-1 rounded border border-line font-mono ml-0.5">↓</kbd> navigate <kbd className="px-1 rounded border border-line font-mono ml-2">↵</kbd> select</span>
</div>
)}
</div>
</div>
);
}
5.5 Breadcrumbs (components/Breadcrumbs.jsx)
import React from "react";
import { ChevronRight, Home } from "lucide-react";
export default function Breadcrumbs({ doc, category, onNavigateHome, onNavigateCategory }) {
if (!doc && !category) return null;
return (
<div className="flex items-center gap-1.5 text-xs text-muted mb-4">
<button onClick={onNavigateHome} className="flex items-center gap-1 hover:text-heading transition-colors">
<Home size={13} /><span>Home</span>
</button>
{(doc || category) && (
<>
<ChevronRight size={12} className="text-dim" />
{doc ? (
<button onClick={() => onNavigateCategory(doc.category)} className="text-label hover:text-heading transition-colors">{doc.category}</button>
) : (
<span className="text-heading font-medium">{category}</span>
)}
</>
)}
{doc && (
<>
<ChevronRight size={12} className="text-dim" />
<span className="text-heading font-medium truncate max-w-xs">{doc.title}</span>
</>
)}
</div>
);
}
5.6 TableOfContents (components/TableOfContents.jsx)
import React, { useState, useEffect } from "react";
export default function TableOfContents({ headings }) {
const [activeId, setActiveId] = useState("");
useEffect(() => {
if (!headings || headings.length === 0) return;
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) { if (entry.isIntersecting) setActiveId(entry.target.id); }
}, { rootMargin: "-80px 0px -70% 0px" });
for (const h of headings) { const el = document.getElementById(h.id); if (el) observer.observe(el); }
return () => observer.disconnect();
}, [headings]);
if (!headings || headings.length < 2) return null;
return (
<div className="hidden xl:block w-56 shrink-0 sticky top-0 max-h-screen overflow-y-auto py-4 pr-4">
<div className="text-[11px] font-semibold uppercase tracking-wider text-dim mb-3">On this page</div>
<ul className="space-y-0.5">
{headings.map((h) => (
<li key={h.id}>
<a href={`#${h.id}`}
className={`block py-1 text-xs transition-colors border-l-2 ${h.level === 3 ? "pl-5" : "pl-3"} ${activeId === h.id ? "border-sky-400 text-sky-fg font-medium" : "border-transparent text-muted hover:text-heading hover:border-line-hard"}`}
onClick={(e) => { e.preventDefault(); document.getElementById(h.id)?.scrollIntoView({ behavior: "smooth" }); }}>
{h.text}
</a>
</li>
))}
</ul>
</div>
);
}
5.7 PresentationMode (components/PresentationMode.jsx)
import React, { useState, useEffect, useCallback, useRef } from "react";
import { X, ChevronLeft, ChevronRight, Clock, Grid3X3 } from "lucide-react";
import MarkdownRenderer, { extractSections } from "./MarkdownRenderer.jsx";
export default function PresentationMode({ content, title, onExit }) {
const [sections, setSections] = useState([]);
const [currentIdx, setCurrentIdx] = useState(0);
const [showTimer, setShowTimer] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [showOverview, setShowOverview] = useState(false);
const startTime = useRef(Date.now());
useEffect(() => { if (content) { setSections(extractSections(content)); setCurrentIdx(0); startTime.current = Date.now(); setElapsed(0); } }, [content]);
useEffect(() => { if (!showTimer) return; const iv = setInterval(() => setElapsed(Math.floor((Date.now() - startTime.current) / 1000)), 1000); return () => clearInterval(iv); }, [showTimer]);
const formatTime = (s) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
const handleKey = useCallback((e) => {
if (showOverview) { if (e.key === "Escape" || e.key === "o" || e.key === "O") setShowOverview(false); return; }
switch (e.key) {
case "Escape": onExit(); break;
case "ArrowRight": case " ": e.preventDefault(); setCurrentIdx((i) => Math.min(i + 1, sections.length - 1)); break;
case "ArrowLeft": setCurrentIdx((i) => Math.max(i - 1, 0)); break;
case "Home": setCurrentIdx(0); break;
case "End": setCurrentIdx(sections.length - 1); break;
case "t": case "T": setShowTimer((v) => !v); break;
case "o": case "O": setShowOverview(true); break;
}
}, [sections, onExit, showOverview]);
useEffect(() => { window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); }, [handleKey]);
useEffect(() => { document.documentElement.requestFullscreen?.().catch(() => {}); return () => { document.exitFullscreen?.().catch(() => {}); }; }, []);
if (!sections.length) return null;
const section = sections[currentIdx];
if (showOverview) {
return (
<div className="fixed inset-0 z-[100] bg-surface-alt p-8 overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-heading">{title} -- Sections</h2>
<button onClick={() => setShowOverview(false)} className="p-2 rounded-lg hover:bg-surface-dim"><X size={20} className="text-muted" /></button>
</div>
<div className="grid grid-cols-3 gap-4">
{sections.map((s, i) => (
<div key={i} onClick={() => { setCurrentIdx(i); setShowOverview(false); }}
className={`p-4 rounded-lg border cursor-pointer transition-colors ${i === currentIdx ? "border-sky-400 bg-sky-tint" : "border-line hover:border-sky-line hover:bg-surface"}`}>
<div className="text-xs text-dim mb-1">Section {i + 1}</div>
<div className="text-sm font-semibold text-heading truncate">{s.title}</div>
</div>
))}
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-[100] bg-surface flex flex-col">
<div className="flex items-center justify-between px-6 py-3 shrink-0">
<span className="text-sm text-muted">{title}</span>
<div className="flex items-center gap-3">
{showTimer && <span className="text-xs font-mono text-dim flex items-center gap-1"><Clock size={13} /> {formatTime(elapsed)}</span>}
<span className="text-xs text-dim">{currentIdx + 1} / {sections.length}</span>
<button onClick={() => setShowOverview(true)} className="p-1.5 rounded hover:bg-surface-dim" title="Overview (O)"><Grid3X3 size={16} className="text-muted" /></button>
<button onClick={onExit} className="p-1.5 rounded hover:bg-surface-dim" title="Exit (Esc)"><X size={16} className="text-muted" /></button>
</div>
</div>
<div className="flex-1 overflow-y-auto flex items-start justify-center px-8 py-6">
<div className="w-full max-w-3xl">
<h2 className="text-3xl font-bold text-heading mb-8">{section.title}</h2>
<div className="presentation-content"><MarkdownRenderer content={`---\n---\n${section.content}`} /></div>
</div>
</div>
<div className="h-1 bg-surface-dim shrink-0"><div className="h-full bg-sky-fg transition-all duration-300" style={{ width: `${((currentIdx + 1) / sections.length) * 100}%` }} /></div>
{currentIdx > 0 && <button onClick={() => setCurrentIdx((i) => i - 1)} className="fixed left-4 top-1/2 -translate-y-1/2 p-3 rounded-full bg-surface-dim/80 hover:bg-surface-inset transition-colors"><ChevronLeft size={24} className="text-muted" /></button>}
{currentIdx < sections.length - 1 && <button onClick={() => setCurrentIdx((i) => i + 1)} className="fixed right-4 top-1/2 -translate-y-1/2 p-3 rounded-full bg-surface-dim/80 hover:bg-surface-inset transition-colors"><ChevronRight size={24} className="text-muted" /></button>}
</div>
);
}
5.8 CategoryLanding (components/CategoryLanding.jsx)
import React from "react";
import { FileText, BarChart3, Users, Shield, Cpu, BookOpen } from "lucide-react";
const AUDIENCE_COLORS = {
user: "bg-blue-tint text-blue-fg border border-blue-line",
executive: "bg-purple-tint text-purple-fg border border-purple-line",
technical: "bg-emerald-tint text-emerald-fg border border-emerald-line",
contributor: "bg-amber-tint text-amber-fg border border-amber-line",
compliance: "bg-red-tint text-red-fg border border-red-line",
};
const CATEGORY_DESCRIPTIONS = {
Executive: "Strategic briefings and decision-support materials for leadership.",
Market: "Market analysis, competitive landscape, and opportunity assessments.",
Architecture: "System architecture, design decisions, and technical specifications.",
Compliance: "Regulatory compliance frameworks, audit readiness, and quality controls.",
Operations: "Operational procedures, workflows, and process documentation.",
System: "Interactive system dashboards, data models, and technical visualizers.",
Business: "Business cases, revenue models, and investment analysis tools.",
Planning: "Implementation planning, roadmaps, and project coordination.",
Product: "Product specifications, feature definitions, and requirements.",
Reference: "Technical reference materials, standards, and configuration guides.",
Research: "Research findings, literature reviews, and exploratory analysis.",
};
function CategoryIcon({ category }) {
const iconMap = { Executive: Users, Compliance: Shield, System: Cpu, Architecture: Cpu, Research: BookOpen };
const Icon = iconMap[category] || FileText;
return <Icon size={24} className="text-sky-fg" />;
}
export default function CategoryLanding({ category, documents, onSelectDoc }) {
if (!category || !documents.length) return null;
const markdownDocs = documents.filter((d) => d.type === "markdown");
const dashboards = documents.filter((d) => d.type === "dashboard");
const description = CATEGORY_DESCRIPTIONS[category] || "";
const audiences = [...new Set(documents.map((d) => d.audience || "technical"))];
return (
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-4 mb-6">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-sky-tint border border-sky-line"><CategoryIcon category={category} /></div>
<div><h2 className="text-2xl font-bold text-heading">{category}</h2>{description && <p className="text-sm text-muted mt-0.5">{description}</p>}</div>
</div>
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5 mb-6 rounded-lg bg-surface-dim border border-line text-xs">
<span className="text-label font-medium">{documents.length} artifact{documents.length !== 1 ? "s" : ""}</span>
{markdownDocs.length > 0 && <span className="text-muted">{markdownDocs.length} document{markdownDocs.length !== 1 ? "s" : ""}</span>}
{dashboards.length > 0 && <span className="text-muted">{dashboards.length} dashboard{dashboards.length !== 1 ? "s" : ""}</span>}
<span className="ml-auto flex gap-1.5">{audiences.map((a) => <span key={a} className={`px-2 py-0.5 rounded-full text-[11px] font-medium ${AUDIENCE_COLORS[a] || AUDIENCE_COLORS.technical}`}>{a}</span>)}</span>
</div>
{markdownDocs.length > 0 && (
<div className="mb-8">
{dashboards.length > 0 && <h3 className="text-sm font-semibold text-label uppercase tracking-wider mb-3">Documents</h3>}
<div className="space-y-2">
{markdownDocs.map((doc) => (
<div key={doc.id} onClick={() => onSelectDoc(doc)} className="flex items-start gap-3 px-4 py-3 rounded-lg border border-line bg-surface hover:bg-surface-dim cursor-pointer transition-colors group">
<FileText size={16} className="mt-0.5 text-muted group-hover:text-sky-fg shrink-0 transition-colors" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2"><span className="text-sm font-medium text-heading group-hover:text-sky-fg transition-colors truncate">{doc.title}</span>
<span className={`px-1.5 py-0.5 rounded-full text-[10px] font-medium shrink-0 ${AUDIENCE_COLORS[doc.audience] || AUDIENCE_COLORS.technical}`}>{doc.audience || "technical"}</span></div>
{doc.summary && <p className="text-xs text-muted mt-0.5 line-clamp-2">{doc.summary}</p>}
</div>
</div>
))}
</div>
</div>
)}
{dashboards.length > 0 && (
<div className="mb-8">
{markdownDocs.length > 0 && <h3 className="text-sm font-semibold text-label uppercase tracking-wider mb-3">Interactive Dashboards</h3>}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{dashboards.map((doc) => (
<div key={doc.id} onClick={() => onSelectDoc(doc)} className="flex items-center gap-3 px-4 py-3 rounded-lg border border-line bg-surface hover:bg-surface-dim cursor-pointer transition-colors group">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-tint border border-emerald-line shrink-0"><BarChart3 size={16} className="text-emerald-fg" /></div>
<div className="min-w-0"><span className="text-sm font-medium text-heading group-hover:text-sky-fg transition-colors truncate block">{doc.title}</span><span className="text-[11px] text-muted">Interactive</span></div>
</div>
))}
</div>
</div>
)}
</div>
);
}
6. CSS Design Token System (styles.css)
Complete CSS including Tailwind CSS v4 theme tokens, light/dark modes, markdown prose, Mermaid diagram styling, code blocks, and print styles.
@import "tailwindcss";
/*
* CODITECT Web Publishing Design Token System
* All UI colors flow through CSS custom properties.
* Toggle .dark on <html> to switch themes.
*
* Naming: surface / heading / body / muted / line (neutrals)
* {color}-fg / {color}-tint / {color}-line (accents)
*/
@theme {
/* -- Neutral Surfaces -- */
--color-surface: #ffffff;
--color-surface-alt: #f9fafb;
--color-surface-dim: #f3f4f6;
--color-surface-inset: #e5e7eb;
/* -- Neutral Text -- */
--color-heading: #111827;
--color-body: #374151;
--color-label: #4b5563;
--color-muted: #6b7280;
--color-dim: #9ca3af;
--color-faint: #d1d5db;
/* -- Neutral Lines -- */
--color-line: #e5e7eb;
--color-line-soft: #f3f4f6;
--color-line-hard: #d1d5db;
/* -- Blue -- */
--color-blue-fg: #1d4ed8;
--color-blue-tint: #eff6ff;
--color-blue-line: #bfdbfe;
/* -- Red -- */
--color-red-fg: #dc2626;
--color-red-tint: #fef2f2;
--color-red-line: #fecaca;
/* -- Amber -- */
--color-amber-fg: #b45309;
--color-amber-tint: #fffbeb;
--color-amber-line: #fde68a;
/* -- Green -- */
--color-green-fg: #16a34a;
--color-green-tint: #f0fdf4;
--color-green-line: #bbf7d0;
/* -- Emerald -- */
--color-emerald-fg: #059669;
--color-emerald-tint: #ecfdf5;
--color-emerald-line: #a7f3d0;
/* -- Sky -- */
--color-sky-fg: #0369a1;
--color-sky-tint: #f0f9ff;
--color-sky-line: #bae6fd;
/* -- Indigo -- */
--color-indigo-fg: #4338ca;
--color-indigo-tint: #eef2ff;
--color-indigo-line: #c7d2fe;
/* -- Purple -- */
--color-purple-fg: #7c3aed;
--color-purple-tint: #faf5ff;
--color-purple-line: #e9d5ff;
/* -- Orange -- */
--color-orange-fg: #c2410c;
--color-orange-tint: #fff7ed;
--color-orange-line: #fed7aa;
/* -- Teal -- */
--color-teal-fg: #0f766e;
--color-teal-tint: #f0fdfa;
--color-teal-line: #99f6e4;
/* -- Yellow -- */
--color-yellow-fg: #a16207;
--color-yellow-tint: #fefce8;
--color-yellow-line: #fef08a;
/* -- Pink -- */
--color-pink-fg: #9d174d;
--color-pink-tint: #fdf2f8;
--color-pink-line: #fbcfe8;
}
/* -- Dark Mode -- */
.dark {
--color-surface: #030712;
--color-surface-alt: #111827;
--color-surface-dim: #1f2937;
--color-surface-inset: #374151;
--color-heading: #f9fafb;
--color-body: #d1d5db;
--color-label: #9ca3af;
--color-muted: #9ca3af;
--color-dim: #6b7280;
--color-faint: #4b5563;
--color-line: #374151;
--color-line-soft: #1f2937;
--color-line-hard: #4b5563;
--color-blue-fg: #60a5fa; --color-blue-tint: #172554; --color-blue-line: #1e3a8a;
--color-red-fg: #f87171; --color-red-tint: #450a0a; --color-red-line: #991b1b;
--color-amber-fg: #fbbf24; --color-amber-tint: #451a03; --color-amber-line: #78350f;
--color-green-fg: #4ade80; --color-green-tint: #052e16; --color-green-line: #14532d;
--color-emerald-fg: #34d399; --color-emerald-tint: #022c22; --color-emerald-line: #064e3b;
--color-sky-fg: #38bdf8; --color-sky-tint: #082f49; --color-sky-line: #0c4a6e;
--color-indigo-fg: #818cf8; --color-indigo-tint: #1e1b4b; --color-indigo-line: #312e81;
--color-purple-fg: #a78bfa; --color-purple-tint: #2e1065; --color-purple-line: #581c87;
--color-orange-fg: #fb923c; --color-orange-tint: #431407; --color-orange-line: #7c2d12;
--color-teal-fg: #2dd4bf; --color-teal-tint: #042f2e; --color-teal-line: #115e59;
--color-yellow-fg: #facc15; --color-yellow-tint: #422006; --color-yellow-line: #713f12;
--color-pink-fg: #f472b6; --color-pink-tint: #500724; --color-pink-line: #831843;
}
/* -- Markdown Body Prose -- */
.markdown-body { color: var(--color-body); line-height: 1.7; font-size: 0.9375rem; }
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 { color: var(--color-heading); font-weight: 700; line-height: 1.3; margin-top: 2em; margin-bottom: 0.75em; scroll-margin-top: 80px; }
.markdown-body h1 { font-size: 1.875rem; margin-top: 0; }
.markdown-body h2 { font-size: 1.5rem; border-bottom: 1px solid var(--color-line-soft); padding-bottom: 0.3em; }
.markdown-body h3 { font-size: 1.25rem; }
.markdown-body h4 { font-size: 1.1rem; }
.markdown-body p { margin: 0 0 1em; }
.markdown-body a { color: var(--color-sky-fg); text-decoration: underline; text-decoration-color: var(--color-sky-line); text-underline-offset: 2px; }
.markdown-body a:hover { text-decoration-color: var(--color-sky-fg); }
.markdown-body strong { color: var(--color-heading); font-weight: 600; }
.markdown-body ul, .markdown-body ol { margin: 0 0 1em; padding-left: 1.75em; }
.markdown-body li { margin-bottom: 0.35em; }
.markdown-body li > ul, .markdown-body li > ol { margin-top: 0.35em; margin-bottom: 0; }
.markdown-body blockquote { margin: 0 0 1em; padding: 0.5em 1em; border-left: 4px solid var(--color-sky-line); background: var(--color-surface-alt); color: var(--color-label); border-radius: 0 0.375rem 0.375rem 0; }
.markdown-body blockquote p:last-child { margin-bottom: 0; }
.markdown-body hr { border: none; border-top: 1px solid var(--color-line); margin: 2em 0; }
.markdown-body img { max-width: 100%; height: auto; border-radius: 0.5rem; border: 1px solid var(--color-line); }
/* -- Tables -- */
.markdown-body table { width: 100%; border-collapse: collapse; margin: 0 0 1em; font-size: 0.875rem; }
.markdown-body th { background: var(--color-surface-dim); color: var(--color-heading); font-weight: 600; text-align: left; padding: 0.5em 0.75em; border: 1px solid var(--color-line); }
.markdown-body td { padding: 0.5em 0.75em; border: 1px solid var(--color-line); }
.markdown-body tr:nth-child(even) { background: var(--color-surface-alt); }
/* -- Inline Code -- */
.markdown-body code:not(pre code) { background: var(--color-surface-dim); color: var(--color-heading); padding: 0.15em 0.35em; border-radius: 0.25rem; font-size: 0.85em; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace; }
/* -- Code Blocks -- */
.markdown-body pre { background: var(--color-surface-dim); border: 1px solid var(--color-line); border-radius: 0.5rem; padding: 1em; overflow-x: auto; margin: 0 0 1em; font-size: 0.8125rem; line-height: 1.6; position: relative; }
.markdown-body pre code { background: none; padding: 0; font-size: inherit; color: inherit; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace; }
/* -- Dark Mode: Highlight.js Overrides -- */
.dark .markdown-body pre, .dark .hljs { background: var(--color-surface-dim); color: var(--color-body); }
.dark .hljs-keyword, .dark .hljs-selector-tag, .dark .hljs-literal { color: #c678dd; }
.dark .hljs-string, .dark .hljs-title.class_, .dark .hljs-addition { color: #98c379; }
.dark .hljs-comment, .dark .hljs-quote { color: #5c6370; font-style: italic; }
.dark .hljs-number, .dark .hljs-selector-attr { color: #d19a66; }
.dark .hljs-built_in, .dark .hljs-type { color: #e5c07b; }
.dark .hljs-function, .dark .hljs-title.function_ { color: #61afef; }
.dark .hljs-attr { color: #d19a66; }
.dark .hljs-variable { color: #e06c75; }
/* -- Copy Button -- */
.copy-btn { position: absolute; top: 0.5rem; right: 0.5rem; padding: 0.2rem 0.5rem; font-size: 0.6875rem; font-family: inherit; color: var(--color-muted); background: var(--color-surface); border: 1px solid var(--color-line); border-radius: 0.25rem; cursor: pointer; opacity: 0; transition: opacity 0.15s, background 0.15s; }
.markdown-body pre:hover .copy-btn { opacity: 1; }
.copy-btn:hover { background: var(--color-surface-dim); color: var(--color-heading); }
/* -- Mermaid Diagrams (ADR-197 D5: forced dark background) -- */
.mermaid-diagram { margin: 1.5rem 0; padding: 1.5rem 1rem; background: #0f172a; border: 1px solid #334155; border-radius: 0.5rem; overflow-x: auto; text-align: center; }
.mermaid-diagram svg { max-width: 100%; height: auto; background: transparent !important; }
.mermaid-diagram .node rect, .mermaid-diagram .node circle, .mermaid-diagram .node polygon { stroke-width: 1.5px; }
.mermaid-diagram .edgePath .path, .mermaid-diagram .flowchart-link, .mermaid-diagram line, .mermaid-diagram .relation, .mermaid-diagram .messageLine0, .mermaid-diagram .messageLine1 { stroke-width: 2px !important; }
.mermaid-diagram marker path { stroke-width: 1px; }
.mermaid-diagram .actor-line { stroke-width: 2.5px !important; stroke: #e2e8f0 !important; }
.mermaid-diagram .actor { stroke-width: 2px !important; }
.mermaid-diagram .messageText { font-size: 13px !important; font-weight: 500 !important; fill: #f1f5f9 !important; }
.mermaid-diagram .sequenceNumber { font-weight: 700 !important; }
.mermaid-diagram .loopText tspan { font-weight: 600 !important; fill: #f1f5f9 !important; }
.mermaid-diagram .note rect { stroke-width: 1.5px !important; }
.mermaid-diagram .noteText { font-size: 12px !important; fill: #f1f5f9 !important; }
.mermaid-error { margin: 0.5rem 0; padding: 0.5rem 1rem; font-size: 0.75rem; color: var(--color-red-fg, #ef4444); background: var(--color-red-tint, rgba(239,68,68,0.1)); border: 1px solid var(--color-red-line, rgba(239,68,68,0.2)); border-radius: 0.375rem; }
/* -- Task Lists (GFM) -- */
.markdown-body input[type="checkbox"] { margin-right: 0.4em; accent-color: var(--color-sky-fg); }
.markdown-body ul:has(input[type="checkbox"]) { list-style: none; padding-left: 0.5em; }
/* -- KaTeX Math -- */
.markdown-body .katex-display { margin: 1em 0; overflow-x: auto; padding: 0.5em 0; }
/* -- Presentation Mode Content -- */
.presentation-content .markdown-body { font-size: 1.125rem; line-height: 1.8; }
.presentation-content .markdown-body h2 { display: none; }
.presentation-content .markdown-body li { margin-bottom: 0.5em; }
/* -- Print Styles -- */
@media print {
body { background: white !important; color: black !important; }
header, footer, nav, .copy-btn, button { display: none !important; }
main { overflow: visible !important; }
.markdown-body { color: #1a1a1a; font-size: 11pt; line-height: 1.5; max-width: 100%; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { color: #000; page-break-after: avoid; }
.markdown-body pre { white-space: pre-wrap; word-break: break-all; border: 1px solid #ccc; background: #f5f5f5 !important; page-break-inside: avoid; }
.markdown-body table { page-break-inside: avoid; }
.markdown-body img { max-width: 100%; page-break-inside: avoid; }
.markdown-body a { color: #1a1a1a; text-decoration: underline; }
.markdown-body a::after { content: " (" attr(href) ")"; font-size: 0.8em; color: #666; }
.markdown-body a[href^="#"]::after { content: none; }
}
7. Vite Build Configuration (vite.config.js)
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
export default defineConfig({
plugins: [
tailwindcss(),
react(),
viteStaticCopy({
targets: [
{ src: "docs", dest: "." },
// Add additional content directories here:
// { src: "research", dest: "." },
],
}),
],
});
Purpose of each plugin:
tailwindcss()- Processes Tailwind CSS v4@themedirectivesreact()- JSX transform, fast refreshviteStaticCopy()- Copies markdown content directories to build output for fetch()
8. Dependency Specification (package.json)
{
"name": "{{PROJECT_PACKAGE_NAME}}",
"version": "1.0.0",
"type": "module",
"private": true,
"description": "{{PROJECT_DESCRIPTION}}",
"scripts": {
"dev": "vite --open",
"build": "node scripts/generate-publish-manifest.js && vite build",
"generate-manifest": "node scripts/generate-publish-manifest.js"
},
"dependencies": {
"katex": "^0.16.28",
"lucide-react": "^0.564.0",
"mermaid": "^11.12.2",
"minisearch": "^7.2.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"rehype-autolink-headings": "^7.1.0",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"unified": "^11.0.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-react": "^5.1.4",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1",
"vite-plugin-static-copy": "^3.2.0"
}
}
Note: gray-matter is intentionally excluded. It uses Node.js Buffer which crashes in browsers. The parseFrontmatter() function in MarkdownRenderer replaces it for browser-side frontmatter extraction. The build script (generate-publish-manifest.js) runs in Node.js and can use gray-matter for server-side manifest generation.
9. Manifest Generation Script (scripts/generate-publish-manifest.js)
#!/usr/bin/env node
/**
* Generate publish.json manifest from content directories.
* Walks docs/ and dashboards/, extracts YAML frontmatter, emits public/publish.json.
*/
import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync } from "fs";
import { join, relative, basename, extname } from "path";
import matter from "gray-matter";
const ROOT = new URL("..", import.meta.url).pathname;
function walk(dir, exts) {
const results = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) results.push(...walk(full, exts));
else if (exts.includes(extname(entry).toLowerCase())) results.push(full);
}
return results;
}
function titleFromFilename(filename) {
return basename(filename, extname(filename)).replace(/^\d+-/, "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Map directory names to display categories -- customize per project */
const CATEGORY_MAP = {
executive: "Executive", market: "Market", architecture: "Architecture",
compliance: "Compliance", operations: "Operations", product: "Product",
reference: "Reference", research: "Research", system: "System",
business: "Business", planning: "Planning",
};
function buildManifest() {
const documents = [];
for (const dir of ["docs"]) {
const fullDir = join(ROOT, dir);
try {
const files = walk(fullDir, [".md"]);
for (const file of files) {
const raw = readFileSync(file, "utf-8");
const { data: fm } = matter(raw);
const relPath = relative(ROOT, file);
const parts = relPath.split("/");
const subdir = parts.length > 2 ? parts[1] : dir;
const category = CATEGORY_MAP[subdir] || subdir;
documents.push({
id: relPath.replace(/[/\\]/g, "-").replace(/\.md$/, ""),
title: fm.title || titleFromFilename(file),
path: relPath, type: "markdown",
audience: fm.audience || "technical", category,
keywords: fm.keywords || [], summary: fm.summary || "",
author: fm.author || "", status: fm.status || "active",
});
}
} catch { /* Directory may not exist */ }
}
const dashDir = join(ROOT, "dashboards");
try {
const files = walk(dashDir, [".jsx"]);
for (const file of files) {
const relPath = relative(ROOT, file);
const parts = relPath.split("/");
const category = CATEGORY_MAP[parts[1]] || parts[1] || "Dashboards";
documents.push({
id: relPath.replace(/[/\\]/g, "-").replace(/\.jsx$/, ""),
title: titleFromFilename(file), path: relPath, type: "dashboard",
audience: "technical", category, keywords: [], summary: "", author: "", status: "active",
});
}
} catch { /* Directory may not exist */ }
documents.sort((a, b) => a.category.localeCompare(b.category) || a.title.localeCompare(b.title));
const categories = {};
for (const doc of documents) {
if (!categories[doc.category]) categories[doc.category] = { name: doc.category, count: 0, types: {} };
categories[doc.category].count++;
categories[doc.category].types[doc.type] = (categories[doc.category].types[doc.type] || 0) + 1;
}
const manifest = {
project_name: "{{PROJECT_NAME}}",
version: "1.0.0",
generated_at: new Date().toISOString(),
total_documents: documents.length,
categories: Object.values(categories),
documents,
};
mkdirSync(join(ROOT, "public"), { recursive: true });
writeFileSync(join(ROOT, "public/publish.json"), JSON.stringify(manifest, null, 2));
console.log(`Generated publish.json: ${documents.length} documents across ${Object.keys(categories).length} categories`);
}
buildManifest();
Note: This script runs in Node.js (not browser) so it CAN use gray-matter. Add it to devDependencies when using the manifest generator: "gray-matter": "^4.0.3".
10. Mermaid Subgraph Color Conventions (ADR-197 D6)
When creating Mermaid diagrams in markdown content, use these style directives for semantic consistency across all projects:
| Category | Background | Border | Usage |
|---|---|---|---|
| Actors/External | #1e3a5f | #38bdf8 | Users, external systems, APIs |
| Core/Processing | #2d2250 | #818cf8 | Core logic, processing engines |
| Data/Storage | #1a3636 | #2dd4bf | Databases, caches, file storage |
| Persistence/Infra | #1e293b | #475569 | Infrastructure, cloud services |
| Integration/IO | #451a03 | #fbbf24 | Integrations, webhooks, message queues |
| Regulated/Compliance | #3b1d1d | #f87171 | Compliance, audit, regulated operations |
Example in markdown:
```mermaid
graph TD
subgraph Actors
style Actors fill:#1e3a5f,stroke:#38bdf8,color:#f1f5f9
U[User]
end
subgraph Core
style Core fill:#2d2250,stroke:#818cf8,color:#f1f5f9
E[Engine]
end
subgraph Data
style Data fill:#1a3636,stroke:#2dd4bf,color:#f1f5f9
DB[(Database)]
end
U --> E --> DB
```
11. Assembly Checklist
To deploy a new web publishing viewer:
- Create project directory structure (Section 2)
- Copy
index.htmlfrom Section 4, replace{{PROJECT_NAME}} - Copy
viewer.jsxfrom Section 5.1, replace all{{TEMPLATE}}variables - Copy all 7 components from Sections 5.2-5.8 into
components/ - Copy
styles.cssfrom Section 6 - Copy
vite.config.jsfrom Section 7, add content directories toviteStaticCopy - Copy
package.jsonfrom Section 8, replace template variables - Copy
generate-publish-manifest.jsfrom Section 9, customizeCATEGORY_MAP - Add
gray-matterto devDependencies:npm install -D gray-matter - Place markdown content in
docs/{category}/ - Place dashboard JSX in
dashboards/{category}/ - Place
coditect-logo.pnginpublic/ - Run
npm install && npm run dev
Automated: Use /web-publish command to execute steps 1-12 automatically.
12. Governing Documents
| Document | Purpose |
|---|---|
| ADR-195 | Push-Button Documentation Publishing Platform architecture |
| ADR-197 | Unified Web Publishing Component System decisions |
| CODITECT-STANDARD-DESIGN-TOKENS | Base design token naming convention |
| CODITECT-STANDARD-AUTOMATION | Automation principles |
Standard ID: CODITECT-STD-012 Version: 1.0.0 Created: 2026-02-14 Author: CODITECT Core Team Track: H.10 (Unified Web Publishing Component System)