public
anord
read
Ledger
Why work hard when you can work easier?
Languages
Repository composition by tracked source files.
TypeScript
86%
CSS
10%
SQL
3%
Shell
1%
HTML
0%
Commit
Redesign the web app into a docs-first knowledge base UI
9693751
apps/web/src/App.tsx | 580 +---------------
apps/web/src/AppShell.tsx | 1062 +++++++++++++++++++++++++++++
apps/web/src/components/CommandSearch.tsx | 120 ++++
apps/web/src/components/DocsSidebar.tsx | 227 ++++++
apps/web/src/components/EmptyState.tsx | 22 +
apps/web/src/components/FeedbackForm.tsx | 14 +-
apps/web/src/components/Icon.tsx | 41 ++
apps/web/src/components/PageEditor.tsx | 33 +-
apps/web/src/components/PageSidebar.tsx | 217 +++++-
apps/web/src/components/SearchBar.tsx | 16 +-
apps/web/src/components/Skeleton.tsx | 41 ++
apps/web/src/docs-theme.css | 948 +++++++++++++++++++++++++
apps/web/src/main.tsx | 1 +
apps/web/src/styles.css | 308 +--------
ledger-deploy-ui.zip | Bin 0 -> 63455 bytes
15 files changed, 2708 insertions(+), 922 deletions(-)
create mode 100644 apps/web/src/AppShell.tsx
create mode 100644 apps/web/src/components/CommandSearch.tsx
create mode 100644 apps/web/src/components/DocsSidebar.tsx
create mode 100644 apps/web/src/components/EmptyState.tsx
create mode 100644 apps/web/src/components/Icon.tsx
create mode 100644 apps/web/src/components/Skeleton.tsx
create mode 100644 apps/web/src/docs-theme.css
create mode 100644 ledger-deploy-ui.zip
Diff
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index a937641..a380b1d 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,579 +1 @@
-import React, { useEffect, useState } from "react";
-import { Link, Route, Routes, useNavigate, useParams } from "react-router-dom";
-import type { PageDetail, PageSummary, SessionUser } from "@ledger/shared";
-import { FeedbackForm } from "./components/FeedbackForm";
-import { PageEditor } from "./components/PageEditor";
-import { PageSidebar } from "./components/PageSidebar";
-import { SearchBar } from "./components/SearchBar";
-import { api } from "./lib/api";
-
-type BrandingResponse = {
- branding: {
- siteName: string;
- logoUrl?: string | null;
- brandColor: string;
- footerText: string | null;
- publicKnowledgeBaseEnabled: boolean;
- };
-};
-
-type SetupStatus = {
- isInitialized: boolean;
- branding: {
- site_name: string;
- brand_color: string;
- } | null;
-};
-
-function useSession() {
- const [user, setUser] = useState<SessionUser | null>(null);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- api
- .get<{ user: SessionUser | null }>("/api/auth/session")
- .then((response) => setUser(response.user))
- .finally(() => setLoading(false));
- }, []);
-
- return { user, setUser, loading };
-}
-
-function Shell({
- children,
- branding,
- user,
- onLogout
-}: {
- children: React.ReactNode;
- branding: BrandingResponse["branding"] | null;
- user: SessionUser | null;
- onLogout: () => Promise<void>;
-}) {
- return (
- <div className="app-shell">
- <header className="topbar">
- <Link to="/" className="brand">
- <span className="brand-mark" style={{ background: branding?.brandColor ?? "#245cff" }} />
- <div>
- <strong>{branding?.siteName ?? "Ledger"}</strong>
- <small>Trusted answers for teams</small>
- </div>
- </Link>
- <nav className="topnav">
- <Link to="/">Knowledge base</Link>
- {user ? <Link to="/dashboard">Dashboard</Link> : <Link to="/login">Sign in</Link>}
- {user ? (
- <button className="button-secondary" onClick={onLogout}>
- Sign out
- </button>
- ) : null}
- </nav>
- </header>
- <main>{children}</main>
- <footer className="footer">{branding?.footerText ?? "Built for fast, trusted answers."}</footer>
- </div>
- );
-}
-
-function HomePage() {
- const navigate = useNavigate();
- const [spaces, setSpaces] = useState<Array<{ id: string; name: string; key: string; visibility: string }>>([]);
- const [results, setResults] = useState<PageSummary[]>([]);
-
- useEffect(() => {
- api
- .get<{ spaces: Array<{ id: string; name: string; key: string; visibility: string }> }>("/api/spaces")
- .then((response) => setSpaces(response.spaces))
- .catch(() => setSpaces([]));
- }, []);
-
- async function runSearch(query: string) {
- const response = await api.get<{ pages: PageSummary[] }>(
- `/api/search?q=${encodeURIComponent(query)}`
- );
- setResults(response.pages);
- }
-
- return (
- <div className="hero-layout">
- <section className="hero-card">
- <p className="eyebrow">Open-source knowledge base</p>
- <h1>Find the answer fast. Keep internal knowledge protected.</h1>
- <p className="lede">
- Ledger gives teams a public docs experience and an internal knowledge base on the same
- foundation, with backend-enforced visibility rules.
- </p>
- <SearchBar onSearch={runSearch} />
- </section>
- <section className="grid">
- <div className="card">
- <h3>Spaces</h3>
- <div className="stack">
- {spaces.map((space) => (
- <button key={space.id} className="space-link" onClick={() => navigate(`/space/${space.key}`)}>
- <span>{space.name}</span>
- <small>{space.visibility}</small>
- </button>
- ))}
- </div>
- </div>
- <div className="card">
- <h3>Search results</h3>
- <div className="stack">
- {results.length === 0 ? <p className="muted">Search the knowledge base to see results.</p> : null}
- {results.map((page) => (
- <Link key={page.id} to={`/page/${page.slug}`} className="result-item">
- <strong>{page.title}</strong>
- <span>{page.excerpt}</span>
- </Link>
- ))}
- </div>
- </div>
- </section>
- </div>
- );
-}
-
-function SpacePage() {
- const { spaceKey = "" } = useParams();
- const [pages, setPages] = useState<PageSummary[]>([]);
-
- useEffect(() => {
- api.get<{ pages: PageSummary[] }>(`/api/pages/space/${spaceKey}`).then((response) => setPages(response.pages));
- }, [spaceKey]);
-
- return (
- <div className="content-layout">
- <PageSidebar pages={pages} title={`Space: ${spaceKey}`} />
- <section className="card">
- <h2>{spaceKey}</h2>
- <p className="muted">Select a page from the sidebar.</p>
- </section>
- </div>
- );
-}
-
-function PageView() {
- const { slug = "" } = useParams();
- const [page, setPage] = useState<PageDetail | null>(null);
- const [related, setRelated] = useState<PageSummary[]>([]);
-
- useEffect(() => {
- api.get<{ page: PageDetail }>(`/api/pages/slug/${slug}`).then((response) => {
- setPage(response.page);
- return api.get<{ pages: PageSummary[] }>(`/api/pages/space/${response.page.spaceId}`);
- }).then((response) => setRelated(response.pages))
- .catch(() => {
- setPage(null);
- setRelated([]);
- });
- }, [slug]);
-
- if (!page) {
- return <section className="card">Page not found or not visible to your account.</section>;
- }
-
- return (
- <div className="content-layout">
- <PageSidebar pages={related} title="Pages" />
- <article className="page-card">
- <div className="page-card__header">
- <div>
- <p className="eyebrow">{page.visibility}</p>
- <h1>{page.title}</h1>
- </div>
- <span className={`pill pill-${page.visibility}`}>{page.state}</span>
- </div>
- <div className="toc">
- {page.toc.map((item) => (
- <a key={item.id} href={`#${item.id}`}>
- {item.text}
- </a>
- ))}
- </div>
- <div className="markdown" dangerouslySetInnerHTML={{ __html: page.bodyHtml }} />
- <FeedbackForm pageId={page.id} revisionId={page.revisionId} />
- </article>
- </div>
- );
-}
-
-function LoginPage({ onLogin }: { onLogin: (user: SessionUser) => void }) {
- const navigate = useNavigate();
- const [email, setEmail] = useState("");
- const [password, setPassword] = useState("");
- const [error, setError] = useState<string | null>(null);
-
- async function submit(event: React.FormEvent) {
- event.preventDefault();
- try {
- const response = await api.post<{ user: SessionUser }>("/api/auth/login", { email, password });
- onLogin(response.user);
- navigate("/dashboard");
- } catch (reason) {
- setError(reason instanceof Error ? reason.message : "Could not sign in");
- }
- }
-
- return (
- <section className="auth-card">
- <p className="eyebrow">Sign in</p>
- <h1>Access internal knowledge</h1>
- <form className="stack" onSubmit={submit}>
- <label>
- Email
- <input value={email} onChange={(event) => setEmail(event.target.value)} />
- </label>
- <label>
- Password
- <input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
- </label>
- <button type="submit">Sign in</button>
- </form>
- {error ? <p className="muted">{error}</p> : null}
- </section>
- );
-}
-
-function SetupPage({
- onInitialized,
- initialBranding
-}: {
- onInitialized: (user: SessionUser, branding: BrandingResponse["branding"]) => void;
- initialBranding: SetupStatus["branding"];
-}) {
- const navigate = useNavigate();
- const [form, setForm] = useState({
- siteName: initialBranding?.site_name ?? "Ledger",
- brandColor: initialBranding?.brand_color ?? "#245cff",
- footerText: "Built for fast, trusted answers.",
- publicKnowledgeBaseEnabled: true,
- ownerEmail: "",
- ownerDisplayName: "",
- password: ""
- });
- const [status, setStatus] = useState<string | null>(null);
-
- async function submit(event: React.FormEvent) {
- event.preventDefault();
- try {
- const response = await api.post<{ user: SessionUser }>("/api/setup/initialize", {
- ...form,
- footerText: form.footerText || null
- });
-
- onInitialized(response.user, {
- siteName: form.siteName,
- logoUrl: null,
- brandColor: form.brandColor,
- footerText: form.footerText || null,
- publicKnowledgeBaseEnabled: form.publicKnowledgeBaseEnabled
- });
- navigate("/dashboard");
- } catch (error) {
- setStatus(error instanceof Error ? error.message : "Could not initialize Ledger");
- }
- }
-
- return (
- <section className="setup-layout">
- <div className="hero-card">
- <p className="eyebrow">First-time setup</p>
- <h1>Create your Ledger base</h1>
- <p className="lede">
- Set your site identity, choose whether public docs are enabled, and create the first
- owner account. This happens once on a fresh install.
- </p>
- </div>
- <form className="card stack" onSubmit={submit}>
- <div className="split">
- <label>
- Site name
- <input
- value={form.siteName}
- onChange={(event) => setForm((current) => ({ ...current, siteName: event.target.value }))}
- />
- </label>
- <label>
- Brand color
- <input
- value={form.brandColor}
- onChange={(event) =>
- setForm((current) => ({ ...current, brandColor: event.target.value }))
- }
- />
- </label>
- </div>
- <label>
- Footer text
- <input
- value={form.footerText}
- onChange={(event) => setForm((current) => ({ ...current, footerText: event.target.value }))}
- />
- </label>
- <label className="checkbox-row">
- <input
- type="checkbox"
- checked={form.publicKnowledgeBaseEnabled}
- onChange={(event) =>
- setForm((current) => ({
- ...current,
- publicKnowledgeBaseEnabled: event.target.checked
- }))
- }
- />
- Enable the public knowledge base immediately
- </label>
- <div className="split">
- <label>
- Owner name
- <input
- value={form.ownerDisplayName}
- onChange={(event) =>
- setForm((current) => ({ ...current, ownerDisplayName: event.target.value }))
- }
- />
- </label>
- <label>
- Owner email
- <input
- value={form.ownerEmail}
- onChange={(event) => setForm((current) => ({ ...current, ownerEmail: event.target.value }))}
- />
- </label>
- </div>
- <label>
- Password
- <input
- type="password"
- value={form.password}
- onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))}
- />
- </label>
- <button type="submit">Initialize Ledger</button>
- {status ? <p className="muted">{status}</p> : null}
- </form>
- </section>
- );
-}
-
-function Dashboard({ user }: { user: SessionUser }) {
- const [spaces, setSpaces] = useState<Array<{ id: string; name: string; key: string }>>([]);
- const [analytics, setAnalytics] = useState<{
- topSearches: Array<{ query: string; count: number }>;
- noResults: Array<{ query: string; count: number }>;
- } | null>(null);
- const [feedback, setFeedback] = useState<Array<{ id: string; page_title: string; helpful: boolean; comment: string | null }> | null>(null);
- const [brandingForm, setBrandingForm] = useState({
- siteName: "Ledger",
- logoUrl: "",
- brandColor: "#245cff",
- footerText: "",
- publicKnowledgeBaseEnabled: true
- });
- const [settingsStatus, setSettingsStatus] = useState<string | null>(null);
-
- useEffect(() => {
- api
- .get<{ spaces: Array<{ id: string; name: string; key: string }> }>("/api/spaces")
- .then((response) => setSpaces(response.spaces));
- }, []);
-
- useEffect(() => {
- if (user.role === "admin" || user.role === "owner") {
- api
- .get<{
- topSearches: Array<{ query: string; count: number }>;
- noResults: Array<{ query: string; count: number }>;
- }>("/api/admin/search-analytics")
- .then(setAnalytics);
- api
- .get<{ feedback: Array<{ id: string; page_title: string; helpful: boolean; comment: string | null }> }>(
- "/api/admin/feedback"
- )
- .then((response) => setFeedback(response.feedback));
- api.get<{ branding: { site_name: string; logo_url: string | null; brand_color: string; footer_text: string | null; public_knowledge_base_enabled: boolean } }>("/api/settings/admin").then((response) => {
- const branding = response.branding;
- setBrandingForm({
- siteName: branding.site_name,
- logoUrl: branding.logo_url ?? "",
- brandColor: branding.brand_color,
- footerText: branding.footer_text ?? "",
- publicKnowledgeBaseEnabled: branding.public_knowledge_base_enabled
- });
- });
- }
- }, [user.role]);
-
- async function saveBranding() {
- try {
- await api.put("/api/settings/branding", {
- siteName: brandingForm.siteName,
- logoUrl: brandingForm.logoUrl || null,
- brandColor: brandingForm.brandColor,
- footerText: brandingForm.footerText || null,
- publicKnowledgeBaseEnabled: brandingForm.publicKnowledgeBaseEnabled
- });
- setSettingsStatus("Branding saved.");
- } catch (error) {
- setSettingsStatus(error instanceof Error ? error.message : "Could not save branding");
- }
- }
-
- return (
- <div className="dashboard-grid">
- <section className="card">
- <p className="eyebrow">Signed in as {user.displayName}</p>
- <h2>Knowledge base dashboard</h2>
- <p className="muted">Role: {user.role}</p>
- </section>
- {user.role === "editor" || user.role === "admin" || user.role === "owner" ? (
- <PageEditor spaces={spaces} />
- ) : null}
- {analytics ? (
- <section className="card">
- <h3>Search analytics</h3>
- <div className="split">
- <div>
- <strong>Top searches</strong>
- {analytics.topSearches.map((item) => (
- <p key={item.query}>{item.query} ({item.count})</p>
- ))}
- </div>
- <div>
- <strong>No results</strong>
- {analytics.noResults.map((item) => (
- <p key={item.query}>{item.query} ({item.count})</p>
- ))}
- </div>
- </div>
- </section>
- ) : null}
- {feedback ? (
- <section className="card">
- <h3>Feedback queue</h3>
- {feedback.map((item) => (
- <div key={item.id} className="feedback-item">
- <strong>{item.page_title}</strong>
- <span>{item.helpful ? "Helpful" : "Not helpful"}</span>
- <p>{item.comment ?? "No comment"}</p>
- </div>
- ))}
- </section>
- ) : null}
- {user.role === "admin" || user.role === "owner" ? (
- <section className="card">
- <h3>Brand settings</h3>
- <div className="stack">
- <label>
- Site name
- <input
- value={brandingForm.siteName}
- onChange={(event) =>
- setBrandingForm((current) => ({ ...current, siteName: event.target.value }))
- }
- />
- </label>
- <label>
- Brand color
- <input
- value={brandingForm.brandColor}
- onChange={(event) =>
- setBrandingForm((current) => ({ ...current, brandColor: event.target.value }))
- }
- />
- </label>
- <label>
- Footer text
- <input
- value={brandingForm.footerText}
- onChange={(event) =>
- setBrandingForm((current) => ({ ...current, footerText: event.target.value }))
- }
- />
- </label>
- <label className="checkbox-row">
- <input
- type="checkbox"
- checked={brandingForm.publicKnowledgeBaseEnabled}
- onChange={(event) =>
- setBrandingForm((current) => ({
- ...current,
- publicKnowledgeBaseEnabled: event.target.checked
- }))
- }
- />
- Public knowledge base enabled
- </label>
- <button onClick={saveBranding}>Save branding</button>
- {settingsStatus ? <p className="muted">{settingsStatus}</p> : null}
- </div>
- </section>
- ) : null}
- </div>
- );
-}
-
-export function App() {
- const { user, setUser, loading } = useSession();
- const [branding, setBranding] = useState<BrandingResponse["branding"] | null>(null);
- const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
-
- useEffect(() => {
- api.get<BrandingResponse>("/api/settings/public").then((response) => setBranding(response.branding));
- api.get<SetupStatus>("/api/setup/status").then(setSetupStatus);
- }, []);
-
- async function logout() {
- await api.post("/api/auth/logout");
- setUser(null);
- }
-
- if (loading || !setupStatus) {
- return <div className="loading-screen">Loading Ledger...</div>;
- }
-
- if (!setupStatus.isInitialized) {
- return (
- <Shell branding={branding} user={null} onLogout={logout}>
- <Routes>
- <Route
- path="*"
- element={
- <SetupPage
- initialBranding={setupStatus.branding}
- onInitialized={(initializedUser, initializedBranding) => {
- setUser(initializedUser);
- setBranding(initializedBranding);
- setSetupStatus({
- isInitialized: true,
- branding: {
- site_name: initializedBranding.siteName,
- brand_color: initializedBranding.brandColor
- }
- });
- }}
- />
- }
- />
- </Routes>
- </Shell>
- );
- }
-
- return (
- <Shell branding={branding} user={user} onLogout={logout}>
- <Routes>
- <Route path="/" element={<HomePage />} />
- <Route path="/login" element={<LoginPage onLogin={setUser} />} />
- <Route path="/space/:spaceKey" element={<SpacePage />} />
- <Route path="/page/:slug" element={<PageView />} />
- <Route
- path="/dashboard"
- element={user ? <Dashboard user={user} /> : <LoginPage onLogin={setUser} />}
- />
- </Routes>
- </Shell>
- );
-}
+export { App } from "./AppShell";
diff --git a/apps/web/src/AppShell.tsx b/apps/web/src/AppShell.tsx
new file mode 100644
index 0000000..c9cbd75
--- /dev/null
+++ b/apps/web/src/AppShell.tsx
@@ -0,0 +1,1062 @@
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import { Link, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
+import type { PageDetail, PageSummary, SessionUser } from "@ledger/shared";
+import { CommandSearch } from "./components/CommandSearch";
+import { EmptyState } from "./components/EmptyState";
+import { FeedbackForm } from "./components/FeedbackForm";
+import { Icon } from "./components/Icon";
+import { PageEditor } from "./components/PageEditor";
+import { DocsSidebar } from "./components/DocsSidebar";
+import { SearchBar } from "./components/SearchBar";
+import { ContentSkeleton, SidebarSkeleton } from "./components/Skeleton";
+import { api } from "./lib/api";
+
+type BrandingResponse = {
+ branding: {
+ siteName: string;
+ logoUrl?: string | null;
+ brandColor: string;
+ footerText: string | null;
+ publicKnowledgeBaseEnabled: boolean;
+ };
+};
+
+type SetupStatus = {
+ isInitialized: boolean;
+ branding: {
+ site_name: string;
+ brand_color: string;
+ } | null;
+};
+
+type Space = {
+ id: string;
+ name: string;
+ key: string;
+ visibility: string;
+};
+
+type PageRecordMap = Record<string, PageSummary[]>;
+
+function useSession() {
+ const [user, setUser] = useState<SessionUser | null>(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ api
+ .get<{ user: SessionUser | null }>("/api/auth/session")
+ .then((response) => setUser(response.user))
+ .finally(() => setLoading(false));
+ }, []);
+
+ return { user, setUser, loading };
+}
+
+function useKnowledgeBaseData(enabled: boolean) {
+ const [spaces, setSpaces] = useState<Space[]>([]);
+ const [pagesBySpace, setPagesBySpace] = useState<PageRecordMap>({});
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ let active = true;
+ setLoading(true);
+ setError(null);
+
+ api
+ .get<{ spaces: Space[] }>("/api/spaces")
+ .then(async (response) => {
+ if (!active) return;
+ setSpaces(response.spaces);
+
+ const entries = await Promise.all(
+ response.spaces.map(async (space) => {
+ const pages = await api.get<{ pages: PageSummary[] }>(`/api/pages/space/${space.key}`);
+ return [space.key, pages.pages] as const;
+ })
+ );
+
+ if (!active) return;
+ setPagesBySpace(Object.fromEntries(entries));
+ })
+ .catch((reason) => {
+ if (!active) return;
+ setError(reason instanceof Error ? reason.message : "Could not load documentation.");
+ })
+ .finally(() => {
+ if (active) setLoading(false);
+ });
+
+ return () => {
+ active = false;
+ };
+ }, [enabled]);
+
+ return { spaces, pagesBySpace, loading, error };
+}
+
+function useCommandShortcut(openSearch: () => void) {
+ useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ const target = event.target as HTMLElement | null;
+ const isTyping =
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target?.isContentEditable;
+
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
+ event.preventDefault();
+ openSearch();
+ }
+
+ if (!isTyping && event.key === "/") {
+ event.preventDefault();
+ openSearch();
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [openSearch]);
+}
+
+function findCurrentSpace(spaces: Space[], page: PageDetail | null, pathname: string) {
+ if (page) {
+ return spaces.find((space) => space.id === page.spaceId);
+ }
+
+ const match = pathname.match(/^\/space\/([^/]+)/);
+ if (!match) {
+ return undefined;
+ }
+
+ return spaces.find((space) => space.key === match[1]);
+}
+
+function buildBreadcrumbs(page: PageDetail, pages: PageSummary[], space?: Space) {
+ const byId = new Map(pages.map((item) => [item.id, item]));
+ const trail: Array<{ title: string; slug?: string }> = [];
+ let current: PageSummary | undefined = page;
+
+ while (current?.parentPageId) {
+ const parent = byId.get(current.parentPageId);
+ if (!parent) break;
+ trail.unshift({ title: parent.title, slug: parent.slug });
+ current = parent;
+ }
+
+ return [
+ { title: "Knowledge base" },
+ ...(space ? [{ title: space.name }] : []),
+ ...trail,
+ { title: page.title, slug: page.slug }
+ ];
+}
+
+function addCopyButtons(container: HTMLElement | null) {
+ if (!container) return;
+
+ container.querySelectorAll("pre").forEach((pre) => {
+ if (pre.querySelector(".code-copy")) {
+ return;
+ }
+
+ const code = pre.querySelector("code");
+ if (!code) return;
+
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "code-copy";
+ button.textContent = "Copy";
+ button.addEventListener("click", async () => {
+ try {
+ await navigator.clipboard.writeText(code.textContent ?? "");
+ button.textContent = "Copied";
+ window.setTimeout(() => {
+ button.textContent = "Copy";
+ }, 1200);
+ } catch {
+ button.textContent = "Failed";
+ }
+ });
+
+ pre.appendChild(button);
+ });
+}
+
+function DocsShell({
+ branding,
+ user,
+ spaces,
+ pagesBySpace,
+ loadingNavigation,
+ navigationError,
+ searchResults,
+ searchLoading,
+ onSearch,
+ onLogout,
+ children
+}: {
+ branding: BrandingResponse["branding"] | null;
+ user: SessionUser | null;
+ spaces: Space[];
+ pagesBySpace: PageRecordMap;
+ loadingNavigation: boolean;
+ navigationError: string | null;
+ searchResults: PageSummary[];
+ searchLoading: boolean;
+ onSearch: (query: string) => void;
+ onLogout: () => Promise<void>;
+ children: React.ReactNode;
+}) {
+ const location = useLocation();
+ const [searchOpen, setSearchOpen] = useState(false);
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+ const currentPageSlug = location.pathname.startsWith("/page/")
+ ? decodeURIComponent(location.pathname.split("/page/")[1] ?? "")
+ : undefined;
+
+ useCommandShortcut(() => setSearchOpen(true));
+
+ const currentPage = useMemo(
+ () =>
+ Object.values(pagesBySpace)
+ .flat()
+ .find((page) => page.slug === currentPageSlug),
+ [currentPageSlug, pagesBySpace]
+ );
+
+ const currentSpace = currentPage
+ ? spaces.find((space) => space.id === currentPage.spaceId)
+ : findCurrentSpace(spaces, null, location.pathname);
+
+ return (
+ <div className="kb-app">
+ <CommandSearch
+ open={searchOpen}
+ onClose={() => setSearchOpen(false)}
+ onSearch={onSearch}
+ spaces={spaces}
+ results={searchResults}
+ isLoading={searchLoading}
+ />
+
+ {loadingNavigation ? (
+ <SidebarSkeleton />
+ ) : navigationError ? (
+ <aside className="sidebar sidebar-state">
+ <EmptyState eyebrow="Navigation" title="Could not load collections" description={navigationError} />
+ </aside>
+ ) : (
+ <DocsSidebar
+ spaces={spaces}
+ pagesBySpace={pagesBySpace}
+ currentSpaceKey={currentSpace?.key}
+ currentSlug={currentPageSlug}
+ user={user ? { displayName: user.displayName, role: user.role } : null}
+ isOpen={sidebarOpen}
+ onClose={() => setSidebarOpen(false)}
+ />
+ )}
+
+ <div className="kb-main">
+ <header className="app-header">
+ <div className="app-header__left">
+ <button type="button" className="button-ghost mobile-only" onClick={() => setSidebarOpen(true)}>
+ <Icon name="menu" className="icon" />
+ </button>
+ <Link to="/" className="brand brand-header">
+ <span className="brand-mark" style={{ background: branding?.brandColor ?? "#245cff" }} />
+ <div>
+ <strong>{branding?.siteName ?? "Ledger"}</strong>
+ <small>Documentation workspace</small>
+ </div>
+ </Link>
+ </div>
+
+ <div className="app-header__center">
+ <button type="button" className="search-launcher" onClick={() => setSearchOpen(true)}>
+ <Icon name="search" className="icon icon-sm" />
+ <span>Search documentation</span>
+ <kbd>{navigator.platform.toLowerCase().includes("mac") ? "Cmd K" : "Ctrl K"}</kbd>
+ </button>
+ </div>
+
+ <div className="app-header__right">
+ {user ? (
+ <>
+ <Link to="/dashboard" className="button-ghost">Manage</Link>
+ <button className="button-ghost" onClick={onLogout}>Sign out</button>
+ </>
+ ) : (
+ <Link to="/login" className="button-ghost">Sign in</Link>
+ )}
+ </div>
+ </header>
+
+ <main className="app-content">{children}</main>
+ <footer className="app-footer">{branding?.footerText ?? "Built for fast, trusted answers."}</footer>
+ </div>
+ </div>
+ );
+}
+
+function HomePage({
+ spaces,
+ pagesBySpace,
+ onSearch
+}: {
+ spaces: Space[];
+ pagesBySpace: PageRecordMap;
+ onSearch: (query: string) => void;
+}) {
+ const navigate = useNavigate();
+ const allPages = useMemo(
+ () =>
+ Object.values(pagesBySpace)
+ .flat()
+ .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)),
+ [pagesBySpace]
+ );
+
+ return (
+ <div className="overview-layout">
+ <section className="overview-hero">
+ <div>
+ <p className="eyebrow">Documentation hub</p>
+ <h1>Find trusted answers, onboarding guides, and internal knowledge.</h1>
+ <p className="lede">
+ Browse collections, jump into recently updated docs, or search across your knowledge
+ base from one calm workspace.
+ </p>
+ </div>
+ <SearchBar onSearch={onSearch} placeholder="Search by title, concept, or workflow" />
+ </section>
+
+ <section className="overview-grid">
+ <div className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Collections</p>
+ <h3>Browse by space</h3>
+ </div>
+ </div>
+ <div className="collection-cards">
+ {spaces.map((space) => (
+ <button key={space.id} className="collection-card" onClick={() => navigate(`/space/${space.key}`)}>
+ <div className="collection-card__icon">
+ <Icon name="collection" className="icon" />
+ </div>
+ <div>
+ <strong>{space.name}</strong>
+ <p>{space.visibility} knowledge</p>
+ </div>
+ <span>{(pagesBySpace[space.key] ?? []).length} docs</span>
+ </button>
+ ))}
+ </div>
+ </div>
+
+ <div className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Recently updated</p>
+ <h3>Fresh documentation</h3>
+ </div>
+ </div>
+ <div className="article-list">
+ {allPages.slice(0, 8).map((page) => (
+ <Link key={page.id} to={`/page/${page.slug}`} className="article-list__item">
+ <div className="article-list__meta">
+ <strong>{page.title}</strong>
+ <span>{spaces.find((space) => space.id === page.spaceId)?.name ?? "Collection"}</span>
+ </div>
+ <p>{page.excerpt ?? "Open this document to read more."}</p>
+ </Link>
+ ))}
+ {allPages.length === 0 ? (
+ <EmptyState
+ title="No published articles yet"
+ description="Complete setup, then create your first pages from the manage screen."
+ />
+ ) : null}
+ </div>
+ </div>
+ </section>
+ </div>
+ );
+}
+
+function SpacePage({ spaces, pagesBySpace }: { spaces: Space[]; pagesBySpace: PageRecordMap }) {
+ const { spaceKey = "" } = useParams();
+ const space = spaces.find((entry) => entry.key === spaceKey);
+ const pages = pagesBySpace[spaceKey] ?? [];
+
+ return (
+ <div className="document-layout">
+ <section className="doc-card">
+ <div className="breadcrumbs">
+ <Link to="/">Knowledge base</Link>
+ <span>/</span>
+ <span>{space?.name ?? spaceKey}</span>
+ </div>
+ <div className="doc-card__header">
+ <div>
+ <p className="eyebrow">Collection</p>
+ <h1>{space?.name ?? spaceKey}</h1>
+ <p className="lede">{pages.length} published documents are currently visible in this collection.</p>
+ </div>
+ <span className={`badge badge-${space?.visibility ?? "internal"}`}>{space?.visibility ?? "internal"}</span>
+ </div>
+ {pages.length === 0 ? (
+ <EmptyState
+ title="This collection is empty"
+ description="Add documents from the manage screen to build out this space."
+ />
+ ) : (
+ <div className="article-list">
+ {pages.map((page) => (
+ <Link key={page.id} to={`/page/${page.slug}`} className="article-list__item">
+ <div className="article-list__meta">
+ <strong>{page.title}</strong>
+ <span>{page.visibility} - {new Date(page.updatedAt).toLocaleDateString()}</span>
+ </div>
+ <p>{page.excerpt ?? "Open this document to continue reading."}</p>
+ </Link>
+ ))}
+ </div>
+ )}
+ </section>
+ <aside className="right-rail">
+ <section className="rail-card">
+ <p className="eyebrow">Collection info</p>
+ <h3>{space?.name ?? "Collection"}</h3>
+ <p className="muted">
+ Use the left navigation tree to browse pages and nested structure inside this space.
+ </p>
+ </section>
+ </aside>
+ </div>
+ );
+}
+
+function PageView({
+ spaces,
+ pagesBySpace,
+ user
+}: {
+ spaces: Space[];
+ pagesBySpace: PageRecordMap;
+ user: SessionUser | null;
+}) {
+ const { slug = "" } = useParams();
+ const [page, setPage] = useState<PageDetail | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const articleRef = useRef<HTMLElement>(null);
+
+ useEffect(() => {
+ setLoading(true);
+ setError(null);
+
+ api
+ .get<{ page: PageDetail }>(`/api/pages/slug/${slug}`)
+ .then((response) => setPage(response.page))
+ .catch((reason) => {
+ setError(reason instanceof Error ? reason.message : "Could not load this page.");
+ setPage(null);
+ })
+ .finally(() => setLoading(false));
+ }, [slug]);
+
+ useEffect(() => {
+ if (page) {
+ addCopyButtons(articleRef.current);
+ }
+ }, [page]);
+
+ if (loading) {
+ return (
+ <div className="document-layout">
+ <ContentSkeleton />
+ <aside className="right-rail">
+ <div className="rail-card">
+ <div className="skeleton skeleton-title" />
+ <div className="skeleton skeleton-line" />
+ <div className="skeleton skeleton-line short" />
+ </div>
+ </aside>
+ </div>
+ );
+ }
+
+ if (!page) {
+ return (
+ <div className="document-layout">
+ <section className="doc-card">
+ <EmptyState
+ eyebrow="Document"
+ title="Page not found"
+ description={error ?? "This page may not exist, or your account may not be allowed to view it."}
+ action={<Link to="/" className="button-secondary">Back to knowledge base</Link>}
+ />
+ </section>
+ </div>
+ );
+ }
+
+ const space = spaces.find((entry) => entry.id === page.spaceId);
+ const breadcrumbs = buildBreadcrumbs(page, pagesBySpace[space?.key ?? ""] ?? [], space);
+
+ async function copyLink() {
+ await navigator.clipboard.writeText(window.location.href);
+ }
+
+ return (
+ <div className="document-layout">
+ <article className="doc-card">
+ <div className="breadcrumbs">
+ {breadcrumbs.map((item, index) => (
+ <React.Fragment key={`${item.title}-${index}`}>
+ {index > 0 ? <span>/</span> : null}
+ {item.slug && index < breadcrumbs.length - 1 ? (
+ <Link to={`/page/${item.slug}`}>{item.title}</Link>
+ ) : (
+ <span>{item.title}</span>
+ )}
+ </React.Fragment>
+ ))}
+ </div>
+
+ <header className="doc-card__header doc-card__header-spread">
+ <div>
+ <div className="doc-badges">
+ <span className={`badge badge-${page.visibility}`}>
+ <Icon name={page.visibility === "public" ? "globe" : "lock"} className="icon icon-sm" />
+ {page.visibility}
+ </span>
+ <span className="badge badge-muted">{page.state}</span>
+ </div>
+ <h1>{page.title}</h1>
+ <p className="doc-meta">
+ Updated {new Date(page.updatedAt).toLocaleDateString()} by {page.authorName}
+ </p>
+ </div>
+
+ <div className="doc-actions">
+ <button type="button" className="button-secondary" onClick={copyLink}>
+ <Icon name="copy" className="icon icon-sm" />
+ Copy link
+ </button>
+ {user && (user.role === "editor" || user.role === "admin" || user.role === "owner") ? (
+ <Link to="/dashboard" className="button-secondary">
+ <Icon name="external" className="icon icon-sm" />
+ Edit in manage
+ </Link>
+ ) : null}
+ </div>
+ </header>
+
+ {page.excerpt ? <p className="doc-summary">{page.excerpt}</p> : null}
+
+ <div ref={articleRef} className="markdown markdown-prose" dangerouslySetInnerHTML={{ __html: page.bodyHtml }} />
+ <FeedbackForm pageId={page.id} revisionId={page.revisionId} />
+ </article>
+
+ <aside className="right-rail">
+ <section className="rail-card">
+ <p className="eyebrow">On this page</p>
+ {page.toc.length === 0 ? <p className="muted">No headings yet.</p> : null}
+ <nav className="toc-rail">
+ {page.toc.map((item) => (
+ <a key={item.id} href={`#${item.id}`} style={{ paddingLeft: `${(item.level - 1) * 0.75}rem` }}>
+ {item.text}
+ </a>
+ ))}
+ </nav>
+ </section>
+
+ <section className="rail-card">
+ <p className="eyebrow">Context</p>
+ <div className="context-list">
+ <div>
+ <span>Collection</span>
+ <strong>{space?.name ?? "Unknown"}</strong>
+ </div>
+ <div>
+ <span>State</span>
+ <strong>{page.state}</strong>
+ </div>
+ <div>
+ <span>Access</span>
+ <strong>{page.visibility}</strong>
+ </div>
+ </div>
+ </section>
+ </aside>
+ </div>
+ );
+}
+
+function LoginPage({ onLogin }: { onLogin: (user: SessionUser) => void }) {
+ const navigate = useNavigate();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState<string | null>(null);
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault();
+ try {
+ const response = await api.post<{ user: SessionUser }>("/api/auth/login", { email, password });
+ onLogin(response.user);
+ navigate("/dashboard");
+ } catch (reason) {
+ setError(reason instanceof Error ? reason.message : "Could not sign in");
+ }
+ }
+
+ return (
+ <div className="auth-layout">
+ <section className="auth-panel">
+ <p className="eyebrow">Secure access</p>
+ <h1>Sign in to internal documentation</h1>
+ <p className="muted">
+ Access private knowledge, restricted runbooks, and editing tools with your Ledger account.
+ </p>
+ </section>
+ <form className="panel auth-form" onSubmit={submit}>
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Authentication</p>
+ <h3>Welcome back</h3>
+ </div>
+ </div>
+ <label className="field">
+ Email
+ <input value={email} onChange={(event) => setEmail(event.target.value)} />
+ </label>
+ <label className="field">
+ Password
+ <input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
+ </label>
+ <button type="submit">Sign in</button>
+ {error ? <p className="muted">{error}</p> : null}
+ </form>
+ </div>
+ );
+}
+
+function SetupPage({
+ onInitialized,
+ initialBranding
+}: {
+ onInitialized: (user: SessionUser, branding: BrandingResponse["branding"]) => void;
+ initialBranding: SetupStatus["branding"];
+}) {
+ const navigate = useNavigate();
+ const [form, setForm] = useState({
+ siteName: initialBranding?.site_name ?? "Ledger",
+ brandColor: initialBranding?.brand_color ?? "#245cff",
+ footerText: "Built for fast, trusted answers.",
+ publicKnowledgeBaseEnabled: true,
+ ownerEmail: "",
+ ownerDisplayName: "",
+ password: ""
+ });
+ const [status, setStatus] = useState<string | null>(null);
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault();
+ try {
+ const response = await api.post<{ user: SessionUser }>("/api/setup/initialize", {
+ ...form,
+ footerText: form.footerText || null
+ });
+
+ onInitialized(response.user, {
+ siteName: form.siteName,
+ logoUrl: null,
+ brandColor: form.brandColor,
+ footerText: form.footerText || null,
+ publicKnowledgeBaseEnabled: form.publicKnowledgeBaseEnabled
+ });
+
+ navigate("/dashboard");
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not initialize Ledger");
+ }
+ }
+
+ return (
+ <div className="setup-screen">
+ <section className="auth-panel">
+ <p className="eyebrow">First-time setup</p>
+ <h1>Create your Ledger knowledge base</h1>
+ <p className="lede">
+ Define the base identity for your documentation workspace, create the first owner, and
+ launch into collections, content, and integrations.
+ </p>
+ <div className="setup-points">
+ <div><Icon name="collection" className="icon icon-sm" /> Collections and page structure</div>
+ <div><Icon name="search" className="icon icon-sm" /> Search-first reading experience</div>
+ <div><Icon name="settings" className="icon icon-sm" /> Ready for branding and integrations</div>
+ </div>
+ </section>
+
+ <form className="panel setup-form" onSubmit={submit}>
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Workspace settings</p>
+ <h3>Initialize Ledger</h3>
+ </div>
+ </div>
+
+ <div className="field-grid">
+ <label className="field">
+ Site name
+ <input
+ value={form.siteName}
+ onChange={(event) => setForm((current) => ({ ...current, siteName: event.target.value }))}
+ />
+ </label>
+ <label className="field">
+ Brand color
+ <input
+ value={form.brandColor}
+ onChange={(event) => setForm((current) => ({ ...current, brandColor: event.target.value }))}
+ />
+ </label>
+ </div>
+
+ <label className="field">
+ Footer text
+ <input
+ value={form.footerText}
+ onChange={(event) => setForm((current) => ({ ...current, footerText: event.target.value }))}
+ />
+ </label>
+
+ <label className="checkbox-row checkbox-card">
+ <input
+ type="checkbox"
+ checked={form.publicKnowledgeBaseEnabled}
+ onChange={(event) =>
+ setForm((current) => ({
+ ...current,
+ publicKnowledgeBaseEnabled: event.target.checked
+ }))
+ }
+ />
+ <span>Enable the public knowledge base immediately</span>
+ </label>
+
+ <div className="field-grid">
+ <label className="field">
+ Owner name
+ <input
+ value={form.ownerDisplayName}
+ onChange={(event) => setForm((current) => ({ ...current, ownerDisplayName: event.target.value }))}
+ />
+ </label>
+ <label className="field">
+ Owner email
+ <input
+ value={form.ownerEmail}
+ onChange={(event) => setForm((current) => ({ ...current, ownerEmail: event.target.value }))}
+ />
+ </label>
+ </div>
+
+ <label className="field">
+ Password
+ <input
+ type="password"
+ value={form.password}
+ onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))}
+ />
+ </label>
+
+ <button type="submit">Create workspace</button>
+ {status ? <p className="muted">{status}</p> : null}
+ </form>
+ </div>
+ );
+}
+
+function Dashboard({ user, spaces }: { user: SessionUser; spaces: Space[] }) {
+ const [analytics, setAnalytics] = useState<{
+ topSearches: Array<{ query: string; count: number }>;
+ noResults: Array<{ query: string; count: number }>;
+ } | null>(null);
+ const [feedback, setFeedback] = useState<Array<{ id: string; page_title: string; helpful: boolean; comment: string | null }> | null>(null);
+ const [brandingForm, setBrandingForm] = useState({
+ siteName: "Ledger",
+ logoUrl: "",
+ brandColor: "#245cff",
+ footerText: "",
+ publicKnowledgeBaseEnabled: true
+ });
+ const [settingsStatus, setSettingsStatus] = useState<string | null>(null);
+
+ useEffect(() => {
+ if (user.role === "admin" || user.role === "owner") {
+ api
+ .get<{
+ topSearches: Array<{ query: string; count: number }>;
+ noResults: Array<{ query: string; count: number }>;
+ }>("/api/admin/search-analytics")
+ .then(setAnalytics);
+ api
+ .get<{ feedback: Array<{ id: string; page_title: string; helpful: boolean; comment: string | null }> }>("/api/admin/feedback")
+ .then((response) => setFeedback(response.feedback));
+ api
+ .get<{ branding: { site_name: string; logo_url: string | null; brand_color: string; footer_text: string | null; public_knowledge_base_enabled: boolean } }>("/api/settings/admin")
+ .then((response) => {
+ const branding = response.branding;
+ setBrandingForm({
+ siteName: branding.site_name,
+ logoUrl: branding.logo_url ?? "",
+ brandColor: branding.brand_color,
+ footerText: branding.footer_text ?? "",
+ publicKnowledgeBaseEnabled: branding.public_knowledge_base_enabled
+ });
+ });
+ }
+ }, [user.role]);
+
+ async function saveBranding() {
+ try {
+ await api.put("/api/settings/branding", {
+ siteName: brandingForm.siteName,
+ logoUrl: brandingForm.logoUrl || null,
+ brandColor: brandingForm.brandColor,
+ footerText: brandingForm.footerText || null,
+ publicKnowledgeBaseEnabled: brandingForm.publicKnowledgeBaseEnabled
+ });
+ setSettingsStatus("Brand settings saved.");
+ } catch (error) {
+ setSettingsStatus(error instanceof Error ? error.message : "Could not save branding");
+ }
+ }
+
+ return (
+ <div className="manage-layout">
+ <section className="panel manage-hero">
+ <div>
+ <p className="eyebrow">Manage workspace</p>
+ <h1>{user.displayName}</h1>
+ <p className="lede">Create documents, tune branding, and use search analytics to improve the knowledge base.</p>
+ </div>
+ <div className="manage-hero__meta">
+ <span className="pill">{user.role}</span>
+ <span className="pill">{spaces.length} collections</span>
+ </div>
+ </section>
+
+ <div className="manage-grid">
+ {user.role === "editor" || user.role === "admin" || user.role === "owner" ? (
+ <PageEditor spaces={spaces} />
+ ) : null}
+
+ {user.role === "admin" || user.role === "owner" ? (
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Branding</p>
+ <h3>Workspace settings</h3>
+ </div>
+ </div>
+
+ <label className="field">
+ Site name
+ <input
+ value={brandingForm.siteName}
+ onChange={(event) => setBrandingForm((current) => ({ ...current, siteName: event.target.value }))}
+ />
+ </label>
+ <div className="field-grid">
+ <label className="field">
+ Brand color
+ <input
+ value={brandingForm.brandColor}
+ onChange={(event) => setBrandingForm((current) => ({ ...current, brandColor: event.target.value }))}
+ />
+ </label>
+ <label className="field">
+ Logo URL
+ <input
+ value={brandingForm.logoUrl}
+ onChange={(event) => setBrandingForm((current) => ({ ...current, logoUrl: event.target.value }))}
+ />
+ </label>
+ </div>
+ <label className="field">
+ Footer text
+ <input
+ value={brandingForm.footerText}
+ onChange={(event) => setBrandingForm((current) => ({ ...current, footerText: event.target.value }))}
+ />
+ </label>
+ <label className="checkbox-row checkbox-card">
+ <input
+ type="checkbox"
+ checked={brandingForm.publicKnowledgeBaseEnabled}
+ onChange={(event) =>
+ setBrandingForm((current) => ({
+ ...current,
+ publicKnowledgeBaseEnabled: event.target.checked
+ }))
+ }
+ />
+ <span>Public knowledge base enabled</span>
+ </label>
+ <div className="panel__footer">
+ <button onClick={saveBranding}>Save settings</button>
+ {settingsStatus ? <p className="muted">{settingsStatus}</p> : null}
+ </div>
+ </section>
+ ) : null}
+
+ {analytics ? (
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Analytics</p>
+ <h3>Search performance</h3>
+ </div>
+ </div>
+ <div className="analytics-grid">
+ <div>
+ <strong>Top searches</strong>
+ {analytics.topSearches.map((item) => (
+ <p key={item.query}>{item.query} <span className="muted">({item.count})</span></p>
+ ))}
+ </div>
+ <div>
+ <strong>No results</strong>
+ {analytics.noResults.map((item) => (
+ <p key={item.query}>{item.query} <span className="muted">({item.count})</span></p>
+ ))}
+ </div>
+ </div>
+ </section>
+ ) : null}
+
+ {feedback ? (
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Feedback queue</p>
+ <h3>Reader responses</h3>
+ </div>
+ </div>
+ <div className="feedback-list">
+ {feedback.map((item) => (
+ <div key={item.id} className="feedback-item">
+ <div className="feedback-item__header">
+ <strong>{item.page_title}</strong>
+ <span className={`badge ${item.helpful ? "badge-public" : "badge-restricted"}`}>
+ {item.helpful ? "Helpful" : "Not helpful"}
+ </span>
+ </div>
+ <p>{item.comment ?? "No comment provided."}</p>
+ </div>
+ ))}
+ </div>
+ </section>
+ ) : null}
+ </div>
+ </div>
+ );
+}
+
+export function App() {
+ const { user, setUser, loading } = useSession();
+ const [branding, setBranding] = useState<BrandingResponse["branding"] | null>(null);
+ const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
+ const [searchResults, setSearchResults] = useState<PageSummary[]>([]);
+ const [searchLoading, setSearchLoading] = useState(false);
+
+ useEffect(() => {
+ api.get<BrandingResponse>("/api/settings/public").then((response) => setBranding(response.branding));
+ api.get<SetupStatus>("/api/setup/status").then(setSetupStatus);
+ }, []);
+
+ const { spaces, pagesBySpace, loading: loadingNavigation, error: navigationError } = useKnowledgeBaseData(
+ Boolean(setupStatus?.isInitialized)
+ );
+
+ async function logout() {
+ await api.post("/api/auth/logout");
+ setUser(null);
+ }
+
+ async function handleSearch(query: string) {
+ if (!query.trim()) {
+ setSearchResults([]);
+ return;
+ }
+
+ setSearchLoading(true);
+ try {
+ const response = await api.get<{ pages: PageSummary[] }>(`/api/search?q=${encodeURIComponent(query)}`);
+ setSearchResults(response.pages);
+ } catch {
+ setSearchResults([]);
+ } finally {
+ setSearchLoading(false);
+ }
+ }
+
+ if (loading || !setupStatus) {
+ return <div className="loading-screen">Loading Ledger...</div>;
+ }
+
+ if (!setupStatus.isInitialized) {
+ return (
+ <div className="app-standalone">
+ <SetupPage
+ initialBranding={setupStatus.branding}
+ onInitialized={(initializedUser, initializedBranding) => {
+ setUser(initializedUser);
+ setBranding(initializedBranding);
+ setSetupStatus({
+ isInitialized: true,
+ branding: {
+ site_name: initializedBranding.siteName,
+ brand_color: initializedBranding.brandColor
+ }
+ });
+ }}
+ />
+ </div>
+ );
+ }
+
+ return (
+ <DocsShell
+ branding={branding}
+ user={user}
+ spaces={spaces}
+ pagesBySpace={pagesBySpace}
+ loadingNavigation={loadingNavigation}
+ navigationError={navigationError}
+ searchResults={searchResults}
+ searchLoading={searchLoading}
+ onSearch={handleSearch}
+ onLogout={logout}
+ >
+ <Routes>
+ <Route path="/" element={<HomePage spaces={spaces} pagesBySpace={pagesBySpace} onSearch={handleSearch} />} />
+ <Route path="/login" element={<LoginPage onLogin={setUser} />} />
+ <Route path="/space/:spaceKey" element={<SpacePage spaces={spaces} pagesBySpace={pagesBySpace} />} />
+ <Route path="/page/:slug" element={<PageView spaces={spaces} pagesBySpace={pagesBySpace} user={user} />} />
+ <Route path="/dashboard" element={user ? <Dashboard user={user} spaces={spaces} /> : <LoginPage onLogin={setUser} />} />
+ </Routes>
+ </DocsShell>
+ );
+}
diff --git a/apps/web/src/components/CommandSearch.tsx b/apps/web/src/components/CommandSearch.tsx
new file mode 100644
index 0000000..f33003d
--- /dev/null
+++ b/apps/web/src/components/CommandSearch.tsx
@@ -0,0 +1,120 @@
+import { useEffect, useRef, useState } from "react";
+import { Link } from "react-router-dom";
+import type { PageSummary } from "@ledger/shared";
+import { Icon } from "./Icon";
+
+type Space = {
+ id: string;
+ name: string;
+ key: string;
+ visibility: string;
+};
+
+export function CommandSearch({
+ open,
+ onClose,
+ onSearch,
+ spaces,
+ results,
+ isLoading
+}: {
+ open: boolean;
+ onClose: () => void;
+ onSearch: (query: string) => void;
+ spaces: Space[];
+ results: PageSummary[];
+ isLoading: boolean;
+}) {
+ const [query, setQuery] = useState("");
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ useEffect(() => {
+ if (!open) {
+ setQuery("");
+ return;
+ }
+ inputRef.current?.focus();
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ const timeout = window.setTimeout(() => {
+ onSearch(query);
+ }, 180);
+
+ return () => window.clearTimeout(timeout);
+ }, [open, onSearch, query]);
+
+ useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ onClose();
+ }
+ }
+
+ if (open) {
+ window.addEventListener("keydown", handleKeyDown);
+ }
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [onClose, open]);
+
+ const grouped = results.reduce<Record<string, PageSummary[]>>((acc, result) => {
+ acc[result.spaceId] = acc[result.spaceId] ?? [];
+ acc[result.spaceId].push(result);
+ return acc;
+ }, {});
+
+ const spaceNameFor = (spaceId: string) => spaces.find((space) => space.id === spaceId)?.name ?? "Collection";
+
+ return (
+ <>
+ <div className={`command-palette__overlay${open ? " is-open" : ""}`} onClick={onClose} />
+ <section className={`command-palette${open ? " is-open" : ""}`} aria-hidden={!open}>
+ <div className="command-palette__input">
+ <Icon name="search" className="icon" />
+ <input
+ ref={inputRef}
+ value={query}
+ onChange={(event) => setQuery(event.target.value)}
+ placeholder="Search docs, pages, and answers"
+ aria-label="Search"
+ />
+ <button type="button" className="button-ghost" onClick={onClose}>
+ Esc
+ </button>
+ </div>
+
+ <div className="command-palette__results">
+ {isLoading ? <p className="muted">Searching...</p> : null}
+ {!isLoading && query.trim() && results.length === 0 ? (
+ <p className="muted">No results found.</p>
+ ) : null}
+ {!isLoading && !query.trim() ? (
+ <p className="muted">Search by title or article content. Try a page name, keyword, or process.</p>
+ ) : null}
+
+ {Object.entries(grouped).map(([spaceId, items]) => (
+ <div key={spaceId} className="search-group">
+ <div className="search-group__label">{spaceNameFor(spaceId)}</div>
+ {items.map((result) => (
+ <Link key={result.id} to={`/page/${result.slug}`} className="search-result" onClick={onClose}>
+ <div className="search-result__icon">
+ <Icon name="document" className="icon icon-sm" />
+ </div>
+ <div className="search-result__body">
+ <strong>{result.title}</strong>
+ <p>{result.excerpt ?? "Open this article to read more."}</p>
+ <span>{spaceNameFor(spaceId)} / {result.slug}</span>
+ </div>
+ </Link>
+ ))}
+ </div>
+ ))}
+ </div>
+ </section>
+ </>
+ );
+}
diff --git a/apps/web/src/components/DocsSidebar.tsx b/apps/web/src/components/DocsSidebar.tsx
new file mode 100644
index 0000000..9454587
--- /dev/null
+++ b/apps/web/src/components/DocsSidebar.tsx
@@ -0,0 +1,227 @@
+import { useMemo, useState } from "react";
+import { Link, useLocation } from "react-router-dom";
+import type { PageSummary } from "@ledger/shared";
+import { Icon } from "./Icon";
+
+type Space = {
+ id: string;
+ name: string;
+ key: string;
+ visibility: string;
+};
+
+type PageNode = PageSummary & { children: PageNode[] };
+
+function closeSidebarOnMobile(onClose: () => void) {
+ if (window.innerWidth <= 920) {
+ onClose();
+ }
+}
+
+function buildTree(pages: PageSummary[]) {
+ const byId = new Map<string, PageNode>();
+ const roots: PageNode[] = [];
+
+ for (const page of pages) {
+ byId.set(page.id, { ...page, children: [] });
+ }
+
+ for (const node of byId.values()) {
+ if (node.parentPageId && byId.has(node.parentPageId)) {
+ byId.get(node.parentPageId)!.children.push(node);
+ } else {
+ roots.push(node);
+ }
+ }
+
+ const sortNodes = (nodes: PageNode[]) => {
+ nodes.sort((a, b) => a.title.localeCompare(b.title));
+ nodes.forEach((node) => sortNodes(node.children));
+ };
+
+ sortNodes(roots);
+ return roots;
+}
+
+function TreeNode({
+ node,
+ depth,
+ currentSlug,
+ onClose
+}: {
+ node: PageNode;
+ depth: number;
+ currentSlug: string | undefined;
+ onClose: () => void;
+}) {
+ const [isOpen, setIsOpen] = useState(true);
+ const isActive = node.slug === currentSlug;
+ const hasChildren = node.children.length > 0;
+
+ return (
+ <div className="tree-node">
+ <div className={`tree-item${isActive ? " is-active" : ""}`} style={{ paddingLeft: `${depth * 0.85 + 0.75}rem` }}>
+ {hasChildren ? (
+ <button
+ type="button"
+ className="tree-toggle"
+ onClick={() => setIsOpen((value) => !value)}
+ aria-label={isOpen ? "Collapse section" : "Expand section"}
+ >
+ <Icon name={isOpen ? "chevronDown" : "chevronRight"} className="icon icon-sm" />
+ </button>
+ ) : (
+ <span className="tree-spacer" />
+ )}
+ <Link to={`/page/${node.slug}`} className="tree-link" onClick={() => closeSidebarOnMobile(onClose)}>
+ <span className="tree-link__title">{node.title}</span>
+ <span className={`badge badge-${node.visibility}`}>{node.visibility}</span>
+ </Link>
+ </div>
+ {hasChildren && isOpen ? (
+ <div className="tree-children">
+ {node.children.map((child) => (
+ <TreeNode key={child.id} node={child} depth={depth + 1} currentSlug={currentSlug} onClose={onClose} />
+ ))}
+ </div>
+ ) : null}
+ </div>
+ );
+}
+
+export function DocsSidebar({
+ spaces,
+ pagesBySpace,
+ currentSpaceKey,
+ currentSlug,
+ user,
+ isOpen,
+ onClose
+}: {
+ spaces: Space[];
+ pagesBySpace: Record<string, PageSummary[]>;
+ currentSpaceKey?: string;
+ currentSlug?: string;
+ user: { displayName: string; role: string } | null;
+ isOpen: boolean;
+ onClose: () => void;
+}) {
+ const location = useLocation();
+ const [collapsedSpaces, setCollapsedSpaces] = useState<Record<string, boolean>>({});
+
+ const recentPages = useMemo(
+ () =>
+ Object.values(pagesBySpace)
+ .flat()
+ .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
+ .slice(0, 5),
+ [pagesBySpace]
+ );
+
+ return (
+ <>
+ <div className={`sidebar-overlay${isOpen ? " is-open" : ""}`} onClick={onClose} />
+ <aside className={`sidebar${isOpen ? " is-open" : ""}`}>
+ <div className="sidebar__top">
+ <div>
+ <p className="eyebrow">Workspace</p>
+ <h2 className="sidebar__workspace">Ledger</h2>
+ <p className="muted">{user ? `${user.displayName} - ${user.role}` : "Public knowledge base"}</p>
+ </div>
+ <button type="button" className="mobile-only button-ghost" onClick={onClose} aria-label="Close navigation">
+ <Icon name="chevronRight" className="icon" />
+ </button>
+ </div>
+
+ <nav className="sidebar-nav" aria-label="Primary">
+ <Link
+ className={`sidebar-nav__item${location.pathname === "/" ? " is-current" : ""}`}
+ to="/"
+ onClick={() => closeSidebarOnMobile(onClose)}
+ >
+ <Icon name="home" className="icon icon-sm" />
+ <span>Overview</span>
+ </Link>
+ <Link
+ className={`sidebar-nav__item${location.pathname === "/dashboard" ? " is-current" : ""}`}
+ to="/dashboard"
+ onClick={() => closeSidebarOnMobile(onClose)}
+ >
+ <Icon name="settings" className="icon icon-sm" />
+ <span>Manage</span>
+ </Link>
+ </nav>
+
+ <section className="sidebar-section">
+ <div className="sidebar-section__header">
+ <span>Recent</span>
+ </div>
+ <div className="sidebar-list">
+ {recentPages.length === 0 ? <p className="muted">No documents yet.</p> : null}
+ {recentPages.map((page) => (
+ <Link
+ key={page.id}
+ to={`/page/${page.slug}`}
+ className={`sidebar-doc${page.slug === currentSlug ? " is-current" : ""}`}
+ onClick={() => closeSidebarOnMobile(onClose)}
+ >
+ <Icon name="document" className="icon icon-sm" />
+ <span>{page.title}</span>
+ </Link>
+ ))}
+ </div>
+ </section>
+
+ <section className="sidebar-section">
+ <div className="sidebar-section__header">
+ <span>Collections</span>
+ </div>
+ <div className="sidebar-collections">
+ {spaces.map((space) => {
+ const isCollapsed = collapsedSpaces[space.key] ?? false;
+ const tree = buildTree(pagesBySpace[space.key] ?? []);
+
+ return (
+ <div key={space.id} className="collection-group">
+ <button
+ type="button"
+ className={`collection-group__header${space.key === currentSpaceKey ? " is-current" : ""}`}
+ onClick={() =>
+ setCollapsedSpaces((current) => ({
+ ...current,
+ [space.key]: !isCollapsed
+ }))
+ }
+ >
+ <span className="collection-group__title">
+ <Icon name={isCollapsed ? "chevronRight" : "chevronDown"} className="icon icon-sm" />
+ <Icon name="collection" className="icon icon-sm" />
+ {space.name}
+ </span>
+ <span className={`badge badge-${space.visibility}`}>{space.visibility}</span>
+ </button>
+
+ {!isCollapsed ? (
+ <div className="collection-group__body">
+ <Link
+ to={`/space/${space.key}`}
+ className={`collection-link${space.key === currentSpaceKey && !currentSlug ? " is-current" : ""}`}
+ onClick={() => closeSidebarOnMobile(onClose)}
+ >
+ Browse collection
+ </Link>
+ {tree.length === 0 ? <p className="muted">No published pages yet.</p> : null}
+ {tree.map((node) => (
+ <TreeNode key={node.id} node={node} depth={0} currentSlug={currentSlug} onClose={onClose} />
+ ))}
+ </div>
+ ) : null}
+ </div>
+ );
+ })}
+ </div>
+ </section>
+ </aside>
+ </>
+ );
+}
diff --git a/apps/web/src/components/EmptyState.tsx b/apps/web/src/components/EmptyState.tsx
new file mode 100644
index 0000000..f57efe1
--- /dev/null
+++ b/apps/web/src/components/EmptyState.tsx
@@ -0,0 +1,22 @@
+import type { ReactNode } from "react";
+
+export function EmptyState({
+ eyebrow,
+ title,
+ description,
+ action
+}: {
+ eyebrow?: string;
+ title: string;
+ description: string;
+ action?: ReactNode;
+}) {
+ return (
+ <section className="empty-state">
+ {eyebrow ? <p className="eyebrow">{eyebrow}</p> : null}
+ <h2>{title}</h2>
+ <p className="muted">{description}</p>
+ {action ? <div className="empty-state__action">{action}</div> : null}
+ </section>
+ );
+}
diff --git a/apps/web/src/components/FeedbackForm.tsx b/apps/web/src/components/FeedbackForm.tsx
index 10a1004..6f4bf38 100644
--- a/apps/web/src/components/FeedbackForm.tsx
+++ b/apps/web/src/components/FeedbackForm.tsx
@@ -21,14 +21,19 @@ export function FeedbackForm({ pageId, revisionId }: { pageId: string; revisionI
}
return (
- <section className="card">
- <h3>Was this page helpful?</h3>
+ <section className="feedback-card">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Feedback</p>
+ <h3>Was this article helpful?</h3>
+ </div>
+ </div>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
- placeholder="Optional comment"
+ placeholder="Optional context for your response"
/>
- <div className="row">
+ <div className="feedback-actions">
<button onClick={() => submit(true)}>Helpful</button>
<button className="button-secondary" onClick={() => submit(false)}>
Not helpful
@@ -38,4 +43,3 @@ export function FeedbackForm({ pageId, revisionId }: { pageId: string; revisionI
</section>
);
}
-
diff --git a/apps/web/src/components/Icon.tsx b/apps/web/src/components/Icon.tsx
new file mode 100644
index 0000000..e9f7b8e
--- /dev/null
+++ b/apps/web/src/components/Icon.tsx
@@ -0,0 +1,41 @@
+import type { CSSProperties } from "react";
+
+const paths = {
+ search: "M11 4a7 7 0 1 0 4.42 12.43l3.58 3.57 1.4-1.4-3.57-3.58A7 7 0 0 0 11 4Zm0 2a5 5 0 1 1 0 10 5 5 0 0 1 0-10Z",
+ menu: "M4 6h16v2H4V6Zm0 5h16v2H4v-2Zm0 5h16v2H4v-2Z",
+ chevronRight: "m10 7 5 5-5 5-1.4-1.4 3.6-3.6-3.6-3.6L10 7Z",
+ chevronDown: "m7 10 5 5 5-5-1.4-1.4-3.6 3.6-3.6-3.6L7 10Z",
+ home: "M5 11.5 12 6l7 5.5V20h-5v-5H10v5H5v-8.5Z",
+ settings: "m12 8.5 3.5-2 1 1.7-2 3.5 2 3.5-1 1.8-3.5-2-3.5 2-1-1.8 2-3.5-2-3.5 1-1.7 3.5 2 3.5-2Z M12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z",
+ document: "M7 4h7l4 4v12H7V4Zm7 1.5V9h3.5L14 5.5Z",
+ collection: "M4 6h7v5H4V6Zm9 0h7v5h-7V6ZM4 13h7v5H4v-5Zm9 0h7v5h-7v-5Z",
+ lock: "M8 10V8a4 4 0 0 1 8 0v2h1a1 1 0 0 1 1 1v7H6v-7a1 1 0 0 1 1-1h1Zm2 0h4V8a2 2 0 1 0-4 0v2Z",
+ globe: "M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm5.74 7h-3.1a13 13 0 0 0-1.12-4A6.02 6.02 0 0 1 17.74 11Zm-5.74 7c-.77 0-1.83-1.53-2.2-5h4.4c-.37 3.47-1.43 5-2.2 5Zm-2.2-7c.37-3.47 1.43-5 2.2-5s1.83 1.53 2.2 5H9.8Zm-3.32 0h-3.1a6.02 6.02 0 0 1 4.22-4 13 13 0 0 0-1.12 4Zm-3.1 2h3.1a13 13 0 0 0 1.12 4 6.02 6.02 0 0 1-4.22-4Zm10.14 4a13 13 0 0 0 1.12-4h3.1a6.02 6.02 0 0 1-4.22 4Z",
+ plus: "M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5Z",
+ copy: "M9 9h10v11H9V9Zm-4-5h10v3H7v9H5V4Z",
+ external: "M14 5h5v5h-2V8.4l-6.3 6.3-1.4-1.4L15.6 7H14V5Z M6 7h5v2H8v7h7v-3h2v5H6V7Z",
+ spark: "m12 4 1.5 4.5L18 10l-4.5 1.5L12 16l-1.5-4.5L6 10l4.5-1.5L12 4Z"
+} as const;
+
+export function Icon({
+ name,
+ className,
+ style
+}: {
+ name: keyof typeof paths;
+ className?: string;
+ style?: CSSProperties;
+}) {
+ return (
+ <svg
+ aria-hidden="true"
+ viewBox="0 0 24 24"
+ className={className}
+ style={style}
+ fill="currentColor"
+ >
+ <path d={paths[name]} />
+ </svg>
+ );
+}
+
diff --git a/apps/web/src/components/PageEditor.tsx b/apps/web/src/components/PageEditor.tsx
index f458694..efeb408 100644
--- a/apps/web/src/components/PageEditor.tsx
+++ b/apps/web/src/components/PageEditor.tsx
@@ -37,12 +37,15 @@ export function PageEditor({ spaces }: { spaces: Array<{ id: string; name: strin
}
return (
- <section className="card">
- <div className="row">
- <h3>Create page</h3>
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Publishing</p>
+ <h3>Create a new page</h3>
+ </div>
<span className="pill">Editor</span>
</div>
- <label>
+ <label className="field">
Space
<select
value={form.spaceId}
@@ -55,29 +58,29 @@ export function PageEditor({ spaces }: { spaces: Array<{ id: string; name: strin
))}
</select>
</label>
- <label>
+ <label className="field">
Title
<input
value={form.title}
onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))}
/>
</label>
- <label>
+ <label className="field">
Slug
<input
value={form.slug}
onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
/>
</label>
- <label>
+ <label className="field">
Excerpt
<input
value={form.excerpt}
onChange={(event) => setForm((current) => ({ ...current, excerpt: event.target.value }))}
/>
</label>
- <div className="split">
- <label>
+ <div className="field-grid">
+ <label className="field">
Visibility
<select
value={form.visibility}
@@ -88,7 +91,7 @@ export function PageEditor({ spaces }: { spaces: Array<{ id: string; name: strin
<option value="restricted">Restricted</option>
</select>
</label>
- <label>
+ <label className="field">
State
<select
value={form.state}
@@ -99,7 +102,7 @@ export function PageEditor({ spaces }: { spaces: Array<{ id: string; name: strin
</select>
</label>
</div>
- <label>
+ <label className="field">
Tags
<input
placeholder="comma,separated,tags"
@@ -114,7 +117,7 @@ export function PageEditor({ spaces }: { spaces: Array<{ id: string; name: strin
}
/>
</label>
- <label>
+ <label className="field">
Markdown
<textarea
className="editor-textarea"
@@ -122,8 +125,10 @@ export function PageEditor({ spaces }: { spaces: Array<{ id: string; name: strin
onChange={(event) => setForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
/>
</label>
- <button onClick={submit}>Save page</button>
- {status ? <p className="muted">{status}</p> : null}
+ <div className="panel__footer">
+ <button onClick={submit}>Publish draft</button>
+ {status ? <p className="muted">{status}</p> : null}
+ </div>
</section>
);
}
diff --git a/apps/web/src/components/PageSidebar.tsx b/apps/web/src/components/PageSidebar.tsx
index 2ce269c..4e1a2d9 100644
--- a/apps/web/src/components/PageSidebar.tsx
+++ b/apps/web/src/components/PageSidebar.tsx
@@ -1,21 +1,210 @@
-import { Link } from "react-router-dom";
+import { useMemo, useState } from "react";
+import { Link, useLocation } from "react-router-dom";
import type { PageSummary } from "@ledger/shared";
+import { Icon } from "./Icon";
+
+type Space = {
+ id: string;
+ name: string;
+ key: string;
+ visibility: string;
+};
+
+type PageNode = PageSummary & { children: PageNode[] };
+
+function closeSidebarOnMobile(onClose: () => void) {
+ if (window.innerWidth <= 920) {
+ onClose();
+ }
+}
+
+function buildTree(pages: PageSummary[]) {
+ const byId = new Map<string, PageNode>();
+ const roots: PageNode[] = [];
+
+ for (const page of pages) {
+ byId.set(page.id, { ...page, children: [] });
+ }
+
+ for (const node of byId.values()) {
+ if (node.parentPageId && byId.has(node.parentPageId)) {
+ byId.get(node.parentPageId)!.children.push(node);
+ } else {
+ roots.push(node);
+ }
+ }
+
+ const sortNodes = (nodes: PageNode[]) => {
+ nodes.sort((a, b) => a.title.localeCompare(b.title));
+ nodes.forEach((node) => sortNodes(node.children));
+ };
+
+ sortNodes(roots);
+ return roots;
+}
+
+function TreeNode({
+ node,
+ depth,
+ currentSlug,
+ onClose
+}: {
+ node: PageNode;
+ depth: number;
+ currentSlug: string | undefined;
+ onClose: () => void;
+}) {
+ const [isOpen, setIsOpen] = useState(true);
+ const isActive = node.slug === currentSlug;
+ const hasChildren = node.children.length > 0;
-export function PageSidebar({ pages, title }: { pages: PageSummary[]; title: string }) {
return (
- <aside className="sidebar-card">
- <div className="sidebar-card__header">
- <h3>{title}</h3>
+ <div className="tree-node">
+ <div className={`tree-item${isActive ? " is-active" : ""}`} style={{ paddingLeft: `${depth * 0.85 + 0.75}rem` }}>
+ {hasChildren ? (
+ <button
+ type="button"
+ className="tree-toggle"
+ onClick={() => setIsOpen((value) => !value)}
+ aria-label={isOpen ? "Collapse section" : "Expand section"}
+ >
+ <Icon name={isOpen ? "chevronDown" : "chevronRight"} className="icon icon-sm" />
+ </button>
+ ) : (
+ <span className="tree-spacer" />
+ )}
+ <Link to={`/page/${node.slug}`} className="tree-link" onClick={() => closeSidebarOnMobile(onClose)}>
+ <span className="tree-link__title">{node.title}</span>
+ <span className={`badge badge-${node.visibility}`}>{node.visibility}</span>
+ </Link>
</div>
- <nav className="page-tree">
- {pages.map((page) => (
- <Link key={page.id} to={`/page/${page.slug}`} className="page-tree__item">
- <span>{page.title}</span>
- <small>{page.visibility}</small>
- </Link>
- ))}
- </nav>
- </aside>
+ {hasChildren && isOpen ? (
+ <div className="tree-children">
+ {node.children.map((child) => (
+ <TreeNode key={child.id} node={child} depth={depth + 1} currentSlug={currentSlug} onClose={onClose} />
+ ))}
+ </div>
+ ) : null}
+ </div>
);
}
+export function PageSidebar({
+ spaces,
+ pagesBySpace,
+ currentSpaceKey,
+ currentSlug,
+ user,
+ isOpen,
+ onClose
+}: {
+ spaces: Space[];
+ pagesBySpace: Record<string, PageSummary[]>;
+ currentSpaceKey?: string;
+ currentSlug?: string;
+ user: { displayName: string; role: string } | null;
+ isOpen: boolean;
+ onClose: () => void;
+}) {
+ const location = useLocation();
+ const [collapsedSpaces, setCollapsedSpaces] = useState<Record<string, boolean>>({});
+
+ const recentPages = useMemo(
+ () =>
+ Object.values(pagesBySpace)
+ .flat()
+ .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
+ .slice(0, 5),
+ [pagesBySpace]
+ );
+
+ return (
+ <>
+ <div className={`sidebar-overlay${isOpen ? " is-open" : ""}`} onClick={onClose} />
+ <aside className={`sidebar${isOpen ? " is-open" : ""}`}>
+ <div className="sidebar__top">
+ <div>
+ <p className="eyebrow">Workspace</p>
+ <h2 className="sidebar__workspace">Ledger</h2>
+ <p className="muted">{user ? `${user.displayName} ยท ${user.role}` : "Public knowledge base"}</p>
+ </div>
+ <button type="button" className="mobile-only button-ghost" onClick={onClose}>
+ <Icon name="chevronRight" className="icon" />
+ </button>
+ </div>
+
+ <nav className="sidebar-nav" aria-label="Primary">
+ <Link className={`sidebar-nav__item${location.pathname === "/" ? " is-current" : ""}`} to="/">
+ <Icon name="home" className="icon icon-sm" />
+ <span>Overview</span>
+ </Link>
+ <Link className={`sidebar-nav__item${location.pathname === "/dashboard" ? " is-current" : ""}`} to="/dashboard">
+ <Icon name="settings" className="icon icon-sm" />
+ <span>Manage</span>
+ </Link>
+ </nav>
+
+ <section className="sidebar-section">
+ <div className="sidebar-section__header">
+ <span>Recent</span>
+ </div>
+ <div className="sidebar-list">
+ {recentPages.length === 0 ? <p className="muted">No documents yet.</p> : null}
+ {recentPages.map((page) => (
+ <Link key={page.id} to={`/page/${page.slug}`} className={`sidebar-doc${page.slug === currentSlug ? " is-current" : ""}`}>
+ <Icon name="document" className="icon icon-sm" />
+ <span>{page.title}</span>
+ </Link>
+ ))}
+ </div>
+ </section>
+
+ <section className="sidebar-section">
+ <div className="sidebar-section__header">
+ <span>Collections</span>
+ </div>
+ <div className="sidebar-collections">
+ {spaces.map((space) => {
+ const isCollapsed = collapsedSpaces[space.key] ?? false;
+ const tree = buildTree(pagesBySpace[space.key] ?? []);
+
+ return (
+ <div key={space.id} className="collection-group">
+ <button
+ type="button"
+ className={`collection-group__header${space.key === currentSpaceKey ? " is-current" : ""}`}
+ onClick={() =>
+ setCollapsedSpaces((current) => ({
+ ...current,
+ [space.key]: !isCollapsed
+ }))
+ }
+ >
+ <span className="collection-group__title">
+ <Icon name={isCollapsed ? "chevronRight" : "chevronDown"} className="icon icon-sm" />
+ <Icon name="collection" className="icon icon-sm" />
+ {space.name}
+ </span>
+ <span className={`badge badge-${space.visibility}`}>{space.visibility}</span>
+ </button>
+
+ {!isCollapsed ? (
+ <div className="collection-group__body">
+ <Link to={`/space/${space.key}`} className={`collection-link${space.key === currentSpaceKey && !currentSlug ? " is-current" : ""}`}>
+ Browse collection
+ </Link>
+ {tree.length === 0 ? <p className="muted">No published pages yet.</p> : null}
+ {tree.map((node) => (
+ <TreeNode key={node.id} node={node} depth={0} currentSlug={currentSlug} />
+ ))}
+ </div>
+ ) : null}
+ </div>
+ );
+ })}
+ </div>
+ </section>
+ </aside>
+ </>
+ );
+}
diff --git a/apps/web/src/components/SearchBar.tsx b/apps/web/src/components/SearchBar.tsx
index b3dfdc6..8132771 100644
--- a/apps/web/src/components/SearchBar.tsx
+++ b/apps/web/src/components/SearchBar.tsx
@@ -1,11 +1,16 @@
import { FormEvent, useState } from "react";
+import { Icon } from "./Icon";
export function SearchBar({
initialQuery = "",
- onSearch
+ onSearch,
+ placeholder = "Search documentation",
+ compact = false
}: {
initialQuery?: string;
onSearch: (query: string) => void;
+ placeholder?: string;
+ compact?: boolean;
}) {
const [query, setQuery] = useState(initialQuery);
@@ -15,14 +20,15 @@ export function SearchBar({
}
return (
- <form className="search-bar" onSubmit={handleSubmit}>
+ <form className={`search-bar${compact ? " search-bar-compact" : ""}`} onSubmit={handleSubmit}>
+ <Icon name="search" className="icon" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
- placeholder="Search trusted answers"
+ placeholder={placeholder}
+ aria-label={placeholder}
/>
- <button type="submit">Search</button>
+ <button type="submit">{compact ? "Go" : "Search"}</button>
</form>
);
}
-
diff --git a/apps/web/src/components/Skeleton.tsx b/apps/web/src/components/Skeleton.tsx
new file mode 100644
index 0000000..851da7a
--- /dev/null
+++ b/apps/web/src/components/Skeleton.tsx
@@ -0,0 +1,41 @@
+export function SidebarSkeleton() {
+ return (
+ <aside className="sidebar">
+ <div className="skeleton skeleton-title" />
+ <div className="skeleton-group">
+ <div className="skeleton skeleton-line" />
+ <div className="skeleton skeleton-line short" />
+ <div className="skeleton skeleton-line" />
+ </div>
+ <div className="skeleton-group">
+ <div className="skeleton skeleton-line" />
+ <div className="skeleton skeleton-line" />
+ <div className="skeleton skeleton-line short" />
+ </div>
+ </aside>
+ );
+}
+
+export function ContentSkeleton() {
+ return (
+ <section className="doc-card">
+ <div className="doc-card__header">
+ <div className="skeleton skeleton-chip" />
+ <div className="skeleton skeleton-chip short" />
+ </div>
+ <div className="skeleton skeleton-heading" />
+ <div className="skeleton skeleton-meta" />
+ <div className="skeleton-block">
+ <div className="skeleton skeleton-paragraph" />
+ <div className="skeleton skeleton-paragraph" />
+ <div className="skeleton skeleton-paragraph short" />
+ </div>
+ <div className="skeleton-block">
+ <div className="skeleton skeleton-subheading" />
+ <div className="skeleton skeleton-paragraph" />
+ <div className="skeleton skeleton-paragraph short" />
+ </div>
+ </section>
+ );
+}
+
diff --git a/apps/web/src/docs-theme.css b/apps/web/src/docs-theme.css
new file mode 100644
index 0000000..2fe5623
--- /dev/null
+++ b/apps/web/src/docs-theme.css
@@ -0,0 +1,948 @@
+:root {
+ --bg: #f4f6f2;
+ --bg-elevated: rgba(255, 255, 255, 0.82);
+ --bg-sidebar: #eef2ea;
+ --bg-panel: #ffffff;
+ --bg-soft: #f6f8f4;
+ --text: #172118;
+ --text-muted: #607065;
+ --text-soft: #7b897f;
+ --line: #dce5db;
+ --line-strong: #cad7cb;
+ --brand: #2d6a4f;
+ --brand-soft: #e3f1e9;
+ --shadow: 0 18px 45px rgba(23, 33, 24, 0.08);
+ --radius-lg: 24px;
+ --radius-md: 16px;
+ --radius-sm: 12px;
+ --sidebar-width: 320px;
+ color: var(--text);
+ background:
+ radial-gradient(circle at top left, rgba(45, 106, 79, 0.11), transparent 28%),
+ linear-gradient(180deg, #f7f9f5 0%, #eef2ea 100%);
+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ min-height: 100%;
+}
+
+body {
+ margin: 0;
+ color: var(--text);
+ background: var(--bg);
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+button {
+ border: 0;
+ cursor: pointer;
+}
+
+input,
+select,
+textarea {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: #fff;
+ color: var(--text);
+ padding: 0.85rem 0.95rem;
+}
+
+textarea {
+ min-height: 120px;
+ resize: vertical;
+}
+
+input:focus,
+select:focus,
+textarea:focus,
+button:focus,
+a:focus {
+ outline: 2px solid rgba(45, 106, 79, 0.22);
+ outline-offset: 2px;
+}
+
+.icon {
+ width: 1.05rem;
+ height: 1.05rem;
+ flex: none;
+}
+
+.icon-sm {
+ width: 0.9rem;
+ height: 0.9rem;
+}
+
+.eyebrow {
+ margin: 0 0 0.55rem;
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--brand);
+}
+
+.muted {
+ color: var(--text-muted);
+}
+
+.lede {
+ font-size: 1.05rem;
+ line-height: 1.75;
+ color: #344238;
+}
+
+.button-ghost,
+.button-secondary {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ justify-content: center;
+ border-radius: 999px;
+ padding: 0.7rem 1rem;
+ background: transparent;
+ color: var(--text);
+}
+
+.button-secondary {
+ border: 1px solid var(--line);
+ background: #fff;
+}
+
+button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-link):not(.collection-card):not(.search-launcher) {
+ border-radius: 999px;
+ padding: 0.82rem 1.15rem;
+ background: var(--brand);
+ color: white;
+ font-weight: 600;
+}
+
+.kb-app {
+ min-height: 100vh;
+ display: grid;
+ grid-template-columns: var(--sidebar-width) 1fr;
+}
+
+.kb-main {
+ min-width: 0;
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+}
+
+.sidebar {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ padding: 1.15rem 1rem 1rem;
+ background: rgba(238, 242, 234, 0.92);
+ border-right: 1px solid rgba(202, 215, 203, 0.85);
+ overflow-y: auto;
+ backdrop-filter: blur(16px);
+}
+
+.sidebar__top,
+.panel__header,
+.doc-card__header,
+.app-header,
+.feedback-item__header,
+.doc-actions,
+.app-header__left,
+.app-header__right,
+.app-header__center,
+.manage-hero__meta,
+.feedback-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+}
+
+.sidebar__top,
+.panel__header,
+.doc-card__header,
+.app-header,
+.feedback-item__header {
+ justify-content: space-between;
+}
+
+.sidebar__workspace {
+ margin: 0;
+ font-size: 1.25rem;
+}
+
+.sidebar-nav,
+.sidebar-list,
+.sidebar-collections,
+.collection-group__body,
+.article-list,
+.feedback-list,
+.context-list,
+.toc-rail,
+.setup-points {
+ display: grid;
+ gap: 0.45rem;
+}
+
+.sidebar-nav {
+ margin: 1rem 0 1.25rem;
+}
+
+.sidebar-nav__item,
+.sidebar-doc,
+.collection-link {
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+ padding: 0.72rem 0.85rem;
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+}
+
+.sidebar-nav__item.is-current,
+.sidebar-doc.is-current,
+.collection-link.is-current {
+ background: rgba(255, 255, 255, 0.72);
+ color: var(--text);
+}
+
+.sidebar-section {
+ margin-top: 1rem;
+}
+
+.sidebar-section__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+ font-size: 0.82rem;
+ font-weight: 700;
+ color: var(--text-soft);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.collection-group {
+ border: 1px solid rgba(202, 215, 203, 0.8);
+ border-radius: var(--radius-md);
+ background: rgba(255, 255, 255, 0.5);
+}
+
+.collection-group__header,
+.tree-item,
+.search-result,
+.collection-card,
+.article-list__item,
+.search-launcher {
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+}
+
+.collection-group__header {
+ width: 100%;
+ justify-content: space-between;
+ padding: 0.8rem 0.9rem;
+ background: transparent;
+}
+
+.collection-group__title {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+}
+
+.collection-group__header.is-current {
+ color: var(--text);
+}
+
+.collection-group__body {
+ padding: 0 0.55rem 0.65rem;
+}
+
+.tree-item {
+ min-height: 2.35rem;
+}
+
+.tree-link {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.7rem;
+ padding: 0.38rem 0.55rem;
+ border-radius: 10px;
+ color: var(--text-muted);
+}
+
+.tree-item.is-active .tree-link {
+ background: #fff;
+ color: var(--text);
+ box-shadow: inset 0 0 0 1px rgba(202, 215, 203, 0.8);
+}
+
+.tree-link__title {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.tree-toggle {
+ width: 1.5rem;
+ height: 1.5rem;
+ display: inline-grid;
+ place-items: center;
+ border-radius: 999px;
+ background: transparent;
+ color: var(--text-soft);
+}
+
+.tree-spacer {
+ width: 1.5rem;
+ flex: none;
+}
+
+.badge,
+.pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ border-radius: 999px;
+ padding: 0.28rem 0.62rem;
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+
+.badge-public {
+ background: #e6f3ea;
+ color: #276247;
+}
+
+.badge-internal,
+.badge-restricted {
+ background: #f9f0d5;
+ color: #8a6208;
+}
+
+.badge-muted,
+.pill {
+ background: #edf2ec;
+ color: #56685b;
+}
+
+.app-header,
+.app-content,
+.app-footer {
+ width: min(1320px, calc(100% - 2rem));
+ margin: 0 auto;
+}
+
+.app-header {
+ padding: 1rem 0;
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.8rem;
+}
+
+.brand small {
+ display: block;
+ color: var(--text-muted);
+}
+
+.brand-mark {
+ width: 1.15rem;
+ height: 1.15rem;
+ border-radius: 0.35rem;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
+}
+
+.search-launcher {
+ min-width: min(560px, 100%);
+ justify-content: space-between;
+ padding: 0.82rem 1rem;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.82);
+ color: var(--text-muted);
+}
+
+.search-launcher kbd {
+ border: 1px solid var(--line-strong);
+ border-bottom-width: 2px;
+ border-radius: 8px;
+ padding: 0.18rem 0.45rem;
+ font-size: 0.76rem;
+ background: var(--bg-soft);
+}
+
+.app-content {
+ padding-bottom: 2rem;
+}
+
+.app-footer {
+ padding: 1rem 0 2rem;
+ color: var(--text-muted);
+}
+
+.overview-layout,
+.manage-layout {
+ display: grid;
+ gap: 1.2rem;
+}
+
+.overview-hero,
+.panel,
+.doc-card,
+.auth-panel,
+.rail-card,
+.auth-form,
+.setup-form {
+ border: 1px solid rgba(220, 229, 219, 0.88);
+ border-radius: var(--radius-lg);
+ background: rgba(255, 255, 255, 0.84);
+ box-shadow: var(--shadow);
+ backdrop-filter: blur(18px);
+}
+
+.overview-hero,
+.doc-card,
+.auth-panel,
+.panel,
+.auth-form,
+.setup-form {
+ padding: 1.4rem;
+}
+
+.overview-hero {
+ display: grid;
+ grid-template-columns: 1.2fr 0.9fr;
+ gap: 1rem;
+ align-items: end;
+}
+
+.overview-hero h1,
+.doc-card h1,
+.auth-panel h1,
+.manage-hero h1 {
+ margin: 0;
+ font-size: clamp(2rem, 2.6vw, 3.2rem);
+ line-height: 1.05;
+ letter-spacing: -0.03em;
+}
+
+.overview-grid,
+.manage-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1.2rem;
+}
+
+.collection-cards,
+.analytics-grid,
+.field-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.9rem;
+}
+
+.collection-card,
+.article-list__item {
+ width: 100%;
+ justify-content: space-between;
+ padding: 1rem;
+ border-radius: var(--radius-md);
+ background: var(--bg-soft);
+ color: inherit;
+}
+
+.collection-card__icon {
+ width: 2.5rem;
+ height: 2.5rem;
+ display: grid;
+ place-items: center;
+ border-radius: 12px;
+ background: var(--brand-soft);
+ color: var(--brand);
+}
+
+.collection-card p,
+.article-list__item p,
+.search-result__body p {
+ margin: 0.18rem 0 0;
+ color: var(--text-muted);
+}
+
+.article-list__item {
+ align-items: flex-start;
+ flex-direction: column;
+}
+
+.article-list__meta {
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ gap: 1rem;
+}
+
+.document-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 260px;
+ gap: 1.2rem;
+ align-items: start;
+}
+
+.doc-card__header-spread {
+ align-items: flex-start;
+}
+
+.doc-badges,
+.breadcrumbs {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.breadcrumbs {
+ margin-bottom: 1rem;
+ color: var(--text-soft);
+ font-size: 0.92rem;
+}
+
+.doc-meta,
+.doc-summary {
+ color: var(--text-muted);
+}
+
+.doc-summary {
+ max-width: 70ch;
+ font-size: 1.02rem;
+ line-height: 1.7;
+}
+
+.doc-actions {
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.right-rail {
+ position: sticky;
+ top: 1rem;
+ display: grid;
+ gap: 1rem;
+}
+
+.rail-card {
+ padding: 1rem;
+}
+
+.toc-rail a {
+ display: block;
+ padding: 0.35rem 0;
+ color: var(--text-muted);
+}
+
+.context-list div {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.context-list span {
+ color: var(--text-muted);
+}
+
+.markdown-prose {
+ max-width: 76ch;
+ font-family: Georgia, "Times New Roman", serif;
+ font-size: 1.05rem;
+ line-height: 1.9;
+}
+
+.markdown-prose :is(h1, h2, h3, h4) {
+ margin-top: 2.4rem;
+ margin-bottom: 1rem;
+ line-height: 1.18;
+ letter-spacing: -0.02em;
+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+.markdown-prose p,
+.markdown-prose ul,
+.markdown-prose ol,
+.markdown-prose blockquote,
+.markdown-prose table,
+.markdown-prose pre {
+ margin: 1rem 0;
+}
+
+.markdown-prose ul,
+.markdown-prose ol {
+ padding-left: 1.25rem;
+}
+
+.markdown-prose a {
+ color: var(--brand);
+ text-decoration: underline;
+ text-underline-offset: 0.18em;
+}
+
+.markdown-prose blockquote {
+ margin-left: 0;
+ padding-left: 1rem;
+ border-left: 3px solid var(--line-strong);
+ color: var(--text-muted);
+}
+
+.markdown-prose table {
+ width: 100%;
+ border-collapse: collapse;
+ overflow: hidden;
+ border-radius: var(--radius-sm);
+}
+
+.markdown-prose th,
+.markdown-prose td {
+ border: 1px solid var(--line);
+ padding: 0.7rem;
+ text-align: left;
+}
+
+.markdown-prose pre {
+ position: relative;
+ padding: 1rem;
+ border-radius: var(--radius-md);
+ overflow: auto;
+ background: #18201a;
+ color: #e9f1ea;
+}
+
+.markdown-prose code {
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 0.92em;
+}
+
+.markdown-prose :not(pre) > code {
+ padding: 0.16rem 0.38rem;
+ border-radius: 8px;
+ background: #edf2ec;
+ color: #244a35;
+}
+
+.code-copy {
+ position: absolute;
+ top: 0.75rem;
+ right: 0.75rem;
+ border-radius: 999px;
+ padding: 0.35rem 0.7rem;
+ background: rgba(255, 255, 255, 0.12);
+ color: white;
+}
+
+.feedback-card {
+ margin-top: 2rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--line);
+}
+
+.auth-layout,
+.setup-screen {
+ min-height: 100vh;
+ width: min(1200px, calc(100% - 2rem));
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1.2rem;
+ align-items: center;
+}
+
+.setup-points {
+ margin-top: 1.25rem;
+}
+
+.setup-points div {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ padding: 0.72rem 0.9rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: rgba(255, 255, 255, 0.65);
+}
+
+.field {
+ display: grid;
+ gap: 0.45rem;
+}
+
+.checkbox-row {
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+}
+
+.checkbox-row input {
+ width: auto;
+}
+
+.checkbox-card {
+ padding: 0.9rem 1rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: var(--bg-soft);
+}
+
+.panel__footer {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin-top: 1rem;
+}
+
+.feedback-item {
+ border-top: 1px solid var(--line);
+ padding-top: 0.9rem;
+ margin-top: 0.9rem;
+}
+
+.feedback-actions {
+ margin-top: 0.85rem;
+}
+
+.command-palette__overlay,
+.sidebar-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(20, 29, 21, 0.35);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 160ms ease;
+ z-index: 20;
+}
+
+.command-palette__overlay.is-open,
+.sidebar-overlay.is-open {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.command-palette {
+ position: fixed;
+ top: 8vh;
+ left: 50%;
+ width: min(760px, calc(100% - 2rem));
+ transform: translateX(-50%) scale(0.98);
+ opacity: 0;
+ pointer-events: none;
+ border: 1px solid var(--line);
+ border-radius: 24px;
+ background: rgba(255, 255, 255, 0.96);
+ box-shadow: 0 28px 70px rgba(17, 28, 18, 0.18);
+ z-index: 30;
+ overflow: hidden;
+ transition: opacity 160ms ease, transform 160ms ease;
+}
+
+.command-palette.is-open {
+ opacity: 1;
+ transform: translateX(-50%) scale(1);
+ pointer-events: auto;
+}
+
+.command-palette__input {
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+ padding: 1rem 1.1rem;
+ border-bottom: 1px solid var(--line);
+}
+
+.command-palette__input input {
+ border: 0;
+ padding: 0;
+ background: transparent;
+}
+
+.command-palette__results {
+ max-height: min(60vh, 560px);
+ overflow: auto;
+ padding: 0.8rem;
+}
+
+.search-group {
+ margin-top: 0.35rem;
+}
+
+.search-group__label {
+ padding: 0.55rem 0.7rem;
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-soft);
+}
+
+.search-result {
+ padding: 0.85rem;
+ border-radius: var(--radius-md);
+}
+
+.search-result:hover {
+ background: var(--bg-soft);
+}
+
+.search-result__icon {
+ width: 2rem;
+ height: 2rem;
+ display: grid;
+ place-items: center;
+ border-radius: 10px;
+ background: var(--brand-soft);
+ color: var(--brand);
+}
+
+.search-result__body span {
+ display: inline-block;
+ margin-top: 0.3rem;
+ color: var(--text-soft);
+ font-size: 0.86rem;
+}
+
+.search-bar {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ padding: 0.35rem 0.35rem 0.35rem 0.9rem;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.92);
+}
+
+.search-bar input {
+ border: 0;
+ padding: 0;
+ background: transparent;
+}
+
+.empty-state {
+ padding: 1rem 0;
+}
+
+.skeleton {
+ border-radius: 999px;
+ background: linear-gradient(90deg, #e7ede6 0%, #f4f7f3 50%, #e7ede6 100%);
+ background-size: 240px 100%;
+ animation: pulse 1.5s linear infinite;
+}
+
+.skeleton-group,
+.skeleton-block {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.skeleton-title { height: 1.3rem; width: 70%; margin-bottom: 1rem; }
+.skeleton-line { height: 0.95rem; width: 100%; }
+.skeleton-heading { height: 2.1rem; width: 60%; margin: 0.8rem 0; }
+.skeleton-meta { height: 0.9rem; width: 35%; margin-bottom: 1rem; }
+.skeleton-paragraph { height: 0.95rem; width: 100%; }
+.skeleton-subheading { height: 1.2rem; width: 42%; }
+.skeleton-chip { height: 1.6rem; width: 6rem; }
+.skeleton.short { width: 55%; }
+
+.loading-screen {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+}
+
+.mobile-only {
+ display: none;
+}
+
+@keyframes pulse {
+ 0% { background-position: 240px 0; }
+ 100% { background-position: -240px 0; }
+}
+
+@media (max-width: 1100px) {
+ .document-layout,
+ .overview-grid,
+ .manage-grid,
+ .auth-layout,
+ .setup-screen {
+ grid-template-columns: 1fr;
+ }
+
+ .right-rail {
+ position: static;
+ }
+}
+
+@media (max-width: 920px) {
+ .kb-app {
+ grid-template-columns: 1fr;
+ }
+
+ .mobile-only {
+ display: inline-flex;
+ }
+
+ .sidebar {
+ position: fixed;
+ inset: 0 auto 0 0;
+ width: min(88vw, 340px);
+ transform: translateX(-100%);
+ transition: transform 180ms ease;
+ z-index: 25;
+ }
+
+ .sidebar.is-open {
+ transform: translateX(0);
+ }
+
+ .app-header {
+ grid-template-columns: 1fr;
+ gap: 0.85rem;
+ align-items: stretch;
+ }
+
+ .app-header,
+ .app-content,
+ .app-footer {
+ width: min(1320px, calc(100% - 1.2rem));
+ }
+
+ .overview-hero,
+ .field-grid,
+ .collection-cards,
+ .analytics-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .app-header__center {
+ order: 3;
+ }
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 3703070..904d91b 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import "./styles.css";
+import "./docs-theme.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index d2b1263..79f8395 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -1,306 +1,4 @@
-:root {
- font-family: "Segoe UI", "Helvetica Neue", sans-serif;
- color: #132238;
- background:
- radial-gradient(circle at top left, rgba(36, 92, 255, 0.14), transparent 28%),
- linear-gradient(180deg, #f7f9fd 0%, #eef3fb 100%);
-}
-
-* {
- box-sizing: border-box;
-}
-
-body {
- margin: 0;
- min-height: 100vh;
-}
-
-a {
- color: inherit;
- text-decoration: none;
-}
-
-button,
-input,
-select,
-textarea {
- font: inherit;
-}
-
-button {
- border: 0;
- border-radius: 14px;
- padding: 0.8rem 1.1rem;
- background: #245cff;
- color: white;
- cursor: pointer;
-}
-
-.button-secondary {
- background: #e6ecff;
- color: #16306b;
-}
-
-input,
-select,
-textarea {
- width: 100%;
- border: 1px solid #d5def0;
- border-radius: 14px;
- padding: 0.8rem 0.9rem;
- background: white;
-}
-
-textarea {
- min-height: 110px;
- resize: vertical;
-}
-
-.app-shell {
- min-height: 100vh;
- display: grid;
- grid-template-rows: auto 1fr auto;
-}
-
-.topbar,
-.footer,
-main {
- width: min(1180px, calc(100% - 2rem));
- margin: 0 auto;
-}
-
-.topbar {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 1.2rem 0;
-}
-
-.brand {
- display: flex;
- gap: 0.9rem;
- align-items: center;
-}
-
-.brand-mark {
- width: 20px;
- height: 20px;
- border-radius: 7px;
- display: inline-block;
-}
-
-.brand small,
-.muted {
- color: #60708b;
-}
-
-.topnav {
- display: flex;
- align-items: center;
- gap: 1rem;
-}
-
-main {
- padding: 1rem 0 2rem;
-}
-
-.hero-layout,
-.dashboard-grid,
-.setup-layout {
- display: grid;
- gap: 1.25rem;
-}
-
-.hero-layout {
- grid-template-columns: 1.2fr 1fr;
-}
-
-.setup-layout {
- grid-template-columns: 1fr 1.1fr;
-}
-
-.grid,
-.content-layout {
- display: grid;
- grid-template-columns: 280px 1fr;
- gap: 1.25rem;
-}
-
-.dashboard-grid {
- grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
-}
-
-.hero-card,
-.card,
-.page-card,
-.auth-card,
-.sidebar-card {
- background: rgba(255, 255, 255, 0.88);
- backdrop-filter: blur(18px);
- border: 1px solid rgba(198, 210, 234, 0.8);
- border-radius: 24px;
- padding: 1.4rem;
- box-shadow: 0 22px 60px rgba(29, 55, 107, 0.08);
-}
-
-.hero-card {
- padding: 2rem;
-}
-
-.eyebrow {
- text-transform: uppercase;
- letter-spacing: 0.12em;
- font-size: 0.8rem;
- color: #245cff;
-}
-
-.lede {
- font-size: 1.05rem;
- line-height: 1.6;
- color: #33435f;
-}
-
-.search-bar,
-.row,
-.split {
- display: flex;
- gap: 0.8rem;
-}
-
-.search-bar input {
- flex: 1;
-}
-
-.split > * {
- flex: 1;
-}
-
-.stack {
- display: grid;
- gap: 0.8rem;
-}
-
-.space-link,
-.result-item,
-.page-tree__item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 0.8rem;
- padding: 0.9rem 1rem;
- border-radius: 16px;
- background: #f5f8ff;
-}
-
-.space-link {
- width: 100%;
- color: #132238;
- background: #f5f8ff;
-}
-
-.page-card__header {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- gap: 1rem;
-}
-
-.pill {
- padding: 0.35rem 0.7rem;
- border-radius: 999px;
- background: #eff3ff;
- color: #20428f;
- font-size: 0.85rem;
-}
-
-.pill-public {
- background: #edf8ee;
- color: #226538;
-}
-
-.pill-internal,
-.pill-restricted {
- background: #fff3d6;
- color: #815600;
-}
-
-.toc {
- display: flex;
- flex-wrap: wrap;
- gap: 0.8rem;
- margin: 1rem 0 1.5rem;
-}
-
-.toc a {
- color: #245cff;
-}
-
-.markdown {
- line-height: 1.7;
-}
-
-.markdown h1,
-.markdown h2,
-.markdown h3 {
- scroll-margin-top: 100px;
-}
-
-.markdown pre,
-.markdown code {
- background: #eef3fb;
- border-radius: 12px;
-}
-
-.editor-textarea {
- min-height: 240px;
-}
-
-.auth-card {
- max-width: 480px;
- margin: 4rem auto;
-}
-
-.feedback-item {
- border-top: 1px solid #e5ecf8;
- padding-top: 0.8rem;
- margin-top: 0.8rem;
-}
-
-.checkbox-row {
- display: flex;
- align-items: center;
- gap: 0.7rem;
-}
-
-.checkbox-row input {
- width: auto;
-}
-
-.loading-screen {
- min-height: 100vh;
- display: grid;
- place-items: center;
-}
-
-.footer {
- padding: 1rem 0 2rem;
- color: #60708b;
-}
-
-@media (max-width: 900px) {
- .hero-layout,
- .grid,
- .content-layout,
- .setup-layout {
- grid-template-columns: 1fr;
- }
-
- .topbar,
- .topnav,
- .page-card__header,
- .search-bar,
- .row,
- .split {
- flex-direction: column;
- }
+html {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
}
diff --git a/ledger-deploy-ui.zip b/ledger-deploy-ui.zip
new file mode 100644
index 0000000..e96d85a
Binary files /dev/null and b/ledger-deploy-ui.zip differ