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

Continues UI fixes and fixing Docs

39c4a7e
Alex Nord <[email protected]> 3 months ago
.env.example                                     |   2 +-
 apps/api/src/app.ts                              |   1 +
 apps/api/src/services/settings.ts                |   2 +-
 apps/web/src/AppShell.tsx                        |  62 ++++++
 apps/web/src/components/AdminConsole.tsx         |  24 ++-
 apps/web/src/components/DashboardView.tsx        |   4 +-
 apps/web/src/components/DocsSidebar.tsx          |  11 +-
 apps/web/src/components/MarkdownComposerHint.tsx |  18 ++
 apps/web/src/components/PageEditor.tsx           |  25 ++-
 apps/web/src/components/PreferencesPage.tsx      | 255 ++++++++++++++++++++++
 apps/web/src/components/SpacesPage.tsx           |  20 ++
 apps/web/src/docs-theme.css                      | 260 +++++++++++++++++++++--
 apps/web/src/lib/api.ts                          |   2 +-
 apps/web/src/lib/mcp.ts                          |  16 ++
 apps/web/vite.config.ts                          |  32 ++-
 15 files changed, 699 insertions(+), 35 deletions(-)
 create mode 100644 apps/web/src/components/MarkdownComposerHint.tsx
 create mode 100644 apps/web/src/components/PreferencesPage.tsx
 create mode 100644 apps/web/src/lib/mcp.ts

Diff

diff --git a/.env.example b/.env.example
index 0b1a019..7ca968b 100644
--- a/.env.example
+++ b/.env.example
@@ -1,7 +1,7 @@
 NODE_ENV=development
 LEDGER_APP_URL=http://localhost:5173
 LEDGER_API_URL=http://localhost:4000
-VITE_API_URL=http://localhost:4000
+VITE_API_URL=
 PORT=4000
 
 POSTGRES_DB=ledger
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 5e27bfe..58e07a3 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -72,6 +72,7 @@ export function createApp() {
   app.use("/api/webhooks", webhooksRouter);
   app.use("/api/integrations", integrationsRouter);
   app.use("/api/ai", aiRouter);
+  app.use("/mcp", mcpRouter);
   app.use("/api/mcp", mcpRouter);
 
   app.use(errorHandler);
diff --git a/apps/api/src/services/settings.ts b/apps/api/src/services/settings.ts
index 9cd25fa..ef379f4 100644
--- a/apps/api/src/services/settings.ts
+++ b/apps/api/src/services/settings.ts
@@ -72,7 +72,7 @@ export async function getAdminSettingsBundle() {
     smtp: smtp.rows[0],
     ai: ai.rows[0],
     mcp: {
-      endpoint: `${env.LEDGER_APP_URL.replace(':5173', ':4000')}/api/mcp`,
+      endpoint: `${env.LEDGER_APP_URL}/mcp`,
       authMode: "session_cookie"
     },
     authProviders: {
diff --git a/apps/web/src/AppShell.tsx b/apps/web/src/AppShell.tsx
index 3164995..fad9e26 100644
--- a/apps/web/src/AppShell.tsx
+++ b/apps/web/src/AppShell.tsx
@@ -19,6 +19,7 @@ import { FeedbackForm } from "./components/FeedbackForm";
 import { Icon } from "./components/Icon";
 import { ImportsPage } from "./components/ImportsPage";
 import { PageEditor } from "./components/PageEditor";
+import { PreferencesPage, type PreferencesState } from "./components/PreferencesPage";
 import { DocsSidebar } from "./components/DocsSidebar";
 import { SearchPage } from "./components/SearchPage";
 import { SearchBar } from "./components/SearchBar";
@@ -55,6 +56,19 @@ type Space = {
 type PageRecordMap = Record<string, PageSummary[]>;
 type AdminFeedback = Array<{ id: string; page_title: string; helpful: boolean; comment: string | null }>;
 
+function readStoredPreference<T>(key: string, fallback: T, parse?: (raw: string) => T): T {
+  if (typeof window === "undefined") {
+    return fallback;
+  }
+
+  const value = window.localStorage.getItem(key);
+  if (!value) {
+    return fallback;
+  }
+
+  return parse ? parse(value) : (value as T);
+}
+
 function useSession() {
   const [user, setUser] = useState<SessionUser | null>(null);
   const [loading, setLoading] = useState(true);
@@ -216,6 +230,7 @@ function DocsShell({
   searchLoading,
   onSearch,
   onLogout,
+  preferences,
   children
 }: {
   branding: BrandingResponse["branding"] | null;
@@ -228,6 +243,7 @@ function DocsShell({
   searchLoading: boolean;
   onSearch: (query: string) => void;
   onLogout: () => Promise<void>;
+  preferences: PreferencesState;
   children: React.ReactNode;
 }) {
   const location = useLocation();
@@ -294,6 +310,7 @@ function DocsShell({
           currentSpaceKey={currentSpace?.key}
           currentSlug={currentPageSlug}
           user={user ? { displayName: user.displayName, role: user.role } : null}
+          preferences={preferences}
           isOpen={sidebarOpen}
           onClose={() => setSidebarOpen(false)}
         />
@@ -1136,12 +1153,31 @@ export function App() {
   const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
   const [searchResults, setSearchResults] = useState<PageSummary[]>([]);
   const [searchLoading, setSearchLoading] = useState(false);
+  const [preferences, setPreferences] = useState<PreferencesState>(() => ({
+    theme: readStoredPreference<PreferencesState["theme"]>("ledger.theme", "system"),
+    compactSidebar: readStoredPreference("ledger.compactSidebar", false, (raw) => raw === "true"),
+    emailNotifications: readStoredPreference("ledger.emailNotifications", true, (raw) => raw === "true"),
+    productUpdates: readStoredPreference("ledger.productUpdates", true, (raw) => raw === "true"),
+    smartText: readStoredPreference("ledger.smartText", true, (raw) => raw === "true"),
+    showLineNumbers: readStoredPreference("ledger.showLineNumbers", true, (raw) => raw === "true")
+  }));
 
   useEffect(() => {
     api.get<BrandingResponse>("/api/settings/public").then((response) => setBranding(response.branding));
     api.get<SetupStatus>("/api/setup/status").then(setSetupStatus);
   }, []);
 
+  useEffect(() => {
+    document.documentElement.dataset.theme = preferences.theme;
+    document.documentElement.dataset.sidebarDensity = preferences.compactSidebar ? "compact" : "default";
+    window.localStorage.setItem("ledger.theme", preferences.theme);
+    window.localStorage.setItem("ledger.compactSidebar", String(preferences.compactSidebar));
+    window.localStorage.setItem("ledger.emailNotifications", String(preferences.emailNotifications));
+    window.localStorage.setItem("ledger.productUpdates", String(preferences.productUpdates));
+    window.localStorage.setItem("ledger.smartText", String(preferences.smartText));
+    window.localStorage.setItem("ledger.showLineNumbers", String(preferences.showLineNumbers));
+  }, [preferences]);
+
   const { spaces, pagesBySpace, loading: loadingNavigation, error: navigationError } = useKnowledgeBaseData(
     Boolean(setupStatus?.isInitialized)
   );
@@ -1206,10 +1242,36 @@ export function App() {
       searchLoading={searchLoading}
       onSearch={handleSearch}
       onLogout={logout}
+      preferences={preferences}
     >
       <Routes>
         <Route path="/" element={<Navigate to="/spaces" replace />} />
         <Route path="/login" element={<LoginPage onLogin={setUser} />} />
+        <Route
+          path="/preferences"
+          element={
+            user ? (
+              <Navigate to="/preferences/preferences" replace />
+            ) : (
+              <LoginPage onLogin={setUser} />
+            )
+          }
+        />
+        <Route
+          path="/preferences/:section"
+          element={
+            user ? (
+              <PreferencesPage
+                user={user}
+                preferences={preferences}
+                onUpdatePreferences={(patch) => setPreferences((current) => ({ ...current, ...patch }))}
+                onLogout={logout}
+              />
+            ) : (
+              <LoginPage onLogin={setUser} />
+            )
+          }
+        />
         <Route path="/search" element={<SearchPage spaces={spaces} results={searchResults} isLoading={searchLoading} onSearch={handleSearch} />} />
         <Route path="/spaces" element={<SpacesPage spaces={spaces} pagesBySpace={pagesBySpace} />} />
         <Route path="/drafts" element={<DraftsPage user={user} spaces={spaces} />} />
diff --git a/apps/web/src/components/AdminConsole.tsx b/apps/web/src/components/AdminConsole.tsx
index c34f309..e96247f 100644
--- a/apps/web/src/components/AdminConsole.tsx
+++ b/apps/web/src/components/AdminConsole.tsx
@@ -8,6 +8,7 @@ import type {
   WebhookSummary
 } from "@ledger/shared";
 import { api } from "../lib/api";
+import { resolveDisplayedMcpEndpoint } from "../lib/mcp";
 import { EmptyState } from "./EmptyState";
 import { PageHeader } from "./PageHeader";
 
@@ -111,11 +112,19 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
     events: ["page.created", "page.updated"] as string[]
   });
   const [status, setStatus] = useState<string | null>(null);
+  const [mcpSettings, setMcpSettings] = useState({
+    endpoint: "/mcp",
+    authMode: "session_cookie"
+  });
 
   const currentWebhook = useMemo(
     () => webhooks.find((webhook) => webhook.id === editingWebhookId) ?? null,
     [editingWebhookId, webhooks]
   );
+  const displayedMcpEndpoint = useMemo(
+    () => resolveDisplayedMcpEndpoint(mcpSettings.endpoint),
+    [mcpSettings.endpoint]
+  );
 
   useEffect(() => {
     async function load() {
@@ -144,6 +153,7 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
         brandColor: settingsResponse.branding.brand_color,
         publicKnowledgeBaseEnabled: settingsResponse.branding.public_knowledge_base_enabled
       });
+      setMcpSettings(settingsResponse.mcp);
       setIntegrations(integrationsResponse.integrations);
       setImportJobs(jobsResponse.jobs);
       setAiSettings((current) => ({ ...current, ...aiResponse.settings, apiKey: "" }));
@@ -726,8 +736,12 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
             </div>
             <div className="list-grid">
               <div className="list-item">
-                <strong>Status</strong>
-                <span>MCP is exposed at `/api/mcp` through the same backend service as the application.</span>
+                <strong>Hosted endpoint</strong>
+                <span>{displayedMcpEndpoint}</span>
+              </div>
+              <div className="list-item">
+                <strong>Transport</strong>
+                <span>Ledger exposes MCP on the same host it is served from, so deployed workspaces use their own domain for MCP access.</span>
               </div>
               <div className="list-item">
                 <strong>Tools</strong>
@@ -735,12 +749,16 @@ export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUs
               </div>
               <div className="list-item">
                 <strong>Auth</strong>
-                <span>MCP currently uses the active Ledger session. Dedicated API tokens are not implemented, so the UI does not claim otherwise.</span>
+                <span>MCP currently uses {mcpSettings.authMode === "session_cookie" ? "the active Ledger session cookie" : mcpSettings.authMode}. Dedicated API tokens are not implemented, so the UI does not claim otherwise.</span>
               </div>
               <div className="list-item">
                 <strong>Permission behavior</strong>
                 <span>MCP calls inherit the same backend visibility checks as page reads, search, and AI retrieval.</span>
               </div>
+              <div className="list-item">
+                <strong>Backend compatibility</strong>
+                <span>The backend also accepts the same MCP router on its direct API service, so local development keeps working without a reverse proxy.</span>
+              </div>
             </div>
           </section>
         ) : null}
diff --git a/apps/web/src/components/DashboardView.tsx b/apps/web/src/components/DashboardView.tsx
index fa470d9..46e6a4d 100644
--- a/apps/web/src/components/DashboardView.tsx
+++ b/apps/web/src/components/DashboardView.tsx
@@ -7,6 +7,7 @@ import type {
   WebhookSummary
 } from "@ledger/shared";
 import { api } from "../lib/api";
+import { resolveDisplayedMcpEndpoint } from "../lib/mcp";
 import { PageEditor } from "./PageEditor";
 
 type Space = {
@@ -67,6 +68,7 @@ export function DashboardView({ user, spaces }: { user: SessionUser; spaces: Spa
     visibility: "internal",
     state: "draft"
   });
+  const displayedMcpEndpoint = resolveDisplayedMcpEndpoint();
 
   async function loadAdminData() {
     if (!canAdmin) {
@@ -524,7 +526,7 @@ export function DashboardView({ user, spaces }: { user: SessionUser; spaces: Spa
                   <h3>Ledger MCP server</h3>
                 </div>
               </div>
-              <p className="muted">Endpoint: `/api/mcp`</p>
+              <p className="muted">Endpoint: {displayedMcpEndpoint}</p>
               <p className="muted">Tools: `search_knowledge_base`, `read_page`, `list_spaces`, `get_page_metadata`, `create_draft_page`</p>
               <p className="muted">Auth: same session context as the web app.</p>
             </section>
diff --git a/apps/web/src/components/DocsSidebar.tsx b/apps/web/src/components/DocsSidebar.tsx
index dfcd2c8..6c7fdd6 100644
--- a/apps/web/src/components/DocsSidebar.tsx
+++ b/apps/web/src/components/DocsSidebar.tsx
@@ -95,6 +95,7 @@ export function DocsSidebar({
   currentSpaceKey,
   currentSlug,
   user,
+  preferences,
   isOpen,
   onClose
 }: {
@@ -103,11 +104,13 @@ export function DocsSidebar({
   currentSpaceKey?: string;
   currentSlug?: string;
   user: { displayName: string; role: string } | null;
+  preferences: { compactSidebar: boolean };
   isOpen: boolean;
   onClose: () => void;
 }) {
   const location = useLocation();
   const [collapsedSpaces, setCollapsedSpaces] = useState<Record<string, boolean>>({});
+  const accountTarget = user ? "/preferences/profile" : "/login";
 
   const recentPages = useMemo(
     () =>
@@ -121,7 +124,7 @@ export function DocsSidebar({
   return (
     <>
       <div className={`sidebar-overlay${isOpen ? " is-open" : ""}`} onClick={onClose} />
-      <aside className={`sidebar${isOpen ? " is-open" : ""}`}>
+      <aside className={`sidebar${isOpen ? " is-open" : ""}${preferences.compactSidebar ? " is-compact" : ""}`}>
         <div className="sidebar__top">
           <div className="sidebar__brand">
             <span className="sidebar__brand-mark">L</span>
@@ -240,7 +243,7 @@ export function DocsSidebar({
                       <Icon name="collection" className="icon icon-sm" />
                       {space.name}
                     </span>
-                    <span className={`badge badge-${space.visibility}`}>{space.visibility}</span>
+            <span className={`badge badge-${space.visibility}`}>{space.visibility}</span>
                   </button>
 
                   {!isCollapsed ? (
@@ -265,13 +268,13 @@ export function DocsSidebar({
         </section>
 
         <div className="sidebar__footer">
-          <div className="sidebar__account">
+          <Link to={accountTarget} className={`sidebar__account${location.pathname.startsWith("/preferences") ? " is-current" : ""}`} onClick={() => closeSidebarOnMobile(onClose)}>
             <div className="sidebar__avatar">{user ? user.displayName.slice(0, 1).toUpperCase() : "P"}</div>
             <div className="sidebar__account-body">
               <strong>{user?.displayName ?? "Public visitor"}</strong>
               <span>{user?.role ?? "public"}</span>
             </div>
-          </div>
+          </Link>
         </div>
       </aside>
     </>
diff --git a/apps/web/src/components/MarkdownComposerHint.tsx b/apps/web/src/components/MarkdownComposerHint.tsx
new file mode 100644
index 0000000..3a98f61
--- /dev/null
+++ b/apps/web/src/components/MarkdownComposerHint.tsx
@@ -0,0 +1,18 @@
+export function MarkdownComposerHint() {
+  return (
+    <section className="markdown-helper" aria-label="Markdown supported">
+      <div className="markdown-helper__header">
+        <p className="eyebrow">Composer</p>
+        <h4>Markdown supported</h4>
+      </div>
+      <div className="markdown-helper__body">
+        <code># Heading</code>
+        <code>- Bullet list</code>
+        <code>```code```</code>
+        <code>&gt; Quote</code>
+        <code>[Link](https://example.com)</code>
+      </div>
+      <p className="markdown-helper__note">Paste Markdown and Ledger will format it automatically.</p>
+    </section>
+  );
+}
diff --git a/apps/web/src/components/PageEditor.tsx b/apps/web/src/components/PageEditor.tsx
index 89d49b9..b4d331a 100644
--- a/apps/web/src/components/PageEditor.tsx
+++ b/apps/web/src/components/PageEditor.tsx
@@ -1,17 +1,34 @@
-import { useState } from "react";
+import { useMemo, useState } from "react";
 import { api } from "../lib/api";
+import { MarkdownComposerHint } from "./MarkdownComposerHint";
 
 const emptyPage = {
   spaceId: "",
   title: "",
   slug: "",
-  bodyMarkdown: "# New page\n\nStart writing...",
+  bodyMarkdown: "",
   excerpt: "",
   visibility: "internal",
   state: "draft",
   tagNames: [] as string[]
 };
 
+function normalizeMarkdownForEmptyState(value: string) {
+  return value
+    .replace(/\u200B/g, "")
+    .replace(/\r\n/g, "\n")
+    .trim();
+}
+
+function hasMeaningfulMarkdown(value: string) {
+  const normalized = normalizeMarkdownForEmptyState(value);
+  if (!normalized) {
+    return false;
+  }
+
+  return normalized !== "# New page\n\nStart writing..." && normalized !== "Start writing...";
+}
+
 export function PageEditor({
   spaces,
   variant = "panel",
@@ -29,6 +46,7 @@ export function PageEditor({
   });
   const [status, setStatus] = useState<string | null>(null);
   const isDialog = variant === "dialog";
+  const showMarkdownHelper = useMemo(() => !hasMeaningfulMarkdown(form.bodyMarkdown), [form.bodyMarkdown]);
 
   async function submit() {
     try {
@@ -131,10 +149,13 @@ export function PageEditor({
       </label>
       <label className="field">
         Markdown
+        {showMarkdownHelper ? <MarkdownComposerHint /> : null}
         <textarea
           className={`editor-textarea${isDialog ? " editor-textarea-dialog" : ""}`}
           value={form.bodyMarkdown}
           onChange={(event) => setForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
+          placeholder="Write Markdown, use / for blocks, or paste existing docs…"
+          aria-label="Document markdown body"
         />
       </label>
       <div className="panel__footer">
diff --git a/apps/web/src/components/PreferencesPage.tsx b/apps/web/src/components/PreferencesPage.tsx
new file mode 100644
index 0000000..987ef85
--- /dev/null
+++ b/apps/web/src/components/PreferencesPage.tsx
@@ -0,0 +1,255 @@
+import { useEffect, useState } from "react";
+import type { ReactNode } from "react";
+import { Link, useParams } from "react-router-dom";
+import type { SessionUser } from "@ledger/shared";
+import { PageHeader } from "./PageHeader";
+
+export type PreferencesState = {
+  theme: "system" | "light" | "dark";
+  compactSidebar: boolean;
+  emailNotifications: boolean;
+  productUpdates: boolean;
+  smartText: boolean;
+  showLineNumbers: boolean;
+};
+
+const sections = [
+  ["profile", "Profile"],
+  ["preferences", "Preferences"],
+  ["appearance", "Appearance"],
+  ["notifications", "Notifications"],
+  ["account", "Account"]
+] as const;
+
+function SettingsRow({
+  label,
+  description,
+  control
+}: {
+  label: string;
+  description: string;
+  control: ReactNode;
+}) {
+  return (
+    <div className="settings-row">
+      <div className="settings-row__content">
+        <strong>{label}</strong>
+        <p>{description}</p>
+      </div>
+      <div className="settings-row__control">{control}</div>
+    </div>
+  );
+}
+
+function Toggle({
+  checked,
+  onChange,
+  disabled = false,
+  label
+}: {
+  checked: boolean;
+  onChange: (next: boolean) => void;
+  disabled?: boolean;
+  label: string;
+}) {
+  return (
+    <label className={`toggle-switch${disabled ? " is-disabled" : ""}`} aria-label={label}>
+      <input
+        type="checkbox"
+        checked={checked}
+        disabled={disabled}
+        onChange={(event) => onChange(event.target.checked)}
+      />
+      <span className="toggle-switch__track" />
+    </label>
+  );
+}
+
+export function PreferencesPage({
+  user,
+  preferences,
+  onUpdatePreferences,
+  onLogout
+}: {
+  user: SessionUser;
+  preferences: PreferencesState;
+  onUpdatePreferences: (patch: Partial<PreferencesState>) => void;
+  onLogout: () => Promise<void>;
+}) {
+  const { section = "preferences" } = useParams();
+  const currentSection = sections.some(([key]) => key === section) ? section : "preferences";
+  const [displayName, setDisplayName] = useState(user.displayName);
+
+  useEffect(() => {
+    setDisplayName(user.displayName);
+  }, [user.displayName]);
+
+  function renderSection() {
+    switch (currentSection) {
+      case "profile":
+        return (
+          <section className="settings-section">
+            <h2 className="settings-section__title">Profile</h2>
+            <SettingsRow
+              label="Display name"
+              description="Profile editing is not connected to the server yet, but you can preview a preferred display name here."
+              control={<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />}
+            />
+            <SettingsRow
+              label="Email"
+              description="Your account email is controlled by Ledger authentication."
+              control={<input value={user.email} disabled />}
+            />
+          </section>
+        );
+      case "appearance":
+        return (
+          <section className="settings-section">
+            <h2 className="settings-section__title">Appearance</h2>
+            <SettingsRow
+              label="Theme"
+              description="Choose how Ledger should appear on this device."
+              control={
+                <select
+                  value={preferences.theme}
+                  onChange={(event) =>
+                    onUpdatePreferences({ theme: event.target.value as PreferencesState["theme"] })
+                  }
+                >
+                  <option value="system">System</option>
+                  <option value="light">Light</option>
+                  <option value="dark">Dark</option>
+                </select>
+              }
+            />
+            <SettingsRow
+              label="Compact sidebar"
+              description="Reduce sidebar spacing for a denser documentation tree."
+              control={
+                <Toggle
+                  label="Compact sidebar"
+                  checked={preferences.compactSidebar}
+                  onChange={(next) => onUpdatePreferences({ compactSidebar: next })}
+                />
+              }
+            />
+          </section>
+        );
+      case "notifications":
+        return (
+          <section className="settings-section">
+            <h2 className="settings-section__title">Notifications</h2>
+            <SettingsRow
+              label="Email notifications"
+              description="Receive important workspace updates by email when that functionality is enabled."
+              control={
+                <Toggle
+                  label="Email notifications"
+                  checked={preferences.emailNotifications}
+                  onChange={(next) => onUpdatePreferences({ emailNotifications: next })}
+                />
+              }
+            />
+            <SettingsRow
+              label="Product updates"
+              description="Show product update notices inside Ledger."
+              control={
+                <Toggle
+                  label="Product updates"
+                  checked={preferences.productUpdates}
+                  onChange={(next) => onUpdatePreferences({ productUpdates: next })}
+                />
+              }
+            />
+          </section>
+        );
+      case "account":
+        return (
+          <section className="settings-section">
+            <h2 className="settings-section__title">Account</h2>
+            <SettingsRow
+              label="Current role"
+              description="Your effective role in this workspace."
+              control={<div className="settings-row__value">{user.role}</div>}
+            />
+            <SettingsRow
+              label="Session"
+              description="Sign out of the current Ledger session."
+              control={
+                <button type="button" className="button-secondary" onClick={() => void onLogout()}>
+                  Sign out
+                </button>
+              }
+            />
+            <SettingsRow
+              label="Danger zone"
+              description="Advanced account actions are not available in this build yet."
+              control={<button type="button" className="button-secondary" disabled>Unavailable</button>}
+            />
+          </section>
+        );
+      case "preferences":
+      default:
+        return (
+          <>
+            <section className="settings-section">
+              <h2 className="settings-section__title">Preferences</h2>
+              <SettingsRow
+                label="Smart text replacements"
+                description="Auto-format quotes, dashes, and common markdown shortcuts while editing."
+                control={
+                  <Toggle
+                    label="Smart text replacements"
+                    checked={preferences.smartText}
+                    onChange={(next) => onUpdatePreferences({ smartText: next })}
+                  />
+                }
+              />
+              <SettingsRow
+                label="Show line numbers"
+                description="Display line numbers next to code blocks where supported."
+                control={
+                  <Toggle
+                    label="Show line numbers"
+                    checked={preferences.showLineNumbers}
+                    onChange={(next) => onUpdatePreferences({ showLineNumbers: next })}
+                  />
+                }
+              />
+            </section>
+          </>
+        );
+    }
+  }
+
+  return (
+    <div className="preferences-shell">
+      <aside className="preferences-sidebar">
+        <Link to="/spaces" className="preferences-backlink">Back to app</Link>
+        <div className="preferences-nav-group">
+          <p className="preferences-nav-group__label">Account</p>
+          <nav className="preferences-nav">
+            {sections.map(([key, label]) => (
+              <Link
+                key={key}
+                to={`/preferences/${key}`}
+                className={`preferences-nav__item${currentSection === key ? " is-current" : ""}`}
+              >
+                {label}
+              </Link>
+            ))}
+          </nav>
+        </div>
+      </aside>
+
+      <div className="preferences-content">
+        <PageHeader
+          eyebrow="Preferences"
+          title={sections.find(([key]) => key === currentSection)?.[1] ?? "Preferences"}
+          description="Manage settings that affect your personal Ledger experience on this device."
+        />
+        {renderSection()}
+      </div>
+    </div>
+  );
+}
diff --git a/apps/web/src/components/SpacesPage.tsx b/apps/web/src/components/SpacesPage.tsx
index c09a4bd..4721184 100644
--- a/apps/web/src/components/SpacesPage.tsx
+++ b/apps/web/src/components/SpacesPage.tsx
@@ -87,6 +87,26 @@ export function SpacesPage({
               );
             })}
           </section>
+
+          <section className="content-section home-collections">
+            <div className="section-head">
+              <div>
+                <p className="eyebrow">Quick access</p>
+                <h3>Collections</h3>
+              </div>
+            </div>
+            <div className="collection-list">
+              {spaces.map((space) => (
+                <Link key={space.id} to={`/space/${space.key}`} className="collection-list__item">
+                  <div className="collection-list__body">
+                    <strong>{space.name}</strong>
+                    <p>{(pagesBySpace[space.key] ?? []).length} documents</p>
+                  </div>
+                  <span className={`badge badge-${space.visibility}`}>{space.visibility}</span>
+                </Link>
+              ))}
+            </div>
+          </section>
         </>
       )}
     </div>
diff --git a/apps/web/src/docs-theme.css b/apps/web/src/docs-theme.css
index 5eea6b4..7c8f5b4 100644
--- a/apps/web/src/docs-theme.css
+++ b/apps/web/src/docs-theme.css
@@ -60,6 +60,50 @@
   }
 }
 
+:root[data-theme="dark"] {
+  color-scheme: dark;
+  --bg: #0f1217;
+  --bg-sidebar: #0b0d12;
+  --bg-elevated: #12161d;
+  --bg-panel: #141922;
+  --bg-soft: #171d27;
+  --bg-soft-2: #1d2430;
+  --bg-hover: rgba(148, 163, 184, 0.08);
+  --bg-active: rgba(138, 160, 196, 0.12);
+  --text: #ebeff6;
+  --text-muted: #96a2b4;
+  --text-soft: #6b778a;
+  --line: rgba(148, 163, 184, 0.12);
+  --line-strong: rgba(148, 163, 184, 0.22);
+  --brand: #e9a23b;
+  --brand-soft: rgba(233, 162, 59, 0.14);
+  --brand-strong: #f4b45a;
+}
+
+:root[data-theme="light"] {
+  color-scheme: light;
+  --bg: #f6f4ef;
+  --bg-sidebar: #efede7;
+  --bg-elevated: #fbfaf7;
+  --bg-panel: #faf8f4;
+  --bg-soft: #f2eee7;
+  --bg-soft-2: #ebe6dc;
+  --bg-hover: rgba(15, 23, 42, 0.04);
+  --bg-active: rgba(37, 99, 235, 0.08);
+  --text: #171b22;
+  --text-muted: #596579;
+  --text-soft: #7a8596;
+  --line: rgba(15, 23, 42, 0.08);
+  --line-strong: rgba(15, 23, 42, 0.16);
+  --brand: #c97b21;
+  --brand-soft: rgba(201, 123, 33, 0.12);
+  --brand-strong: #b86718;
+}
+
+:root[data-sidebar-density="compact"] {
+  --sidebar-width: 248px;
+}
+
 * {
   box-sizing: border-box;
 }
@@ -180,14 +224,18 @@ a:focus {
   justify-content: center;
   gap: 0.5rem;
   border-radius: var(--radius-sm);
-  padding: 0.62rem 0.9rem;
-  background: var(--brand);
-  color: #111318;
+  padding: 0.5rem 0.72rem;
+  background: transparent;
+  color: var(--text);
+  border: 1px solid var(--line);
   font-weight: 600;
+  font-size: var(--text-sm);
+  letter-spacing: -0.01em;
 }
 
 .button-primary:hover {
-  background: var(--brand-strong);
+  background: var(--bg-hover);
+  border-color: var(--line-strong);
 }
 
 button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-link):not(.collection-card):not(.search-launcher):not(.home-tab) {
@@ -223,6 +271,11 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
   overflow-y: auto;
 }
 
+.sidebar.is-compact {
+  gap: var(--space-3);
+  padding-top: var(--space-3);
+}
+
 .sidebar__top,
 .panel__header,
 .doc-card__header,
@@ -362,6 +415,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
   padding: 0.38rem 0.55rem;
   background: transparent;
   border-top: 1px solid var(--line);
+  border-radius: var(--radius-sm);
 }
 
 .collection-group__title {
@@ -427,26 +481,30 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
   align-items: center;
   gap: 0.35rem;
   border-radius: var(--radius-sm);
-  padding: 0.18rem 0.38rem;
+  padding: 0.12rem 0.34rem;
   font-size: 0.72rem;
   font-weight: 600;
+  border: 1px solid transparent;
 }
 
 .badge-public {
-  background: rgba(34, 197, 94, 0.14);
+  background: transparent;
   color: #7ee0a2;
+  border-color: rgba(34, 197, 94, 0.18);
 }
 
 .badge-internal,
 .badge-restricted {
-  background: rgba(245, 158, 11, 0.14);
-  color: #f8bf54;
+  background: transparent;
+  color: #c9a46a;
+  border-color: rgba(233, 162, 59, 0.16);
 }
 
 .badge-muted,
 .pill {
-  background: var(--bg-soft-2);
+  background: transparent;
   color: var(--text-muted);
+  border-color: var(--line);
 }
 
 .app-header,
@@ -457,10 +515,22 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
 }
 
 .app-header {
-  padding: var(--space-4) 0 var(--space-3);
+  padding: 0.85rem 0 0.7rem;
   border-bottom: 1px solid var(--line);
 }
 
+.app-header__left {
+  min-width: 0;
+}
+
+.app-header__center {
+  flex: 1;
+}
+
+.app-header__right {
+  gap: var(--space-2);
+}
+
 .brand {
   display: inline-flex;
   align-items: center;
@@ -482,16 +552,17 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
 .search-launcher {
   min-width: min(560px, 100%);
   justify-content: space-between;
-  padding: 0.7rem 0.82rem;
+  padding: 0.62rem 0.75rem;
   border-radius: var(--radius-sm);
-  border: 1px solid var(--line);
-  background: var(--bg-elevated);
+  border: 1px solid transparent;
+  background: var(--bg-soft);
   color: var(--text-muted);
   transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
 }
 
 .search-launcher:hover {
-  border-color: var(--line-strong);
+  background: var(--bg-elevated);
+  border-color: var(--line);
   color: var(--text);
 }
 
@@ -514,12 +585,62 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
 
 .editor-textarea {
   min-height: 320px;
+  margin-top: 0.25rem;
+  font-family: ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
+  line-height: 1.65;
+  white-space: pre-wrap;
 }
 
 .editor-textarea-dialog {
   min-height: 420px;
 }
 
+.markdown-helper {
+  display: grid;
+  gap: 0.8rem;
+  margin-top: 0.3rem;
+  padding: 0.95rem 1rem;
+  border: 1px solid var(--line);
+  background: color-mix(in srgb, var(--bg-soft) 72%, transparent);
+}
+
+.markdown-helper__header {
+  display: grid;
+  gap: 0.2rem;
+}
+
+.markdown-helper__header .eyebrow {
+  margin-bottom: 0;
+}
+
+.markdown-helper__header h4 {
+  margin: 0;
+  font-size: var(--text-base);
+  font-weight: 600;
+}
+
+.markdown-helper__body {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+  gap: 0.55rem;
+}
+
+.markdown-helper__body code {
+  display: block;
+  padding: 0.45rem 0.55rem;
+  border: 1px solid var(--line);
+  background: transparent;
+  color: var(--text-muted);
+  font-size: var(--text-sm);
+  line-height: 1.45;
+}
+
+.markdown-helper__note {
+  margin: 0;
+  color: var(--text-muted);
+  font-size: var(--text-sm);
+}
+
 .search-launcher kbd {
   border: 1px solid var(--line-strong);
   border-radius: 3px;
@@ -625,6 +746,40 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
   line-height: 1.55;
 }
 
+.home-collections {
+  padding-top: var(--space-5);
+}
+
+.collection-list {
+  display: grid;
+}
+
+.collection-list__item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-4);
+  padding: 0.85rem 0;
+  border-bottom: 1px solid var(--line);
+  transition: color 140ms ease, border-color 140ms ease;
+}
+
+.collection-list__item:hover {
+  color: var(--text);
+  border-bottom-color: var(--line-strong);
+}
+
+.collection-list__body strong {
+  display: block;
+  font-size: var(--text-md);
+}
+
+.collection-list__body p {
+  margin: 0.25rem 0 0;
+  color: var(--text-muted);
+  font-size: var(--text-sm);
+}
+
 .panel-muted {
   background: transparent;
 }
@@ -1284,6 +1439,14 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
   display: flex;
   align-items: center;
   gap: var(--space-3);
+  padding: 0.45rem 0.55rem;
+  border-radius: var(--radius-md);
+  transition: background 140ms ease, color 140ms ease;
+}
+
+.sidebar__account:hover,
+.sidebar__account.is-current {
+  background: var(--bg-hover);
 }
 
 .sidebar__avatar {
@@ -1421,12 +1584,74 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
   padding-top: 1rem;
 }
 
+.preferences-shell {
+  display: grid;
+  grid-template-columns: 220px minmax(0, 1fr);
+  gap: 2.5rem;
+}
+
+.preferences-sidebar {
+  position: sticky;
+  top: 1rem;
+  align-self: start;
+  padding-top: 0.3rem;
+}
+
+.preferences-backlink {
+  display: inline-flex;
+  align-items: center;
+  padding-bottom: 1rem;
+  color: var(--text);
+  font-weight: 600;
+}
+
+.preferences-nav-group {
+  margin-bottom: 1rem;
+}
+
+.preferences-nav-group__label {
+  margin: 0 0 0.45rem;
+  color: #6f89b2;
+  font-size: 0.82rem;
+  font-weight: 700;
+}
+
+.preferences-nav {
+  display: grid;
+  gap: 0.18rem;
+}
+
+.preferences-nav__item {
+  display: flex;
+  align-items: center;
+  min-height: 2.3rem;
+  padding: 0.45rem 0.6rem;
+  border-radius: var(--radius-sm);
+  color: var(--text-soft);
+  font-size: var(--text-sm);
+}
+
+.preferences-nav__item:hover,
+.preferences-nav__item.is-current {
+  background: var(--bg-hover);
+  color: var(--text);
+}
+
+.preferences-content {
+  display: grid;
+  gap: var(--space-4);
+}
+
 .toggle-switch {
   position: relative;
   display: inline-flex;
   justify-content: flex-end;
 }
 
+.toggle-switch.is-disabled {
+  opacity: 0.55;
+}
+
 .toggle-switch input {
   position: absolute;
   opacity: 0;
@@ -1502,7 +1727,8 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
   .auth-layout,
   .setup-screen,
   .admin-shell,
-  .admin-grid {
+  .admin-grid,
+  .preferences-shell {
     grid-template-columns: 1fr;
   }
 
@@ -1514,6 +1740,10 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
     grid-template-columns: 1fr;
     gap: 0.8rem;
   }
+
+  .preferences-sidebar {
+    position: static;
+  }
 }
 
 @media (max-width: 920px) {
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index f6bdce7..32864e5 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -1,4 +1,4 @@
-const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:4000";
+const API_BASE = import.meta.env.VITE_API_URL ?? "";
 
 async function request<T>(path: string, init?: RequestInit): Promise<T> {
   const headers = new Headers(init?.headers);
diff --git a/apps/web/src/lib/mcp.ts b/apps/web/src/lib/mcp.ts
new file mode 100644
index 0000000..287f3df
--- /dev/null
+++ b/apps/web/src/lib/mcp.ts
@@ -0,0 +1,16 @@
+function getOrigin(value: string | undefined | null) {
+  if (!value) return null;
+
+  try {
+    return new URL(value, typeof window !== "undefined" ? window.location.origin : "http://localhost").origin;
+  } catch {
+    return null;
+  }
+}
+
+export function resolveDisplayedMcpEndpoint(fallbackEndpoint?: string) {
+  const browserOrigin = typeof window !== "undefined" ? window.location.origin : null;
+  const fallbackOrigin = getOrigin(fallbackEndpoint);
+  const origin = browserOrigin ?? fallbackOrigin;
+  return origin ? `${origin}/mcp` : "/mcp";
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index cadfa39..909b995 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -1,10 +1,28 @@
-import { defineConfig } from "vite";
+import { defineConfig, loadEnv } from "vite";
 import react from "@vitejs/plugin-react";
 
-export default defineConfig({
-  plugins: [react()],
-  server: {
-    port: 5173
-  }
-});
+export default defineConfig(({ mode }) => {
+  const env = loadEnv(mode, process.cwd(), "");
+  const apiTarget = env.LEDGER_API_URL || "http://localhost:4000";
 
+  return {
+    plugins: [react()],
+    server: {
+      port: 5173,
+      proxy: {
+        "/api": {
+          target: apiTarget,
+          changeOrigin: true
+        },
+        "/health": {
+          target: apiTarget,
+          changeOrigin: true
+        },
+        "/mcp": {
+          target: apiTarget,
+          changeOrigin: true
+        }
+      }
+    }
+  };
+});