public
anord
read
Ledger
Why work hard when you can work easier?
Languages
Repository composition by tracked source files.
TypeScript
86%
CSS
10%
SQL
3%
Shell
1%
HTML
0%
Commit
Continues UI fixes and fixing Docs
39c4a7e
.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>> 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
+ }
+ }
+ }
+ };
+});