diff --git a/StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs b/StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs
new file mode 100644
index 000000000..c302c1e4b
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs
@@ -0,0 +1,251 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using ColorTextBlock.Avalonia;
+using Markdown.Avalonia;
+using StabilityMatrix.Core.Models.Documentation;
+
+namespace StabilityMatrix.Avalonia.Controls;
+
+///
+/// A that routes hyperlink clicks through a
+/// bindable (so relative .md links can navigate in-app
+/// and external links can open in the browser) and resolves relative image paths against
+/// via the engine's asset path root.
+///
+public class DocumentationMarkdownViewer : BetterMarkdownScrollViewer
+{
+ ///
+ /// Command invoked when a hyperlink is clicked. The command parameter is the raw href string.
+ ///
+ public static readonly StyledProperty LinkCommandProperty = AvaloniaProperty.Register<
+ DocumentationMarkdownViewer,
+ ICommand?
+ >(nameof(LinkCommand));
+
+ ///
+ /// Base URL used to resolve relative image paths in the rendered markdown
+ /// (e.g. the raw URL of the current page's folder).
+ ///
+ public static readonly StyledProperty ImageBaseUrlProperty = AvaloniaProperty.Register<
+ DocumentationMarkdownViewer,
+ string?
+ >(nameof(ImageBaseUrl));
+
+ public ICommand? LinkCommand
+ {
+ get => GetValue(LinkCommandProperty);
+ set => SetValue(LinkCommandProperty, value);
+ }
+
+ ///
+ /// Zoom factor applied to the rendered document content (1.0 = 100%).
+ /// Scales the content inside the internal scroll viewer, so the scrollbar is unaffected.
+ ///
+ public static readonly StyledProperty ContentZoomProperty = AvaloniaProperty.Register<
+ DocumentationMarkdownViewer,
+ double
+ >(nameof(ContentZoom), 1.0);
+
+ public string? ImageBaseUrl
+ {
+ get => GetValue(ImageBaseUrlProperty);
+ set => SetValue(ImageBaseUrlProperty, value);
+ }
+
+ public double ContentZoom
+ {
+ get => GetValue(ContentZoomProperty);
+ set => SetValue(ContentZoomProperty, value);
+ }
+
+ ///
+ /// Hosts the document content inside the internal scroll viewer so zoom can scale the
+ /// content without scaling the scrollbar. Null if the base control's composition changes.
+ ///
+ private readonly LayoutTransformControl? zoomHost;
+
+ public DocumentationMarkdownViewer()
+ {
+ ApplyLinkCommand();
+ ApplyImageBaseUrl();
+
+ // The base ctor composes a non-templated inner ScrollViewer (a direct visual child)
+ // whose Content is the document wrapper, and never reassigns Content afterwards
+ // (page changes only swap the wrapper's Document). Re-parent the wrapper into a
+ // LayoutTransformControl so zoom scales the document but not the scrollbar, and add
+ // right margin so the overlay scrollbar doesn't cover the rightmost text.
+ if (this.GetVisualChildren().OfType().FirstOrDefault() is { } innerViewer)
+ {
+ if (innerViewer.Content is Control content)
+ {
+ innerViewer.Content = null;
+ zoomHost = new LayoutTransformControl
+ {
+ Child = content,
+ Margin = new Thickness(0, 0, 18, 0),
+ };
+ innerViewer.Content = zoomHost;
+ }
+ else
+ {
+ // Fallback: at least keep the scrollbar off the content.
+ innerViewer.Padding = new Thickness(0, 0, 18, 0);
+ }
+ }
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == LinkCommandProperty)
+ {
+ ApplyLinkCommand();
+ }
+ else if (change.Property == ImageBaseUrlProperty)
+ {
+ ApplyImageBaseUrl();
+ }
+ else if (change.Property == ContentZoomProperty)
+ {
+ ApplyContentZoom();
+ }
+ }
+
+ private void ApplyContentZoom()
+ {
+ if (zoomHost is null)
+ return;
+
+ // Guard against zero/negative values from bad bindings.
+ var zoom = Math.Clamp(ContentZoom, 0.25, 4.0);
+ zoomHost.LayoutTransform = new ScaleTransform(zoom, zoom);
+ }
+
+ private void ApplyLinkCommand()
+ {
+ // The engine owns the HyperlinkCommand used for all rendered links. The Engine getter
+ // always returns an IMarkdownEngine2 (custom IMarkdownEngine values are upgraded to a
+ // wrapper that only implements IMarkdownEngine2), so match on that interface.
+ if (Engine is IMarkdownEngine2 engine)
+ {
+ engine.HyperlinkCommand = LinkCommand;
+ }
+ }
+
+ private void ApplyImageBaseUrl()
+ {
+ // AssetPathRoot flows through to the engine's bitmap loader so relative image
+ // paths resolve against the raw docs URL.
+ AssetPathRoot = ImageBaseUrl ?? string.Empty;
+ }
+
+ private static readonly string[] HeadingClasses =
+ [
+ "Heading1",
+ "Heading2",
+ "Heading3",
+ "Heading4",
+ "Heading5",
+ "Heading6",
+ ];
+
+ ///
+ /// Scrolls the rendered content so the heading matching the given GitHub-style anchor slug
+ /// is brought to the top of the viewport.
+ ///
+ /// The bare heading slug (no leading #).
+ /// true if a matching heading was found at call time; otherwise false.
+ public bool ScrollToAnchor(string anchor)
+ {
+ if (string.IsNullOrWhiteSpace(anchor))
+ return false;
+
+ var slug = DocumentationPathResolver.Slugify(anchor);
+ if (slug.Length == 0)
+ return false;
+
+ // Content is built synchronously when Markdown changes, but layout/measure (needed for
+ // TranslatePoint) only runs on the next layout pass — defer the actual scroll.
+ var found = FindHeadingBySlug(slug) is not null;
+
+ Dispatcher.UIThread.Post(
+ () =>
+ {
+ var target = FindHeadingBySlug(slug);
+ if (target is not null)
+ ScrollHeadingIntoView(target);
+ },
+ DispatcherPriority.Background
+ );
+
+ return found;
+ }
+
+ ///
+ /// Locates the heading control whose slug matches, applying GitHub-style duplicate suffixes
+ /// (-1, -2, ...) in document order.
+ ///
+ private CTextBlock? FindHeadingBySlug(string slug)
+ {
+ var seen = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var descendant in this.GetVisualDescendants())
+ {
+ if (descendant is not CTextBlock textBlock || !IsHeading(textBlock))
+ continue;
+
+ var baseSlug = DocumentationPathResolver.Slugify(textBlock.Text ?? string.Empty);
+ if (baseSlug.Length == 0)
+ continue;
+
+ string effectiveSlug;
+ if (seen.TryGetValue(baseSlug, out var count))
+ {
+ effectiveSlug = $"{baseSlug}-{count}";
+ seen[baseSlug] = count + 1;
+ }
+ else
+ {
+ effectiveSlug = baseSlug;
+ seen[baseSlug] = 1;
+ }
+
+ if (string.Equals(effectiveSlug, slug, StringComparison.Ordinal))
+ return textBlock;
+ }
+
+ return null;
+ }
+
+ private static bool IsHeading(StyledElement control)
+ {
+ foreach (var cls in HeadingClasses)
+ {
+ if (control.Classes.Contains(cls))
+ return true;
+ }
+
+ return false;
+ }
+
+ private void ScrollHeadingIntoView(Visual heading)
+ {
+ // Position of the heading relative to this control's viewport, plus the current scroll
+ // offset, gives the heading's Y within the scrollable content.
+ var current = ScrollValue;
+ var point = heading.TranslatePoint(new Point(0, 0), this);
+ if (point is null)
+ return;
+
+ var targetY = Math.Max(0, point.Value.Y + current.Y);
+ ScrollValue = new Vector(current.X, targetY);
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationNavNode.cs b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationNavNode.cs
new file mode 100644
index 000000000..2cd6c6269
--- /dev/null
+++ b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationNavNode.cs
@@ -0,0 +1,14 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace StabilityMatrix.Avalonia.ViewModels.Documentation;
+
+///
+/// Base for nodes shown in the documentation navigation tree (sections and pages).
+/// Exposes the expansion state consumed by the TreeView's TreeViewItem style binding.
+///
+public abstract partial class DocumentationNavNode : ObservableObject
+{
+ /// Whether the corresponding TreeViewItem is expanded.
+ [ObservableProperty]
+ public partial bool IsExpanded { get; set; }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationPageNavItem.cs b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationPageNavItem.cs
new file mode 100644
index 000000000..490f7e22c
--- /dev/null
+++ b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationPageNavItem.cs
@@ -0,0 +1,13 @@
+namespace StabilityMatrix.Avalonia.ViewModels.Documentation;
+
+///
+/// A single navigable documentation page entry (leaf) in the sidebar tree.
+///
+public partial class DocumentationPageNavItem : DocumentationNavNode
+{
+ /// Display title, e.g. "Overview".
+ public required string Title { get; init; }
+
+ /// Path relative to the docs root, e.g. getting-started/overview.md.
+ public required string Path { get; init; }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationSectionNavItem.cs b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationSectionNavItem.cs
new file mode 100644
index 000000000..498445671
--- /dev/null
+++ b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationSectionNavItem.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+
+namespace StabilityMatrix.Avalonia.ViewModels.Documentation;
+
+///
+/// A section grouping in the documentation sidebar (e.g. "Getting Started").
+///
+public partial class DocumentationSectionNavItem : DocumentationNavNode
+{
+ public DocumentationSectionNavItem()
+ {
+ // Sections are expanded by default.
+ IsExpanded = true;
+ }
+
+ /// Section title. Empty for the root section (renders without a header).
+ public required string Title { get; init; }
+
+ /// Whether this section has a visible header (i.e. is not the root section).
+ public bool HasHeader => !string.IsNullOrEmpty(Title);
+
+ /// Pages within this section.
+ public required IReadOnlyList Pages { get; init; }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs
new file mode 100644
index 000000000..7efcc2f7e
--- /dev/null
+++ b/StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs
@@ -0,0 +1,369 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using AsyncAwaitBestPractices;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using FluentAvalonia.UI.Controls;
+using FluentIcons.Common;
+using Injectio.Attributes;
+using Microsoft.Extensions.Logging;
+using StabilityMatrix.Avalonia.ViewModels.Base;
+using StabilityMatrix.Avalonia.ViewModels.Documentation;
+using StabilityMatrix.Core.Attributes;
+using StabilityMatrix.Core.Models.Documentation;
+using StabilityMatrix.Core.Processes;
+using StabilityMatrix.Core.Services;
+using Symbol = FluentIcons.Common.Symbol;
+using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource;
+
+namespace StabilityMatrix.Avalonia.ViewModels;
+
+[View(typeof(Views.DocumentationPage))]
+[RegisterSingleton]
+public partial class DocumentationViewModel : PageViewModelBase
+{
+ private const double MinZoom = 0.5;
+ private const double MaxZoom = 2.0;
+ private const double ZoomStep = 0.1;
+
+ private readonly ILogger logger;
+ private readonly IDocumentationService documentationService;
+ private readonly ISettingsManager settingsManager;
+
+ public override string Title => "Documentation";
+
+ public override IconSource IconSource =>
+ new SymbolIconSource { Symbol = Symbol.BookOpen, IconVariant = IconVariant.Filled };
+
+ public ObservableCollection Sections { get; } = [];
+
+ ///
+ /// Flattened source for the navigation TreeView: root-level pages are hoisted to
+ /// top-level leaves and each non-root section is a parent node. Items are either
+ /// or .
+ ///
+ public ObservableCollection