NobGit
public anord read

Ledger

Why work hard when you can work easier?

Languages

Repository composition by tracked source files.

TypeScript
TypeScript 86% CSS 10% SQL 3% Shell 1% HTML 0%
Create file Wiki Documentation
Clone
https://nobgit.com/orgs/anord/ledger.git
ssh://[email protected]:2222/orgs/anord/ledger.git

Commit

Redesign the web app into a docs-first knowledge base UI

9693751
Alex Nord <[email protected]> 3 months ago
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