diff --git a/InterlinedList/ViewModels/PoweredDocumentPanelViewModel.cs b/InterlinedList/ViewModels/PoweredDocumentPanelViewModel.cs
new file mode 100644
index 0000000..00ce02f
--- /dev/null
+++ b/InterlinedList/ViewModels/PoweredDocumentPanelViewModel.cs
@@ -0,0 +1,526 @@
+using System.Collections.ObjectModel;
+using System.Windows.Input;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+/// One source a derived Powered Document mode can point at.
+public sealed record AiDocumentSource(string Id, string Title)
+{
+ public override string ToString() => Title;
+}
+
+/// One entry in the mode picker.
+public sealed record AiDocumentModeOption(AiDocumentMode Mode, string Label, string Hint);
+
+///
+/// Powered Document: draft a full markdown document, in one of four modes.
+///
+/// You pass a reference, not source text.from_list sends a
+/// listId, from_article a documentId, research_url a
+/// URL — and the server loads (or fetches) and truncates the source itself.
+/// Those three are IDOR-guarded: a reference that's missing or isn't yours comes
+/// back 422 invalid_input. Verified live, all four for free (input
+/// validation costs no quota):
+///
+/// from_list with a non-owned id → {"error":"List not found.","code":"invalid_input"}
+/// from_list with no id → {"error":"A list must be selected.","code":"invalid_input"}
+/// from_article with a non-owned id → {"error":"Document not found.","code":"invalid_input"}
+/// research_url with ftp:// → {"error":"Only http(s) URLs are supported.","code":"invalid_input"}
+///
+/// Those all land on — next to the offending picker —
+/// rather than in the generic notice slot, which is #15's last acceptance
+/// criterion.
+///
+/// One live finding shapes the mode picker. An unrecognized
+/// context.mode is not cheap-rejected: {"mode":"not_a_mode"} fell
+/// through to the model and came back 422 invalid_ai_output — billed
+/// a quota unit. So the mode is never a free string here; it's the
+/// four-value enum, always, and the picker can only
+/// ever produce one of them.
+///
+public sealed partial class PoweredDocumentPanelViewModel : AiPanelViewModelBase
+{
+ private bool _sourcesLoaded;
+
+ public PoweredDocumentPanelViewModel(SessionService session, AiAvailabilityService? availability = null)
+ : base(session, availability)
+ {
+ Modes = new[]
+ {
+ new AiDocumentModeOption(AiDocumentMode.Article, "From a topic", "Drafts from your description alone."),
+ new AiDocumentModeOption(AiDocumentMode.FromList, "From one of my lists", "Reads the list's schema and up to 50 rows."),
+ new AiDocumentModeOption(AiDocumentMode.FromArticle, "From one of my documents", "Reads that document's title and body."),
+ new AiDocumentModeOption(AiDocumentMode.ResearchUrl, "From a URL", "Fetches the page and cites it.")
+ };
+
+ selectedMode = Modes[0];
+ }
+
+ /// Set by the hosting control from its OpenDocumentCommand dependency property.
+ public ICommand? OpenDocumentCommand { get; set; }
+
+ /// Set by the hosting control from its RefreshCommand dependency property.
+ public ICommand? RefreshCommand { get; set; }
+
+ // ── Mode ────────────────────────────────────────────────────────────────────
+
+ public IReadOnlyList Modes { get; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsListMode))]
+ [NotifyPropertyChangedFor(nameof(IsArticleSourceMode))]
+ [NotifyPropertyChangedFor(nameof(IsUrlMode))]
+ [NotifyPropertyChangedFor(nameof(NeedsSource))]
+ [NotifyPropertyChangedFor(nameof(ModeHint))]
+ private AiDocumentModeOption selectedMode;
+
+ public bool IsListMode => SelectedMode.Mode == AiDocumentMode.FromList;
+ public bool IsArticleSourceMode => SelectedMode.Mode == AiDocumentMode.FromArticle;
+ public bool IsUrlMode => SelectedMode.Mode == AiDocumentMode.ResearchUrl;
+
+ /// True for the three derived modes, which all need a reference.
+ public bool NeedsSource => SelectedMode.Mode != AiDocumentMode.Article;
+
+ public string ModeHint => SelectedMode.Hint;
+
+ partial void OnSelectedModeChanged(AiDocumentModeOption value)
+ {
+ SourceError = null;
+ SuggestCommand.NotifyCanExecuteChanged();
+
+ // Load the pickers' contents only once a mode actually needs them —
+ // two GETs nobody asked for is the wrong default on panel construction.
+ if (NeedsSource) _ = LoadSourcesAsync();
+ }
+
+ // ── Sources ─────────────────────────────────────────────────────────────────
+
+ public ObservableCollection MyLists { get; } = new();
+
+ public ObservableCollection MyDocuments { get; } = new();
+
+ [ObservableProperty]
+ private AiDocumentSource? selectedList;
+
+ [ObservableProperty]
+ private AiDocumentSource? selectedDocument;
+
+ [ObservableProperty]
+ private string sourceUrl = "";
+
+ ///
+ /// The message shown against the source picker, not in the generic
+ /// notice slot. Both the client's own pre-flight and a server-side
+ /// reference rejection land here.
+ ///
+ [ObservableProperty]
+ private string? sourceError;
+
+ [ObservableProperty]
+ private bool isLoadingSources;
+
+ partial void OnSelectedListChanged(AiDocumentSource? value)
+ {
+ SourceError = null;
+ SuggestCommand.NotifyCanExecuteChanged();
+ }
+
+ partial void OnSelectedDocumentChanged(AiDocumentSource? value)
+ {
+ SourceError = null;
+ SuggestCommand.NotifyCanExecuteChanged();
+ }
+
+ partial void OnSourceUrlChanged(string value)
+ {
+ SourceError = null;
+ SuggestCommand.NotifyCanExecuteChanged();
+ }
+
+ private async Task LoadSourcesAsync()
+ {
+ if (_sourcesLoaded) return;
+
+ IsLoadingSources = true;
+ try
+ {
+ var lists = await Session.Api.GetListsAsync(limit: 100, offset: 0);
+ MyLists.Clear();
+ foreach (var list in lists.Lists)
+ MyLists.Add(new AiDocumentSource(list.Id, list.Title));
+
+ // Root documents plus everything filed in a folder — a document a
+ // user tidied away is still a legitimate source, and the folder
+ // entries carry their own ids.
+ var documents = await Session.Api.GetRootDocumentsAsync();
+ var folders = await Session.Api.GetDocumentFoldersAsync();
+
+ MyDocuments.Clear();
+ foreach (var doc in documents.Documents)
+ MyDocuments.Add(new AiDocumentSource(doc.Id, doc.Title));
+
+ foreach (var folder in folders.Folders)
+ foreach (var entry in folder.Documents)
+ MyDocuments.Add(new AiDocumentSource(entry.Id, $"{folder.Name} / {entry.Title}"));
+
+ _sourcesLoaded = true;
+ }
+ catch (InterlinedApiException ex)
+ {
+ SourceError = ex.Message;
+ }
+ catch (Exception ex)
+ {
+ SourceError = "Couldn't load your lists and documents.";
+ AppLog.Warn($"Powered Document source load failed: {ex.Message}");
+ }
+ finally
+ {
+ IsLoadingSources = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task ReloadSourcesAsync()
+ {
+ _sourcesLoaded = false;
+ await LoadSourcesAsync();
+ }
+
+ // ── Input ───────────────────────────────────────────────────────────────────
+
+ /// Collapsed by default — an option in the Documents flow, not the default path.
+ [ObservableProperty]
+ private bool isOpen;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(WordCount))]
+ [NotifyPropertyChangedFor(nameof(WordCountLabel))]
+ [NotifyPropertyChangedFor(nameof(IsOverWordCap))]
+ private string topic = "";
+
+ public int WordCount => AiFeatureLimits.CountWords(Topic);
+
+ public int MaxWords => AiFeatureLimits.For(AiFeature.PoweredDocument).MaxInputWords;
+
+ public string WordCountLabel => $"{WordCount} / {MaxWords} words";
+
+ public bool IsOverWordCap => WordCount > MaxWords;
+
+ partial void OnTopicChanged(string value) => SuggestCommand.NotifyCanExecuteChanged();
+
+ // ── Preview ─────────────────────────────────────────────────────────────────
+
+ [ObservableProperty]
+ private bool hasPreview;
+
+ [ObservableProperty]
+ private string draftTitle = "";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CharacterCountLabel))]
+ [NotifyPropertyChangedFor(nameof(IsOverCharacterCap))]
+ private string draftMarkdown = "";
+
+ /// Server-side ceiling on a generated document.
+ public int MaxCharacters => AiFeatureLimits.MaxDocumentCharacters;
+
+ public string CharacterCountLabel => $"{DraftMarkdown.Length:N0} / {MaxCharacters:N0} characters";
+
+ public bool IsOverCharacterCap => DraftMarkdown.Length > MaxCharacters;
+
+ /// The model's section plan, shown as read-only context for the draft.
+ public ObservableCollection DraftOutline { get; } = new();
+
+ public bool HasOutline => DraftOutline.Count > 0;
+
+ /// Whether the created document should be publicly readable. The artifact carries this.
+ [ObservableProperty]
+ private bool draftIsPublic;
+
+ ///
+ /// Toggles the preview between the rendered document and the editable
+ /// markdown source. The source is the single point of truth — the rendered
+ /// view is a projection of it, so edits can't be lost to the toggle.
+ ///
+ [ObservableProperty]
+ private bool showMarkdownSource;
+
+ [ObservableProperty]
+ private string? usageLabel;
+
+ // ── Commands ────────────────────────────────────────────────────────────────
+
+ protected override void NotifyAiCommandsChanged()
+ {
+ SuggestCommand.NotifyCanExecuteChanged();
+ ConfirmCommand.NotifyCanExecuteChanged();
+ }
+
+ [RelayCommand]
+ private void ToggleOpen()
+ {
+ IsOpen = !IsOpen;
+ if (IsOpen) _ = EnsureStatusLoadedAsync();
+ }
+
+ private bool CanSuggest()
+ {
+ if (!CanRunAi || string.IsNullOrWhiteSpace(Topic)) return false;
+
+ return SelectedMode.Mode switch
+ {
+ AiDocumentMode.FromList => SelectedList is not null,
+ AiDocumentMode.FromArticle => SelectedDocument is not null,
+ AiDocumentMode.ResearchUrl => !string.IsNullOrWhiteSpace(SourceUrl),
+ _ => true
+ };
+ }
+
+ [RelayCommand(CanExecute = nameof(CanSuggest))]
+ private async Task SuggestAsync(CancellationToken ct)
+ {
+ SourceError = null;
+
+ if (!TryBuildContext(out var context, out var sourceError))
+ {
+ // Against the field, not in the generic slot.
+ SourceError = sourceError;
+ return;
+ }
+
+ if (!AiFeatureLimits.TryValidateInput(AiFeature.PoweredDocument, Topic, out var inputError))
+ {
+ RejectLocally(inputError!);
+ return;
+ }
+
+ var mode = SelectedMode.Mode;
+
+ var result = await RunAiAsync(
+ "Drafting your document…",
+ token => Session.Api.AiSuggestAsync(AiFeature.PoweredDocument, Topic.Trim(), context, ct: token),
+ r => r.Quota,
+ ct);
+
+ if (result is null)
+ {
+ AttributeFailureToSource(mode);
+ return;
+ }
+
+ if (result.Artifact.AsDocument() is not { } document)
+ {
+ Notice = new AiNotice(AiNoticeKind.ModelDeclined,
+ $"Expected a document but the AI returned a \"{result.Artifact.Kind}\". " +
+ "That attempt still counted against today's allowance — try rewording the topic.",
+ Spent: true);
+ return;
+ }
+
+ DraftTitle = document.Title;
+ DraftMarkdown = document.Markdown;
+ DraftIsPublic = document.IsPublic;
+
+ DraftOutline.Clear();
+ foreach (var section in document.Outline)
+ DraftOutline.Add(section);
+ OnPropertyChanged(nameof(HasOutline));
+
+ ShowMarkdownSource = false;
+ HasPreview = true;
+ UsageLabel = result.Usage?.Display;
+ Notice = AiNotice.Info("Draft ready. Edit it below, then save it as a document.");
+ ConfirmCommand.NotifyCanExecuteChanged();
+ }
+
+ ///
+ /// Move a reference rejection out of the generic notice and onto the source
+ /// field, per #15.
+ ///
+ /// Why mode + code is enough, without reading the server's prose.
+ /// Every client-knowable input problem — the 500-word cap, an empty topic,
+ /// a missing reference, a non-http scheme — is already rejected locally
+ /// before the call goes out. So an invalid_input that comes back from
+ /// the server in a derived mode can only be the one thing the client
+ /// cannot check: the reference doesn't exist, or it isn't this user's. That
+ /// inference is what lets this branch on the code alone (the repo's rule)
+ /// while still showing the server's own wording, which is the specific and
+ /// useful part.
+ ///
+ private void AttributeFailureToSource(AiDocumentMode mode)
+ {
+ if (mode == AiDocumentMode.Article) return;
+ if (Notice is not { Kind: AiNoticeKind.Input } notice) return;
+
+ SourceError = mode switch
+ {
+ AiDocumentMode.FromList => $"{notice.Text} Pick a different list.",
+ AiDocumentMode.FromArticle => $"{notice.Text} Pick a different document.",
+ AiDocumentMode.ResearchUrl => $"{notice.Text} Check the URL.",
+ _ => notice.Text
+ };
+
+ ClearNotice();
+ }
+
+ ///
+ /// Build context for the selected mode, running the checks the
+ /// server would answer 422 for. These cost no quota either way (verified),
+ /// but catching them locally also keeps the message on the right field.
+ ///
+ private bool TryBuildContext(out AiContext? context, out string? error)
+ {
+ context = null;
+ error = null;
+
+ switch (SelectedMode.Mode)
+ {
+ case AiDocumentMode.Article:
+ context = AiContext.DocumentFromTopic();
+ return true;
+
+ case AiDocumentMode.FromList:
+ if (SelectedList is null)
+ {
+ error = "Pick one of your lists to draft from.";
+ return false;
+ }
+ context = AiContext.DocumentFromList(SelectedList.Id);
+ break;
+
+ case AiDocumentMode.FromArticle:
+ if (SelectedDocument is null)
+ {
+ error = "Pick one of your documents to draft from.";
+ return false;
+ }
+ context = AiContext.DocumentFromArticle(SelectedDocument.Id);
+ break;
+
+ case AiDocumentMode.ResearchUrl:
+ var url = SourceUrl.Trim();
+ if (url.Length == 0)
+ {
+ error = "Enter the URL to research.";
+ return false;
+ }
+
+ // Client-side http/https check. The server enforces it too
+ // ("Only http(s) URLs are supported.", verified), but catching
+ // it here puts the message on the field and skips a round trip.
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)
+ || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
+ {
+ error = "Only http:// and https:// URLs are supported.";
+ return false;
+ }
+
+ context = AiContext.DocumentFromResearchUrl(uri.ToString());
+ break;
+
+ default:
+ error = "Pick how the document should be drafted.";
+ return false;
+ }
+
+ // Second belt: the service's own pre-flight for the same rules.
+ if (context is not null && !context.TryValidate(AiFeature.PoweredDocument, out var contextError))
+ {
+ error = contextError;
+ return false;
+ }
+
+ return true;
+ }
+
+ [RelayCommand]
+ private void Discard()
+ {
+ HasPreview = false;
+ DraftTitle = "";
+ DraftMarkdown = "";
+ DraftIsPublic = false;
+ DraftOutline.Clear();
+ OnPropertyChanged(nameof(HasOutline));
+ ShowMarkdownSource = false;
+ UsageLabel = null;
+ ClearNotice();
+ ConfirmCommand.NotifyCanExecuteChanged();
+ }
+
+ [RelayCommand]
+ private void ToggleSourceView() => ShowMarkdownSource = !ShowMarkdownSource;
+
+ private bool CanConfirm() => CanRunAi && HasPreview;
+
+ [RelayCommand(CanExecute = nameof(CanConfirm))]
+ private async Task ConfirmAsync(CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(DraftTitle))
+ {
+ RejectLocally("Give the document a title before saving it.");
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(DraftMarkdown))
+ {
+ RejectLocally("The document is empty — there's nothing to save.");
+ return;
+ }
+
+ // Mirrored server ceiling. /generate costs a unit, so an over-long
+ // document is rejected here rather than there.
+ if (DraftMarkdown.Length > MaxCharacters)
+ {
+ RejectLocally($"A document is capped at {MaxCharacters:N0} characters (this one is {DraftMarkdown.Length:N0}). Trim it before saving.");
+ return;
+ }
+
+ var artifact = AiArtifact.FromPayload(new AiDocumentArtifact
+ {
+ Title = DraftTitle.Trim(),
+ Markdown = DraftMarkdown,
+ Outline = DraftOutline.ToList(),
+ IsPublic = DraftIsPublic
+ });
+
+ var result = await RunAiAsync(
+ "Saving your document…",
+ token => Session.Api.AiGenerateAsync(AiFeature.PoweredDocument, artifact, ct: token),
+ r => r.Quota,
+ ct);
+
+ if (result is null) return;
+
+ // /generate's envelope is contract-transcribed, not observed, so the id
+ // is a hint — prove the document exists before handing it to the host.
+ if (result.Created.DocumentId is { Length: > 0 } documentId)
+ {
+ var created = await RunApiAsync("Loading the new document…", token => Session.Api.GetDocumentAsync(documentId, token), ct);
+
+ RefreshCommand?.Execute(null);
+
+ if (created is not null)
+ {
+ Notice = AiNotice.Info($"Saved \"{created.Title}\".");
+ Discard();
+ IsOpen = false;
+ OpenDocumentCommand?.Execute(created);
+ return;
+ }
+
+ Notice = AiNotice.Info("The document was saved, but loading it back failed. It's in the document list.");
+ Discard();
+ return;
+ }
+
+ RefreshCommand?.Execute(null);
+ Notice = new AiNotice(AiNoticeKind.Error,
+ "The server accepted the document but didn't return its id. Check the document list before trying again — a second attempt would create a duplicate and spend another AI credit.");
+ }
+}
diff --git a/InterlinedList/Views/DocumentsView.xaml b/InterlinedList/Views/DocumentsView.xaml
index 7f8347d..259fa40 100644
--- a/InterlinedList/Views/DocumentsView.xaml
+++ b/InterlinedList/Views/DocumentsView.xaml
@@ -394,6 +394,9 @@
+
+
+
+/// A small markdown → renderer, so #15's "rendered
+/// markdown preview" is actually rendered rather than a wall of hashes and
+/// asterisks.
+///
+/// Why hand-rolled. The app has two NuGet references and no markdown
+/// library, and the artifact it has to display is a narrow, known subset:
+/// powered_document returns model-written prose with ATX headings, lists
+/// and light inline emphasis (verified live — see
+/// ). Pulling in a
+/// CommonMark implementation to render six constructs would add a dependency to
+/// a csproj several other open PRs are also touching. This covers what the
+/// server actually emits and degrades to plain paragraphs for anything else,
+/// which is the right failure mode for a preview.
+///
+/// Everything it draws uses the existing Strata theme brushes looked up
+/// dynamically, so it follows light/dark like the rest of the app and
+/// introduces no colour of its own.
+///
+/// Supported: ATX headings (#…######), fenced code blocks,
+/// unordered (-/*/+) and ordered lists, GFM pipe tables,
+/// blockquotes, horizontal rules, paragraphs, and inline **bold**,
+/// *italic*, `code` and [text](url). Anything else renders
+/// as literal text.
+///
+/// Pipe tables are here because the server emits them. The live
+/// powered_document probe came back with a GFM table
+/// (| Date | Platform | Topic / Asset | Owner |) in the middle of the
+/// draft — without table support those three lines would have collapsed into
+/// one run-together paragraph of pipes and dashes, which is exactly the kind of
+/// mangling a preview exists to prevent. Constructs the real payload used:
+/// h1, h2, bullet lists, paragraphs, *italic*, and that table.
+///
+internal static class MarkdownRenderer
+{
+ private const string MonoFonts = "JetBrains Mono, Consolas";
+
+ public static FlowDocument Render(string? markdown)
+ {
+ var document = new FlowDocument
+ {
+ PagePadding = new Thickness(0),
+ FontSize = 12.5,
+ LineHeight = 19,
+ Foreground = Brush("TextBodyBrush", Colors.Black)
+ };
+
+ if (string.IsNullOrWhiteSpace(markdown))
+ return document;
+
+ var lines = markdown.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
+ var paragraph = new List();
+ var index = 0;
+
+ void FlushParagraph()
+ {
+ if (paragraph.Count == 0) return;
+ document.Blocks.Add(Body(string.Join(" ", paragraph)));
+ paragraph.Clear();
+ }
+
+ while (index < lines.Length)
+ {
+ var line = lines[index];
+ var trimmed = line.TrimStart();
+
+ // ── Fenced code block ───────────────────────────────────────────
+ if (MarkdownSyntax.IsCodeFence(trimmed))
+ {
+ FlushParagraph();
+ index++;
+ var code = new StringBuilder();
+ while (index < lines.Length && !MarkdownSyntax.IsCodeFence(lines[index].TrimStart()))
+ {
+ code.AppendLine(lines[index]);
+ index++;
+ }
+ index++; // closing fence (or end of input)
+ document.Blocks.Add(CodeBlock(code.ToString().TrimEnd()));
+ continue;
+ }
+
+ // ── Blank line ──────────────────────────────────────────────────
+ if (trimmed.Length == 0)
+ {
+ FlushParagraph();
+ index++;
+ continue;
+ }
+
+ // ── Horizontal rule ─────────────────────────────────────────────
+ if (MarkdownSyntax.IsHorizontalRule(trimmed))
+ {
+ FlushParagraph();
+ document.Blocks.Add(HorizontalRule());
+ index++;
+ continue;
+ }
+
+ // ── Heading ─────────────────────────────────────────────────────
+ if (MarkdownSyntax.TryReadHeading(trimmed, out var headingLevel, out var headingText))
+ {
+ FlushParagraph();
+ document.Blocks.Add(Heading(headingText, headingLevel));
+ index++;
+ continue;
+ }
+
+ // ── GFM pipe table ──────────────────────────────────────────────
+ // Needs a header row AND a |---|---| separator underneath, so a
+ // paragraph that merely contains a pipe isn't mistaken for one.
+ if (MarkdownSyntax.IsTableHeader(trimmed, index + 1 < lines.Length ? lines[index + 1] : null))
+ {
+ FlushParagraph();
+
+ var header = MarkdownSyntax.SplitTableRow(trimmed);
+ index += 2; // header + separator
+
+ var body = new List>();
+ while (index < lines.Length)
+ {
+ var rowLine = lines[index].TrimStart();
+ if (rowLine.Length == 0 || !rowLine.Contains('|')) break;
+ body.Add(MarkdownSyntax.SplitTableRow(rowLine));
+ index++;
+ }
+
+ document.Blocks.Add(TableBlock(header, body));
+ continue;
+ }
+
+ // ── Blockquote ──────────────────────────────────────────────────
+ if (trimmed[0] == '>')
+ {
+ FlushParagraph();
+ var quote = new List();
+ while (index < lines.Length && lines[index].TrimStart().StartsWith('>'))
+ {
+ quote.Add(lines[index].TrimStart().TrimStart('>').Trim());
+ index++;
+ }
+ document.Blocks.Add(BlockQuote(string.Join(" ", quote)));
+ continue;
+ }
+
+ // ── List ────────────────────────────────────────────────────────
+ if (MarkdownSyntax.TryReadListMarker(trimmed, out var ordered, out _))
+ {
+ FlushParagraph();
+ var list = new List
+ {
+ MarkerStyle = ordered ? TextMarkerStyle.Decimal : TextMarkerStyle.Disc,
+ Margin = new Thickness(16, 4, 0, 8),
+ Padding = new Thickness(0)
+ };
+
+ while (index < lines.Length)
+ {
+ var itemLine = lines[index].TrimStart();
+ if (itemLine.Length == 0) break;
+ if (!MarkdownSyntax.TryReadListMarker(itemLine, out var itemOrdered, out var content)) break;
+ if (itemOrdered != ordered) break;
+
+ list.ListItems.Add(new ListItem(Body(content, bottomMargin: 2)));
+ index++;
+ }
+
+ document.Blocks.Add(list);
+ continue;
+ }
+
+ // ── Ordinary paragraph line ─────────────────────────────────────
+ paragraph.Add(trimmed);
+ index++;
+ }
+
+ FlushParagraph();
+ return document;
+ }
+
+ // ── Block builders ──────────────────────────────────────────────────────────
+
+ private static Paragraph Heading(string text, int level)
+ {
+ // Sizes stay on the app's existing type scale; no new tokens.
+ var size = level switch { 1 => 19.0, 2 => 16.0, 3 => 14.0, 4 => 13.0, _ => 12.5 };
+
+ var paragraph = new Paragraph
+ {
+ FontSize = size,
+ FontWeight = FontWeights.SemiBold,
+ Foreground = Brush("TextBrush", Colors.Black),
+ Margin = new Thickness(0, level == 1 ? 0 : 12, 0, 6)
+ };
+
+ AppendInline(paragraph.Inlines, text);
+ return paragraph;
+ }
+
+ private static Paragraph Body(string text, double bottomMargin = 8)
+ {
+ var paragraph = new Paragraph { Margin = new Thickness(0, 0, 0, bottomMargin) };
+ AppendInline(paragraph.Inlines, text);
+ return paragraph;
+ }
+
+ private static Section BlockQuote(string text)
+ {
+ var body = Body(text, bottomMargin: 0);
+ body.Foreground = Brush("TextMutedBrush", Colors.Gray);
+
+ // 3px amber edge: the brand's live/AI accent, same as the notice bar.
+ return new Section(body)
+ {
+ BorderBrush = Brush("AmberBrush", Color.FromRgb(0xF0, 0xA8, 0x30)),
+ BorderThickness = new Thickness(3, 0, 0, 0),
+ Padding = new Thickness(10, 4, 0, 4),
+ Margin = new Thickness(0, 4, 0, 8)
+ };
+ }
+
+ private static Section CodeBlock(string code)
+ {
+ var paragraph = new Paragraph(new Run(code))
+ {
+ FontFamily = new FontFamily(MonoFonts),
+ FontSize = 11,
+ Margin = new Thickness(0)
+ };
+
+ return new Section(paragraph)
+ {
+ Background = Brush("Surface3Brush", Color.FromRgb(0xF1, 0xEA, 0xDD)),
+ Padding = new Thickness(10, 8, 10, 8),
+ Margin = new Thickness(0, 4, 0, 8)
+ };
+ }
+
+ ///
+ /// A GFM pipe table. Columns are sized off the widest row rather than the
+ /// header alone, so a header with fewer cells than a body row doesn't drop
+ /// data — malformed tables are common in generated markdown.
+ ///
+ private static Table TableBlock(List header, List> body)
+ {
+ var columns = Math.Max(header.Count, body.Count == 0 ? 0 : body.Max(row => row.Count));
+
+ var table = new Table
+ {
+ CellSpacing = 0,
+ Margin = new Thickness(0, 4, 0, 10),
+ BorderBrush = Brush("BorderBrush", Colors.Gray),
+ BorderThickness = new Thickness(1, 1, 0, 0)
+ };
+
+ for (var i = 0; i < columns; i++)
+ table.Columns.Add(new TableColumn());
+
+ var group = new TableRowGroup();
+ table.RowGroups.Add(group);
+
+ group.Rows.Add(BuildTableRow(header, columns, isHeader: true));
+ foreach (var row in body)
+ group.Rows.Add(BuildTableRow(row, columns, isHeader: false));
+
+ return table;
+ }
+
+ private static TableRow BuildTableRow(List cells, int columns, bool isHeader)
+ {
+ var row = new TableRow();
+ if (isHeader) row.Background = Brush("Surface3Brush", Color.FromRgb(0xF1, 0xEA, 0xDD));
+
+ for (var i = 0; i < columns; i++)
+ {
+ var paragraph = new Paragraph { Margin = new Thickness(0), FontSize = 11 };
+ if (isHeader) paragraph.FontWeight = FontWeights.SemiBold;
+ AppendInline(paragraph.Inlines, i < cells.Count ? cells[i] : "");
+
+ row.Cells.Add(new TableCell(paragraph)
+ {
+ Padding = new Thickness(7, 4, 7, 4),
+ BorderBrush = Brush("BorderBrush", Colors.Gray),
+ BorderThickness = new Thickness(0, 0, 1, 1)
+ });
+ }
+
+ return row;
+ }
+
+ private static Paragraph HorizontalRule() => new()
+ {
+ BorderBrush = Brush("BorderStrongBrush", Colors.Gray),
+ BorderThickness = new Thickness(0, 0, 0, 1),
+ Margin = new Thickness(0, 8, 0, 12),
+ FontSize = 1
+ };
+
+ // ── Inline parsing ──────────────────────────────────────────────────────────
+
+ ///
+ /// Single left-to-right pass over **bold**, *italic*,
+ /// `code` and [text](url). An unclosed marker is emitted as
+ /// literal text rather than swallowing the rest of the line — a preview
+ /// should never lose the user's content to a parse failure.
+ ///
+ private static void AppendInline(InlineCollection inlines, string text)
+ {
+ var literal = new StringBuilder();
+ var i = 0;
+
+ void FlushLiteral()
+ {
+ if (literal.Length == 0) return;
+ inlines.Add(new Run(literal.ToString()));
+ literal.Clear();
+ }
+
+ while (i < text.Length)
+ {
+ // `code`
+ if (text[i] == '`')
+ {
+ var close = text.IndexOf('`', i + 1);
+ if (close > i)
+ {
+ FlushLiteral();
+ inlines.Add(new Run(text[(i + 1)..close])
+ {
+ FontFamily = new FontFamily(MonoFonts),
+ FontSize = 11.5
+ });
+ i = close + 1;
+ continue;
+ }
+ }
+
+ // **bold**
+ if (text[i] == '*' && i + 1 < text.Length && text[i + 1] == '*')
+ {
+ var close = text.IndexOf("**", i + 2, StringComparison.Ordinal);
+ if (close > i + 1)
+ {
+ FlushLiteral();
+ var bold = new Bold();
+ AppendInline(bold.Inlines, text[(i + 2)..close]);
+ inlines.Add(bold);
+ i = close + 2;
+ continue;
+ }
+ }
+
+ // *italic* / _italic_
+ if (text[i] is '*' or '_')
+ {
+ var marker = text[i];
+ var close = text.IndexOf(marker, i + 1);
+ if (close > i + 1)
+ {
+ FlushLiteral();
+ var italic = new Italic();
+ AppendInline(italic.Inlines, text[(i + 1)..close]);
+ inlines.Add(italic);
+ i = close + 1;
+ continue;
+ }
+ }
+
+ // [text](url) — styled, not navigable. A preview shouldn't be able
+ // to launch a browser from model-authored text on a stray click.
+ if (text[i] == '[')
+ {
+ var closeBracket = text.IndexOf(']', i + 1);
+ if (closeBracket > i
+ && closeBracket + 1 < text.Length
+ && text[closeBracket + 1] == '('
+ && text.IndexOf(')', closeBracket + 2) is var closeParen and > 0)
+ {
+ FlushLiteral();
+ var label = text[(i + 1)..closeBracket];
+ var url = text[(closeBracket + 2)..closeParen];
+
+ inlines.Add(new Run(label.Length > 0 ? label : url)
+ {
+ Foreground = Brush("LinkBrush", Colors.SteelBlue),
+ ToolTip = url
+ });
+ i = closeParen + 1;
+ continue;
+ }
+ }
+
+ literal.Append(text[i]);
+ i++;
+ }
+
+ FlushLiteral();
+ }
+
+ ///
+ /// Resolve a theme brush by resource key, with a literal fallback so the
+ /// renderer still works in a designer or a unit-test context where no
+ /// Application exists.
+ ///
+ private static Brush Brush(string key, Color fallback)
+ => Application.Current?.TryFindResource(key) as Brush ?? new SolidColorBrush(fallback);
+}
diff --git a/InterlinedList/Views/MarkdownSyntax.cs b/InterlinedList/Views/MarkdownSyntax.cs
new file mode 100644
index 0000000..aba17ea
--- /dev/null
+++ b/InterlinedList/Views/MarkdownSyntax.cs
@@ -0,0 +1,126 @@
+namespace InterlinedList.Views;
+
+///
+/// The line-level markdown predicates classifies
+/// with, split out from it deliberately: these are pure string functions with no
+/// dependency on WPF, so they can be exercised on any platform, whereas
+/// anything touching FlowDocument can only run on Windows. The
+/// classification rules are the part most likely to get a construct wrong, so
+/// they're the part worth being able to test.
+///
+internal static class MarkdownSyntax
+{
+ ///
+ /// An ATX heading: 1–6 # followed by a space. Returns false for
+ /// #hashtag (no space) and for 7+ hashes, matching CommonMark.
+ ///
+ public static bool TryReadHeading(string line, out int level, out string text)
+ {
+ level = 0;
+ text = "";
+
+ var hashes = 0;
+ while (hashes < line.Length && line[hashes] == '#') hashes++;
+
+ if (hashes is 0 or > 6) return false;
+ if (hashes >= line.Length || line[hashes] != ' ') return false;
+
+ level = hashes;
+ text = line[(hashes + 1)..].Trim();
+ return true;
+ }
+
+ ///
+ /// A list item marker. distinguishes
+ /// 1./1) from -/*/+ so a renderer can
+ /// pick the right marker style and not merge the two kinds into one list.
+ ///
+ public static bool TryReadListMarker(string line, out bool ordered, out string content)
+ {
+ ordered = false;
+ content = "";
+
+ if (line.Length < 2) return false;
+
+ if (line[0] is '-' or '*' or '+' && line[1] == ' ')
+ {
+ content = line[2..].Trim();
+ return true;
+ }
+
+ var digits = 0;
+ while (digits < line.Length && char.IsAsciiDigit(line[digits])) digits++;
+
+ if (digits > 0
+ && digits + 1 < line.Length
+ && line[digits] is '.' or ')'
+ && line[digits + 1] == ' ')
+ {
+ ordered = true;
+ content = line[(digits + 2)..].Trim();
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// A thematic break: three or more of the same -, * or
+ /// _, spaces allowed. Checked before the list rule, since
+ /// --- would otherwise never match and * * * would look like
+ /// a bullet.
+ ///
+ public static bool IsHorizontalRule(string line)
+ {
+ if (line.Length < 3) return false;
+
+ var marker = line[0];
+ if (marker is not ('-' or '*' or '_')) return false;
+
+ var count = 0;
+ foreach (var c in line)
+ {
+ if (c == marker) count++;
+ else if (c != ' ') return false;
+ }
+
+ return count >= 3;
+ }
+
+ ///
+ /// The |---|:---:|---:| row under a GFM table header. Requires a
+ /// pipe and a dash and nothing but pipes, dashes, colons and spaces — which
+ /// is what tells a real table apart from a paragraph containing a dash.
+ ///
+ public static bool IsTableSeparator(string line)
+ {
+ var trimmed = line.Trim();
+ if (!trimmed.Contains('|') || !trimmed.Contains('-')) return false;
+
+ foreach (var c in trimmed)
+ if (c is not ('|' or '-' or ':' or ' ')) return false;
+
+ return true;
+ }
+
+ ///
+ /// A table row is a header candidate only when it contains a pipe and the
+ /// next line is a separator. Taking both lines is what stops a paragraph
+ /// with a stray | becoming a one-column table.
+ ///
+ public static bool IsTableHeader(string line, string? nextLine)
+ => line.Contains('|') && nextLine is not null && IsTableSeparator(nextLine);
+
+ /// Split | a | b | c | into cells, dropping the outer pipes.
+ public static List SplitTableRow(string line)
+ {
+ var trimmed = line.Trim();
+ if (trimmed.StartsWith('|')) trimmed = trimmed[1..];
+ if (trimmed.EndsWith('|')) trimmed = trimmed[..^1];
+
+ return trimmed.Split('|').Select(cell => cell.Trim()).ToList();
+ }
+
+ /// A fenced code block delimiter (``` or longer).
+ public static bool IsCodeFence(string line) => line.StartsWith("```", StringComparison.Ordinal);
+}
diff --git a/InterlinedList/Views/MarkdownViewer.cs b/InterlinedList/Views/MarkdownViewer.cs
new file mode 100644
index 0000000..9e183ed
--- /dev/null
+++ b/InterlinedList/Views/MarkdownViewer.cs
@@ -0,0 +1,47 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Documents;
+
+namespace InterlinedList.Views;
+
+///
+/// A read-only rendered view of a markdown string. Bind
+/// and it re-renders on every change:
+///
+///
+/// <local:MarkdownViewer Markdown="{Binding DraftMarkdown}" MaxHeight="360"/>
+///
+///
+/// Code-only (no XAML partner file) because it's a
+/// with one dependency property and a
+/// re-render callback — a markup file would only add indirection.
+///
+/// stays off: the stock
+/// toolbar brings page/zoom controls styled nothing like Strata.
+///
+public sealed class MarkdownViewer : FlowDocumentScrollViewer
+{
+ public static readonly DependencyProperty MarkdownProperty =
+ DependencyProperty.Register(
+ nameof(Markdown), typeof(string), typeof(MarkdownViewer),
+ new PropertyMetadata(null, OnMarkdownChanged));
+
+ public MarkdownViewer()
+ {
+ IsToolBarVisible = false;
+ VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
+ Padding = new Thickness(0);
+ Document = MarkdownRenderer.Render(null);
+ }
+
+ /// The markdown source to render.
+ public string? Markdown
+ {
+ get => (string?)GetValue(MarkdownProperty);
+ set => SetValue(MarkdownProperty, value);
+ }
+
+ private static void OnMarkdownChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((MarkdownViewer)d).Document = MarkdownRenderer.Render(e.NewValue as string);
+}
diff --git a/InterlinedList/Views/PoweredDocumentPanel.xaml b/InterlinedList/Views/PoweredDocumentPanel.xaml
new file mode 100644
index 0000000..2f2c448
--- /dev/null
+++ b/InterlinedList/Views/PoweredDocumentPanel.xaml
@@ -0,0 +1,407 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/PoweredDocumentPanel.xaml.cs b/InterlinedList/Views/PoweredDocumentPanel.xaml.cs
new file mode 100644
index 0000000..857af79
--- /dev/null
+++ b/InterlinedList/Views/PoweredDocumentPanel.xaml.cs
@@ -0,0 +1,74 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using InterlinedList.Services;
+using InterlinedList.ViewModels;
+
+namespace InterlinedList.Views;
+
+///
+/// The Powered Document surface — all four context.mode variants in one
+/// self-contained card.
+///
+/// Hosting it costs one XAML line and no code-behind:
+///
+/// <local:PoweredDocumentPanel OpenDocumentCommand="{Binding SelectDocumentCommand}"
+/// RefreshCommand="{Binding LoadCommand}"/>
+///
+///
+/// Views/DocumentsView.xaml is contended by #139, so this follows the
+/// #55/#56 pattern for the same reason #14 does: the host contributes a single
+/// line it can move anywhere inside a StackPanel bound to
+/// DocumentsViewModel, and everything else lives here.
+///
+/// The control fetches the user's own lists and documents itself (for the
+/// from_list / from_article pickers) rather than taking them from
+/// the host, so it doesn't depend on the host's load order or on collections
+/// another PR might rename.
+///
+public partial class PoweredDocumentPanel : UserControl
+{
+ ///
+ /// Invoked with the freshly re-fetched
+ /// after a confirmed generation, so the host can open it in its editor.
+ ///
+ public static readonly DependencyProperty OpenDocumentCommandProperty =
+ DependencyProperty.Register(
+ nameof(OpenDocumentCommand), typeof(ICommand), typeof(PoweredDocumentPanel),
+ new PropertyMetadata(null, OnOpenDocumentCommandChanged));
+
+ /// Invoked with null after a confirmed generation so the host reloads its lists.
+ public static readonly DependencyProperty RefreshCommandProperty =
+ DependencyProperty.Register(
+ nameof(RefreshCommand), typeof(ICommand), typeof(PoweredDocumentPanel),
+ new PropertyMetadata(null, OnRefreshCommandChanged));
+
+ private readonly PoweredDocumentPanelViewModel _vm;
+
+ public PoweredDocumentPanel()
+ {
+ InitializeComponent();
+ _vm = new PoweredDocumentPanelViewModel(AppServices.Session);
+ DataContext = _vm;
+
+ _ = _vm.EnsureStatusLoadedAsync();
+ }
+
+ public ICommand? OpenDocumentCommand
+ {
+ get => (ICommand?)GetValue(OpenDocumentCommandProperty);
+ set => SetValue(OpenDocumentCommandProperty, value);
+ }
+
+ public ICommand? RefreshCommand
+ {
+ get => (ICommand?)GetValue(RefreshCommandProperty);
+ set => SetValue(RefreshCommandProperty, value);
+ }
+
+ private static void OnOpenDocumentCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((PoweredDocumentPanel)d)._vm.OpenDocumentCommand = e.NewValue as ICommand;
+
+ private static void OnRefreshCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((PoweredDocumentPanel)d)._vm.RefreshCommand = e.NewValue as ICommand;
+}