diff --git a/CHANGELOG.md b/CHANGELOG.md
index 428a7e2..b72929a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,62 @@ recorded by the `MandoCode` submodule.
## [Unreleased]
+### Changed
+- **Assistant text always starts on its own line.** Inserting a reply at the cursor used to glue it
+ onto the tail of whatever line you were mid-way through. It now opens a new line first — unless
+ the cursor already sits at the start of one, so an empty note doesn't gain a blank first line.
+ Replacing a highlighted selection is unchanged: there you aimed at a specific span, and pushing
+ the replacement onto its own line would orphan the rest of that line.
+- **The snapshot offer now reads as a card floating over the chat.** It was painted with the same
+ panel shade as the docked chrome, which sits within a few points of the transcript background in
+ most themes (Visual Studio Dark is `#252526` on `#1E1E1E`), so it blended into the conversation.
+ Both stages — the thin bar and the full name + model picker — now use a new raised surface plus an
+ accent edge. The shade is derived per theme from that theme's own accent rather than hand-picked,
+ so it carries the theme's character (grayscale in E-Ink Paper, navy in W98, phosphor green in
+ Phosphor Fwog) and new themes get one automatically. The tint eases off on a theme whose text
+ contrast can't afford it — Solarized Light, which already sat below AA on its own panel — and is
+ skipped entirely on a theme whose panel already reads as raised, which keeps W98's card the
+ period-correct white dialog on the silver desktop.
+
+### Fixed
+- **W98 chat prompts are readable again.** Your own prompts rendered in the theme's gold, which
+ resolves to a dark mustard `#806000` — 3.21:1 on a silver window, under the accessibility floor
+ and hard going for anyone with less-than-perfect sight. W98 prompts now use black window text
+ (11.5:1), which is the era-correct answer anyway; the silver bevelled frame already marks whose
+ turn it is. The "Show more" toggle on a clamped prompt got the same treatment: it sits on the teal
+ desktop rather than in the window, where the dim gray it used was 1.44:1 — effectively invisible —
+ and is now white underlined at 4.77:1. Other themes are untouched.
+
### Added
+- **Undo for the notes assistant.** A gold undo arrow appears in the note header after the assistant
+ inserts or replaces text, putting the note back exactly as it was. Ctrl+Z can't do this job —
+ assigning the editor's text resets the TextBox's own undo history, so the one edit you *didn't*
+ type by hand was the one the control couldn't reverse, and a Replace could take a whole note with
+ it. The offer covers the assistant's last edit only and retires the moment you type, since
+ restoring the earlier buffer would otherwise discard whatever you'd written on top of it.
+- **Chat backgrounds included in the box.** Settings → Appearance now offers a gallery of three
+ backgrounds that ship with MandoCode — **Golden Gate**, **Sequoia Trail**, and **Pismo Beach** —
+ so a fresh install has something to pick without hunting for a file. Click a tile to use it, click it again to turn it off; the active one is ringed and
+ named. Choosing your own image works exactly as before, and the two are interchangeable — a
+ tile is just a starting point, not a mode. The gallery is read from the release's
+ `Assets/images/backgrounds` folder at startup rather than listed in code, so a future release
+ adds one by dropping the file in. A **fresh install now opens on "Golden Gate"** at the usual 30%
+ opacity instead of a bare theme — first launch only, so nobody who has already set (or cleared) a
+ background is re-skinned by an update.
+- **One-click snapshot from the tab header.** A camera button joins the folder and explorer icons
+ at the right of each agent's header, taking the same snapshot offer that lived two clicks deep
+ in the tab's "…" menu (which stays). It sits with the header's other *actions* rather than
+ beside the model label it captures, so it keeps a fixed position instead of sliding whenever
+ the model name changes length. On an empty conversation it answers with the usual "Nothing to
+ snapshot" chip rather than presenting a dead button.
+- **History cards quote the agent's last reply.** A card showed the opening prompt and the last
+ thing you typed; it now adds the last thing the agent *said*, which is usually what you
+ actually remember a conversation by. The reply is flattened out of markdown (code fences,
+ headings, bullets and tables dropped; link text kept) and clipped to its first couple of
+ sentences, so the card doesn't grow — the opening line gives up a third row of wrapping to pay
+ for it. The two closing quotes are now labeled **you** and **reply** so it's clear which voice
+ is which. Conversations archived before this fill in on the first History open, alongside the
+ existing last-message backfill (one file read for both).
- **Agent callsigns.** A Settings → Behavior toggle (app-wide) names new agents from a
curated 500+ pool of handles — construct-crew, phreak, and cypher energy ("Morphy",
"Crunch", "Blazor", "Kaos") — drawn from a shuffled deck that doesn't repeat until it runs
diff --git a/src/MandoCode.Desktop.Tests/BuiltInBackgroundsTests.cs b/src/MandoCode.Desktop.Tests/BuiltInBackgroundsTests.cs
new file mode 100644
index 0000000..dbe18ca
--- /dev/null
+++ b/src/MandoCode.Desktop.Tests/BuiltInBackgroundsTests.cs
@@ -0,0 +1,104 @@
+using MandoCode.Desktop.Services;
+using Xunit;
+
+namespace MandoCode.Desktop.Tests;
+
+///
+/// The bundled-background gallery is discovered from a folder rather than declared in code, so the
+/// file-naming convention IS the contract — these pin the rules documented in
+/// Assets/images/backgrounds/README.md, which is what a future release adds images against.
+///
+public class BuiltInBackgroundsTests : IDisposable
+{
+ private readonly string _folder = Path.Combine(
+ Path.GetTempPath(), "mandocode-bg-tests-" + Guid.NewGuid().ToString("N"));
+
+ public BuiltInBackgroundsTests() => Directory.CreateDirectory(_folder);
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_folder, recursive: true); } catch { }
+ }
+
+ private void Add(string fileName) => File.WriteAllText(Path.Combine(_folder, fileName), "x");
+
+ // ---- display names ---------------------------------------------------------------
+
+ [Theory]
+ [InlineData("01-nebula-drift.jpg", "Nebula Drift")]
+ [InlineData("02-violet-haze.png", "Violet Haze")]
+ [InlineData("10_deep_space.webp", "Deep Space")]
+ [InlineData("aurora.jpg", "Aurora")]
+ [InlineData("two words.png", "Two Words")]
+ public void DisplayNameFor_strips_the_ordering_prefix_and_titlecases(string file, string expected)
+ => Assert.Equal(expected, BuiltInBackgrounds.DisplayNameFor(file));
+
+ [Fact]
+ public void DisplayNameFor_keeps_a_leading_number_that_is_part_of_the_name()
+ {
+ // No separator after the digits, so "1999" is the name — not an ordering prefix.
+ Assert.Equal("1999 Skyline", BuiltInBackgrounds.DisplayNameFor("1999 skyline.jpg"));
+ // A prefix with nothing after it must not clip the whole name away.
+ Assert.Equal("01", BuiltInBackgrounds.DisplayNameFor("01.jpg"));
+ }
+
+ // ---- discovery -------------------------------------------------------------------
+
+ [Fact]
+ public void DiscoverIn_returns_empty_for_a_missing_folder()
+ => Assert.Empty(BuiltInBackgrounds.DiscoverIn(Path.Combine(_folder, "nope")));
+
+ [Fact]
+ public void DiscoverIn_returns_empty_when_the_folder_has_no_images()
+ {
+ Add("README.md");
+ Assert.Empty(BuiltInBackgrounds.DiscoverIn(_folder));
+ }
+
+ [Fact]
+ public void DiscoverIn_orders_by_file_name_so_the_numeric_prefix_controls_the_gallery()
+ {
+ Add("03-third.jpg");
+ Add("01-first.jpg");
+ Add("02-second.jpg");
+
+ Assert.Equal(
+ new[] { "First", "Second", "Third" },
+ BuiltInBackgrounds.DiscoverIn(_folder).Select(b => b.DisplayName));
+ }
+
+ [Fact]
+ public void DiscoverIn_takes_only_decodable_image_extensions()
+ {
+ Add("01-keep.jpg");
+ Add("02-keep.jpeg");
+ Add("03-keep.png");
+ Add("04-keep.webp");
+ Add("05-skip.txt");
+ Add("06-skip.bmp"); // the picker accepts it; BitmapImage thumbnails don't
+ Add("README.md");
+
+ var found = BuiltInBackgrounds.DiscoverIn(_folder);
+ Assert.Equal(4, found.Count);
+ Assert.All(found, b => Assert.StartsWith("Keep", b.DisplayName));
+ }
+
+ [Fact]
+ public void DiscoverIn_is_case_insensitive_about_extensions()
+ {
+ Add("01-shouty.JPG");
+ Assert.Single(BuiltInBackgrounds.DiscoverIn(_folder));
+ }
+
+ [Fact]
+ public void DiscoverIn_carries_the_file_name_as_the_durable_identity()
+ {
+ Add("01-nebula-drift.jpg");
+ var only = Assert.Single(BuiltInBackgrounds.DiscoverIn(_folder));
+
+ // The FILE NAME is what's persisted to mark the active tile — not the display name, which
+ // is derived and would change if the labeling rules ever did.
+ Assert.Equal("01-nebula-drift.jpg", only.FileName);
+ Assert.Equal(Path.Combine(_folder, "01-nebula-drift.jpg"), only.FullPath);
+ }
+}
diff --git a/src/MandoCode.Desktop.Tests/CardPreviewTests.cs b/src/MandoCode.Desktop.Tests/CardPreviewTests.cs
new file mode 100644
index 0000000..15d3625
--- /dev/null
+++ b/src/MandoCode.Desktop.Tests/CardPreviewTests.cs
@@ -0,0 +1,132 @@
+using MandoCode.Desktop.Services;
+using Xunit;
+
+namespace MandoCode.Desktop.Tests;
+
+public class CardPreviewTests
+{
+ // ---- Trim: the quoted user turns -------------------------------------------------
+
+ [Fact]
+ public void Trim_returns_null_for_nothing_to_show()
+ {
+ Assert.Null(CardPreview.Trim(null));
+ Assert.Null(CardPreview.Trim(""));
+ Assert.Null(CardPreview.Trim(" \n "));
+ }
+
+ [Fact]
+ public void Trim_keeps_a_short_message_whole_and_unellipsised()
+ => Assert.Equal("checkout main and pull latest", CardPreview.Trim(" checkout main and pull latest "));
+
+ [Fact]
+ public void Trim_caps_a_long_message_with_an_ellipsis()
+ {
+ var clipped = CardPreview.Trim(new string('x', CardPreview.UserChars + 50));
+ Assert.Equal(CardPreview.UserChars + 1, clipped!.Length); // the cap plus the ellipsis
+ Assert.EndsWith("…", clipped);
+ }
+
+ // ---- ClipReply: the agent's answer ----------------------------------------------
+
+ [Fact]
+ public void ClipReply_returns_null_when_there_is_no_reply()
+ {
+ Assert.Null(CardPreview.ClipReply(null));
+ Assert.Null(CardPreview.ClipReply(" "));
+ }
+
+ [Fact]
+ public void ClipReply_keeps_the_first_two_sentences_and_drops_the_rest()
+ => Assert.Equal(
+ "Already on main. The pull failed.",
+ CardPreview.ClipReply("Already on main. The pull failed. Git could not authenticate. Try gh."));
+
+ [Fact]
+ public void ClipReply_keeps_a_one_sentence_reply_whole()
+ => Assert.Equal("Done — the branch is clean.", CardPreview.ClipReply("Done — the branch is clean."));
+
+ [Fact]
+ public void ClipReply_keeps_a_reply_with_no_terminator_at_all()
+ => Assert.Equal("no trailing period here", CardPreview.ClipReply("no trailing period here"));
+
+ [Fact]
+ public void ClipReply_collapses_paragraphs_into_one_line()
+ => Assert.Equal(
+ "First line. Second line.",
+ CardPreview.ClipReply("First line.\n\n Second line.\n"));
+
+ [Fact]
+ public void ClipReply_drops_fenced_code_blocks()
+ => Assert.Equal(
+ "Here is the fix.",
+ CardPreview.ClipReply("Here is the fix.\n\n```csharp\nvar x = 1; // not card material\n```\n"));
+
+ [Fact]
+ public void ClipReply_drops_tilde_fences_too()
+ => Assert.Equal("Ran it.", CardPreview.ClipReply("Ran it.\n~~~\ngit status\n~~~"));
+
+ [Fact]
+ public void ClipReply_returns_null_when_only_code_remains()
+ => Assert.Null(CardPreview.ClipReply("```\ngit push --force\n```"));
+
+ [Fact]
+ public void ClipReply_strips_headings_quotes_and_bullets()
+ => Assert.Equal(
+ "Summary Pulled main. Synced the submodule.",
+ CardPreview.ClipReply("## Summary\n\n- Pulled main.\n- Synced the submodule."));
+
+ [Fact]
+ public void ClipReply_strips_numbered_items_but_keeps_prose_that_starts_with_a_digit()
+ {
+ Assert.Equal("Fetch. Merge.", CardPreview.ClipReply("1. Fetch.\n2) Merge."));
+ Assert.Equal("27 files changed.", CardPreview.ClipReply("27 files changed."));
+ }
+
+ [Fact]
+ public void ClipReply_strips_task_list_checkboxes()
+ => Assert.Equal("done thing", CardPreview.ClipReply("- [x] done thing"));
+
+ [Fact]
+ public void ClipReply_strips_emphasis_and_code_spans_but_keeps_underscores()
+ => Assert.Equal(
+ "The LastMessage field on session_archive is set.",
+ CardPreview.ClipReply("The **LastMessage** field on `session_archive` is set."));
+
+ [Fact]
+ public void ClipReply_keeps_link_text_and_drops_the_target()
+ => Assert.Equal(
+ "See MainWindow.xaml for the template.",
+ CardPreview.ClipReply("See [MainWindow.xaml](src/MandoCode.Desktop/MainWindow.xaml) for the template."));
+
+ [Fact]
+ public void ClipReply_drops_table_separator_rows()
+ => Assert.Equal(
+ "Results: | file | lines |",
+ CardPreview.ClipReply("Results:\n\n| file | lines |\n|------|-------|"));
+
+ [Fact]
+ public void ClipReply_does_not_split_on_decimals_or_file_names()
+ => Assert.Equal(
+ "Bumped to v1.2 in MainWindow.xaml.cs today. Second sentence.",
+ CardPreview.ClipReply("Bumped to v1.2 in MainWindow.xaml.cs today. Second sentence. Third."));
+
+ [Fact]
+ public void ClipReply_treats_a_terminator_cluster_as_one_sentence_end()
+ => Assert.Equal("Wait, what?! It worked.", CardPreview.ClipReply("Wait, what?! It worked. Really."));
+
+ [Fact]
+ public void ClipReply_does_not_split_on_a_short_abbreviation()
+ => Assert.Equal(
+ "Use gh, e.g. gh auth login, to sign in. Then pull.",
+ CardPreview.ClipReply("Use gh, e.g. gh auth login, to sign in. Then pull. And build."));
+
+ [Fact]
+ public void ClipReply_hard_caps_a_long_two_sentence_reply()
+ {
+ var wordy = new string('a', 200) + ". " + new string('b', 200) + ".";
+ var clipped = CardPreview.ClipReply(wordy);
+ Assert.Equal(CardPreview.ReplyChars + 1, clipped!.Length); // the cap plus the ellipsis
+ Assert.EndsWith("…", clipped);
+ }
+}
diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
index 609e37e..41f4259 100644
--- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
+++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
@@ -38,6 +38,13 @@
+
+
+
+
+ #3E2753
+
+
diff --git a/src/MandoCode.Desktop/Assets/images/backgrounds/01-golden-gate.jpg b/src/MandoCode.Desktop/Assets/images/backgrounds/01-golden-gate.jpg
new file mode 100644
index 0000000..c9c5e62
Binary files /dev/null and b/src/MandoCode.Desktop/Assets/images/backgrounds/01-golden-gate.jpg differ
diff --git a/src/MandoCode.Desktop/Assets/images/backgrounds/02-sequoia-trail.jpg b/src/MandoCode.Desktop/Assets/images/backgrounds/02-sequoia-trail.jpg
new file mode 100644
index 0000000..20cb21d
Binary files /dev/null and b/src/MandoCode.Desktop/Assets/images/backgrounds/02-sequoia-trail.jpg differ
diff --git a/src/MandoCode.Desktop/Assets/images/backgrounds/03-pismo-beach.jpg b/src/MandoCode.Desktop/Assets/images/backgrounds/03-pismo-beach.jpg
new file mode 100644
index 0000000..a9b2763
Binary files /dev/null and b/src/MandoCode.Desktop/Assets/images/backgrounds/03-pismo-beach.jpg differ
diff --git a/src/MandoCode.Desktop/Assets/images/backgrounds/README.md b/src/MandoCode.Desktop/Assets/images/backgrounds/README.md
new file mode 100644
index 0000000..166ffd6
--- /dev/null
+++ b/src/MandoCode.Desktop/Assets/images/backgrounds/README.md
@@ -0,0 +1,45 @@
+# Bundled chat backgrounds
+
+Images in this folder ship with the release and appear as tiles in **Settings → Appearance → Chat
+background**, above "Choose image…". Users can still pick any file of their own; these are just the
+ones that come in the box.
+
+Nothing here is registered in code. `Services/BuiltInBackgrounds.cs` reads the folder at startup, so
+**adding an image is a file drop** — copy it in, and it's in the gallery on the next run. Removing one
+is a delete; anyone already using it keeps their copy (see "How selection works" below).
+
+## Naming
+
+ NN-kebab-case-name.jpg
+
+- `NN-` orders the gallery and is stripped from the label, so `01-` sorts first without showing.
+- **`01-` is also the first-run default** — the image a brand-new install opens on, at the standard
+ 30% opacity (see `ThemeManager.ApplyFirstRunBackground`). Change the default by renumbering, not by
+ editing code. It applies on first launch only; existing users are never re-skinned.
+- The rest becomes the tile's caption: `02-violet-nebula.jpg` → **"Violet Nebula"**.
+- Extensions offered: `.jpg`, `.jpeg`, `.png`, `.webp`. Anything else in this folder is ignored.
+- The file name is the durable identity — it's what's saved in `ui-settings.json` to mark the active
+ tile. **Renaming an image in a later release un-marks it** for anyone who had it selected (their
+ background keeps working; the tile just stops showing as active). Prefer adding over renaming.
+
+## Sizing
+
+These go into the installer, so every megabyte here is a megabyte every user downloads.
+
+- **1920×1080 is plenty** — the image is a backdrop behind text, drawn at whatever the window is, and
+ it renders at 30% opacity by default.
+- **Aim for ≤600 KB each.** JPEG at quality ~80, or WebP, gets a 1920-wide render there comfortably.
+- Favor **low-contrast, low-detail** images. Busy or bright ones fight the text, which is the whole
+ reason the opacity slider exists — an image that only works at 10% opacity isn't a good default.
+- Thumbnails are generated at runtime (`DecodePixelWidth`), so don't add separate thumbnail files.
+
+## How selection works
+
+Picking a tile **copies** the image into the user's data folder (`%LOCALAPPDATA%\MandoCode.Desktop`)
+as `chat-bg.`, exactly like picking your own file — the per-tab WebView serves it from there over
+the `mandocode.userdata` host.
+
+That copy is why a user's background survives the app updating or being reinstalled underneath it, and
+why an image dropped from a future release doesn't break anyone still using it. It also means an
+updated image with the same file name will **not** replace the copy someone already has; ship it under
+a new name if you want existing users to see the new version.
diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css
index a75e456..04278f4 100644
--- a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css
+++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css
@@ -115,8 +115,20 @@
buttons. Status lines and tool ops sit directly on the teal like desktop icon labels,
with brightened colors (the theme's dark semantic hues are unreadable on teal).
(A user-chosen chat background image still paints over the teal via #bg — wallpaper.) */
- html[data-win98] .user-echo { background: var(--bg); padding: 7px 12px;
+ /* The one place W98 must drop the gold "user's voice" convention: gold resolves to #806000 in
+ this palette, and dark mustard on a silver window is 3.21:1 — under the 4.5:1 AA floor and
+ genuinely hard to read. Black window text is both legible (11.5:1) and the era-correct answer;
+ the silver bevelled frame already marks whose turn it is, so color isn't carrying that meaning
+ here the way it does in every other theme. */
+ html[data-win98] .user-echo { background: var(--bg); padding: 7px 12px; color: var(--fg);
border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
+ /* The clamp toggle is a SIBLING of the echo, so it lands on the teal desktop rather than inside
+ the silver window — same situation as the status lines above, and it takes the same treatment:
+ a brightened literal, because --dim on teal is 1.45:1 (effectively invisible). White underlined
+ is 4.77:1 and reads as a desktop icon label; the hover is a hue shift, since white has no
+ brightness left to gain. */
+ html[data-win98] .ue-toggle { color: #FFFFFF; text-decoration: underline; }
+ html[data-win98] .ue-toggle:hover { color: #FFE082; }
html[data-win98] .assistant { background: var(--bg);
border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
html[data-win98] .assistant-label {
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
index 9a3aca5..4718e6d 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
@@ -187,4 +187,10 @@ private async void ClearTranscript()
/// opt-in create card so the user can pick a summarizer model.
public void TakeSnapshotManually() => _ = _controller.OfferManualSnapshotAsync();
+ /// The header's camera button — the same offer as the tab menu's "Take snapshot", one
+ /// click away instead of two. No guard for an empty conversation: the offer itself answers that
+ /// with a "Nothing to snapshot" chip in the transcript, which teaches more than a dead button
+ /// would.
+ private void SnapshotButton_Click(object sender, RoutedEventArgs e) => TakeSnapshotManually();
+
}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml b/src/MandoCode.Desktop/Controls/ChatTabView.xaml
index d49bd4e..6030d7b 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml
@@ -109,6 +109,16 @@
ToolTipService.ToolTip="This tab's project folder"/>
+
+
-
+ MaxLines="2" TextTrimming="CharacterEllipsis" TextWrapping="Wrap"/>
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MandoCode.Desktop/MandoCode.Desktop.csproj b/src/MandoCode.Desktop/MandoCode.Desktop.csproj
index 5803b20..9ab53c6 100644
--- a/src/MandoCode.Desktop/MandoCode.Desktop.csproj
+++ b/src/MandoCode.Desktop/MandoCode.Desktop.csproj
@@ -14,7 +14,7 @@
trueenableenable
- 0.1.0
+ 0.14.0Armando Fernandez (DevMando)MandoCode Desktop — the MandoCode AI coding agent with a native WinUI 3 interface.
+
+ PreserveNewest
+
+
+
+
diff --git a/src/MandoCode.Desktop/Services/BuiltInBackgrounds.cs b/src/MandoCode.Desktop/Services/BuiltInBackgrounds.cs
new file mode 100644
index 0000000..71fe982
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/BuiltInBackgrounds.cs
@@ -0,0 +1,97 @@
+namespace MandoCode.Desktop.Services;
+
+/// One chat background that ships with the app, as the Appearance gallery shows it.
+/// Name on disk — the durable identity persisted in ui-settings.json.
+/// Label derived from the file name (see
+/// ).
+/// Absolute path in the install folder, used for the thumbnail and as the
+/// copy source when the user picks it.
+public sealed record BuiltInBackground(string FileName, string DisplayName, string FullPath);
+
+///
+/// The background images bundled with a release, offered alongside "Choose image…" so a fresh
+/// install has something to pick without hunting for a file.
+///
+/// Deliberately DISCOVERED rather than hard-coded: the gallery is whatever ships in
+/// Assets/images/backgrounds, so adding a fourth image in a future release is a file drop
+/// plus a line in the changelog — no code change, no list to keep in sync. The folder's README
+/// documents the naming convention for whoever adds the next one.
+///
+/// Selecting one goes through the SAME path as a user-picked file
+/// ( copies it into the user-data folder, which the
+/// per-tab WebView serves over the mandocode.userdata host). So a built-in needs no special
+/// case anywhere downstream — not in the CSS variable, the live preview, or the export.
+///
+public static class BuiltInBackgrounds
+{
+ /// Extensions offered in the gallery — the subset of the file picker's filters that
+ /// WinUI's BitmapImage can decode for a thumbnail.
+ private static readonly string[] Extensions = { ".jpg", ".jpeg", ".png", ".webp" };
+
+ /// Where the shipped images live next to the executable. Copied there by the csproj's
+ /// Content include, so this resolves in a dev build and an installed one alike.
+ public static string Folder =>
+ Path.Combine(AppContext.BaseDirectory, "Assets", "images", "backgrounds");
+
+ ///
+ /// The shipped gallery, ordered by file name — a numeric prefix ("01-", "02-") is how a release
+ /// controls the order without that prefix showing in the UI. Read once: the install folder can't
+ /// change while the app is running.
+ ///
+ /// Empty is a valid, supported state, not an error: a dev tree with no images yet simply hides
+ /// the gallery and leaves "Choose image…" working exactly as before.
+ ///
+ public static IReadOnlyList All { get; } = Discover();
+
+ /// The shipped image with this file name, or null when it isn't in this release —
+ /// which is what happens to a saved choice if a later version drops an image.
+ public static BuiltInBackground? Find(string? fileName) =>
+ string.IsNullOrEmpty(fileName)
+ ? null
+ : All.FirstOrDefault(b => string.Equals(b.FileName, fileName, StringComparison.OrdinalIgnoreCase));
+
+ private static IReadOnlyList Discover() => DiscoverIn(Folder);
+
+ /// The gallery for an arbitrary folder — 's body, split out so the
+ /// ordering and extension rules the folder's README promises can be tested against a temp
+ /// directory instead of the install folder.
+ public static IReadOnlyList DiscoverIn(string folder)
+ {
+ try
+ {
+ if (!Directory.Exists(folder)) return Array.Empty();
+
+ return Directory.EnumerateFiles(folder)
+ .Where(f => Extensions.Contains(Path.GetExtension(f), StringComparer.OrdinalIgnoreCase))
+ .OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase)
+ .Select(f => new BuiltInBackground(Path.GetFileName(f), DisplayNameFor(f), f))
+ .ToList();
+ }
+ catch
+ {
+ // An unreadable install folder must not take the Appearance page down — the gallery
+ // just doesn't appear, and picking your own file still works.
+ return Array.Empty();
+ }
+ }
+
+ ///
+ /// A label from a file name: 01-nebula-drift.jpg → "Nebula Drift". The leading
+ /// NN- is an ordering device for the release, so it's stripped rather than shown, and
+ /// dashes/underscores become spaces. Falls back to the bare file name if that leaves nothing.
+ ///
+ public static string DisplayNameFor(string path)
+ {
+ var stem = Path.GetFileNameWithoutExtension(path);
+
+ var dash = stem.IndexOfAny(new[] { '-', '_' });
+ if (dash > 0 && stem[..dash].All(char.IsDigit)) stem = stem[(dash + 1)..];
+
+ var words = stem.Split('-', '_', ' ')
+ .Where(w => w.Length > 0)
+ .Select(w => char.ToUpperInvariant(w[0]) + w[1..]);
+
+ var name = string.Join(' ', words);
+ return name.Length == 0 ? Path.GetFileNameWithoutExtension(path) : name;
+ }
+}
diff --git a/src/MandoCode.Desktop/Services/CardPreview.cs b/src/MandoCode.Desktop/Services/CardPreview.cs
new file mode 100644
index 0000000..c72c447
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/CardPreview.cs
@@ -0,0 +1,233 @@
+using System.Text;
+
+namespace MandoCode.Desktop.Services;
+
+///
+/// The quoted lines on a History card. Pure — text in, card line out — so the clipping rules are
+/// pinned down by tests instead of being buried in the panel code.
+///
+/// Two shapes, because the two voices need different treatment. A user turn is short, plain, and
+/// already reads like a card line ( just caps it). An assistant turn is a
+/// formatted REPLY — markdown headings, bullets, fenced code, several paragraphs — so quoting its
+/// raw text would put "```csharp" on the card. flattens it back to prose
+/// and keeps only the opening sentences.
+///
+public static class CardPreview
+{
+ /// Cap for a quoted user turn; shared by the first- and last-message lines so the two
+ /// can't drift apart.
+ public const int UserChars = 140;
+
+ /// Cap for the flattened assistant reply. Tighter than : it's the
+ /// third quote on the card, rendered smaller, and it's there to jog recognition rather than to be
+ /// read in full.
+ public const int ReplyChars = 160;
+
+ /// Sentences kept from the start of the reply. The opening of an answer says what the
+ /// agent DID; the tail is usually a caveat or an offer to continue.
+ public const int ReplySentences = 2;
+
+ ///
+ /// Words whose trailing period is part of the WORD, not the end of a sentence. Checked by token
+ /// rather than by sentence length: "It worked." is a real sentence at ten characters, so any
+ /// length threshold big enough to absorb "e.g." would also swallow that.
+ ///
+ private static readonly HashSet Abbreviations = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "e.g", "i.e", "etc", "vs", "cf", "approx", "no", "fig", "al",
+ "mr", "mrs", "ms", "dr", "st", "jr", "sr",
+ };
+
+ /// Caps a quoted user turn at . Null (not "") for nothing to
+ /// show, so callers can distinguish it from a computed-but-empty line.
+ public static string? Trim(string? text)
+ {
+ var trimmed = text?.Trim();
+ if (string.IsNullOrEmpty(trimmed)) return null;
+ return trimmed.Length > UserChars
+ ? trimmed[..UserChars].TrimEnd() + "…"
+ : trimmed;
+ }
+
+ ///
+ /// The agent's last reply as one card line: markdown flattened to prose, then the first
+ /// sentences, then a hard cap at . Returns
+ /// null when there's nothing quotable left — a reply that was pure code or a bare table flattens
+ /// to nothing, and an absent line reads better than an empty one.
+ ///
+ public static string? ClipReply(string? reply)
+ {
+ var prose = Flatten(reply);
+ if (prose.Length == 0) return null;
+
+ var clipped = FirstSentences(prose, ReplySentences);
+ return clipped.Length > ReplyChars
+ ? clipped[..ReplyChars].TrimEnd() + "…"
+ : clipped;
+ }
+
+ ///
+ /// Markdown → one line of prose. Fenced code blocks go entirely (they're the noisiest thing a
+ /// reply can start with), block prefixes and inline emphasis are stripped, links keep their text,
+ /// and every whitespace run collapses so the result never reproduces the reply's own wrapping.
+ ///
+ private static string Flatten(string? markdown)
+ {
+ if (string.IsNullOrWhiteSpace(markdown)) return "";
+
+ var parts = new List();
+ var inFence = false;
+
+ foreach (var raw in markdown.Split('\n'))
+ {
+ var line = raw.Trim();
+
+ // Fence delimiters carry an info string ("```csharp"), so match on the prefix.
+ if (line.StartsWith("```", StringComparison.Ordinal) || line.StartsWith("~~~", StringComparison.Ordinal))
+ {
+ inFence = !inFence;
+ continue;
+ }
+ if (inFence || line.Length == 0) continue;
+
+ line = StripBlockPrefix(line);
+ if (line.Length == 0) continue;
+
+ // A table separator ("|---|---|") is punctuation only — it would read as garbage.
+ if (line.All(c => c is '|' or '-' or ':' or ' ')) continue;
+
+ parts.Add(StripInline(line));
+ }
+
+ return CollapseWhitespace(string.Join(" ", parts));
+ }
+
+ /// Drops the markers that make a line a heading, quote, bullet, or numbered item —
+ /// repeatedly, so "> - item" loses both.
+ private static string StripBlockPrefix(string line)
+ {
+ while (true)
+ {
+ var before = line;
+
+ if (line.StartsWith('#') || line.StartsWith('>'))
+ line = line.TrimStart('#', '>').TrimStart();
+ else if (line.Length > 1 && line[0] is '-' or '*' or '+' && char.IsWhiteSpace(line[1]))
+ line = line[1..].TrimStart();
+ else if (char.IsDigit(line[0]))
+ {
+ // "12. text" / "12) text" — a numbered item. Anything else starting with a digit
+ // (a version, a count) is real prose and must survive.
+ var i = 0;
+ while (i < line.Length && char.IsDigit(line[i])) i++;
+ if (i < line.Length - 1 && line[i] is '.' or ')' && char.IsWhiteSpace(line[i + 1]))
+ line = line[(i + 1)..].TrimStart();
+ }
+
+ // A task-list checkbox sits after the bullet, so it's peeled on the next pass.
+ if (line.StartsWith("[ ]", StringComparison.Ordinal) || line.StartsWith("[x]", StringComparison.OrdinalIgnoreCase))
+ line = line[3..].TrimStart();
+
+ if (line == before) return line;
+ }
+ }
+
+ ///
+ /// Removes inline markup while leaving identifiers intact. Backticks, asterisks and tildes go;
+ /// UNDERSCORES STAY, because stripping them would rewrite every snake_case name the reply
+ /// mentions — a worse outcome than leaving one stray emphasis marker.
+ ///
+ private static string StripInline(string line)
+ {
+ var sb = new StringBuilder(line.Length);
+
+ for (var i = 0; i < line.Length; i++)
+ {
+ var c = line[i];
+ if (c is '`' or '*' or '~') continue;
+
+ // "[label](target)" → "label": keep the label, drop the target.
+ if (c == '[')
+ {
+ var close = line.IndexOf(']', i + 1);
+ if (close > i && close + 1 < line.Length && line[close + 1] == '(')
+ {
+ var end = line.IndexOf(')', close + 2);
+ if (end > close)
+ {
+ sb.Append(StripInline(line[(i + 1)..close]));
+ i = end;
+ continue;
+ }
+ }
+ }
+
+ sb.Append(c);
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// The first sentences, or the whole thing when it has fewer. A
+ /// terminator ends a sentence only when whitespace or end-of-text follows it, so decimals and
+ /// file names ("v1.2", "MainWindow.xaml.cs") don't split it; catches
+ /// the "e.g. " and "etc. " cases that pass that test.
+ ///
+ private static string FirstSentences(string text, int count)
+ {
+ var found = 0;
+
+ for (var i = 0; i < text.Length; i++)
+ {
+ if (text[i] is not ('.' or '!' or '?')) continue;
+
+ // Run past "?!" or "..." so the whole cluster ends one sentence, not three.
+ var end = i;
+ while (end + 1 < text.Length && text[end + 1] is '.' or '!' or '?') end++;
+
+ var atEnd = end + 1 >= text.Length;
+ if (!atEnd && !char.IsWhiteSpace(text[end + 1])) { i = end; continue; }
+ if (EndsWithAbbreviation(text, i)) { i = end; continue; }
+
+ found++;
+ if (found == count || atEnd) return text[..(end + 1)];
+ i = end;
+ }
+
+ return text;
+ }
+
+ /// True when the word ending at is a known abbreviation, so its
+ /// period belongs to the word. Only a '.' can do this — "etc!" is someone shouting.
+ private static bool EndsWithAbbreviation(string text, int dot)
+ {
+ if (text[dot] != '.') return false;
+
+ var start = dot;
+ while (start > 0 && !char.IsWhiteSpace(text[start - 1])) start--;
+
+ // Leading punctuation isn't part of the word: "(e.g." must still read as "e.g".
+ var word = text[start..dot].TrimStart('(', '[', '"', '\'', '“', '‘');
+ return word.Length > 0 && Abbreviations.Contains(word);
+ }
+
+ private static string CollapseWhitespace(string value)
+ {
+ var sb = new StringBuilder(value.Length);
+ var pendingSpace = false;
+
+ foreach (var ch in value)
+ {
+ if (char.IsWhiteSpace(ch))
+ {
+ pendingSpace = sb.Length > 0; // never lead with a space
+ continue;
+ }
+ if (pendingSpace) { sb.Append(' '); pendingSpace = false; }
+ sb.Append(ch);
+ }
+
+ return sb.ToString();
+ }
+}
diff --git a/src/MandoCode.Desktop/Services/NoteText.cs b/src/MandoCode.Desktop/Services/NoteText.cs
index df9ae08..7e2fe06 100644
--- a/src/MandoCode.Desktop/Services/NoteText.cs
+++ b/src/MandoCode.Desktop/Services/NoteText.cs
@@ -33,4 +33,23 @@ public static string ToFileText(string editorText, string newline) =>
editorText.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Replace("\n", newline, StringComparison.Ordinal);
+
+ ///
+ /// The newline to place in FRONT of text being inserted at , so assistant
+ /// output always begins on its own line instead of being glued onto the end of whatever the caret
+ /// was sitting after. "" when the insertion point already starts a line — the guarantee is "this
+ /// starts on a new line", and an unconditional newline would open every empty note with a blank
+ /// first line.
+ ///
+ /// Checks for CR as well as LF because this runs against the EDITOR's buffer, where WinUI holds
+ /// every newline as a bare CR (see the class remarks). The returned "\n" is normalized to CR by
+ /// the same assignment that applies it, and maps it to the note's own
+ /// convention on save.
+ ///
+ public static string LeadIn(string body, int at)
+ {
+ if (at <= 0 || body.Length == 0) return "";
+ var before = body[Math.Min(at, body.Length) - 1];
+ return before is '\n' or '\r' ? "" : "\n";
+ }
}
diff --git a/src/MandoCode.Desktop/Services/SessionArchiveStore.cs b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs
index 74d857e..136507a 100644
--- a/src/MandoCode.Desktop/Services/SessionArchiveStore.cs
+++ b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs
@@ -41,6 +41,18 @@ public sealed class SessionArchiveEntry
///
public string? LastMessage { get; set; }
+ ///
+ /// The agent's last reply, flattened to prose and clipped to a couple of sentences by
+ /// — the line that makes a row RECOGNIZABLE. The two user
+ /// quotes say what was asked; this says how it came out, which is usually what you actually
+ /// remember a conversation by.
+ ///
+ /// Same null-vs-empty contract as : "" means computed with nothing
+ /// worth showing (no assistant turn, or a reply that was pure code), null means still to be
+ /// backfilled.
+ ///
+ public string? LastReply { get; set; }
+
// ---- display helpers for the panel ----
[System.Text.Json.Serialization.JsonIgnore]
@@ -164,26 +176,35 @@ public void Remove(string key, bool deleteFiles)
Changed?.Invoke();
}
+ /// The card's two derived quote lines, resolved together from one read of a session's
+ /// conversation log. Both MUST be "" rather than null when there's nothing to show — see
+ /// .
+ public readonly record struct CardLines(string LastMessage, string LastReply);
+
///
- /// One-time migration for rows archived before
- /// existed, so old and new cards look the same instead of only new ones carrying a last line.
- /// does the per-session file read and MUST return "" (not null) when
- /// there's nothing to show, otherwise the row is retried on every launch. Persists once for the
- /// whole batch. Safe to call from a background thread — is documented as
- /// possibly arriving off the UI thread.
+ /// One-time migration for rows archived before and
+ /// existed, so old and new cards look the same
+ /// instead of only new ones carrying the extra lines. Both fields are resolved in ONE pass —
+ /// they come from the same log file, and a second migration would read all 60 of them again.
+ /// does the per-session file read and MUST return "" (not null) for a
+ /// line with nothing to show, otherwise the row is retried on every launch. Persists once for
+ /// the whole batch. Safe to call from a background thread — is documented
+ /// as possibly arriving off the UI thread.
///
- public int BackfillLastMessages(Func resolve)
+ public int BackfillCardLines(Func resolve)
{
List pending;
- lock (_lock) pending = _items.Where(e => e.LastMessage == null).ToList();
+ lock (_lock) pending = _items.Where(e => e.LastMessage == null || e.LastReply == null).ToList();
if (pending.Count == 0) return 0;
var filled = 0;
foreach (var entry in pending)
{
- var value = resolve(entry);
- if (value == null) continue; // resolve failed outright — leave it for next time
- entry.LastMessage = value;
+ if (resolve(entry) is not { } lines) continue; // resolve failed outright — leave it for next time
+ // Both are rewritten even when only one was missing: they're recomputed from the same
+ // read, under the current rules, so a line trimmed by an older rule is refreshed too.
+ entry.LastMessage = lines.LastMessage;
+ entry.LastReply = lines.LastReply;
filled++;
}
if (filled == 0) return 0;
diff --git a/src/MandoCode.Desktop/Services/ThemeManager.cs b/src/MandoCode.Desktop/Services/ThemeManager.cs
index 6d34aa9..27de81d 100644
--- a/src/MandoCode.Desktop/Services/ThemeManager.cs
+++ b/src/MandoCode.Desktop/Services/ThemeManager.cs
@@ -238,6 +238,13 @@ public static class ThemeManager
/// survives the original moving; transcripts load it via the mandocode.userdata host.
public static string? ChatBackgroundFile { get; private set; }
+ /// File name of the shipped background currently in use (see
+ /// ), or null when the background is the user's own file or
+ /// none. Identity only — the image itself is copied like any picked file, and that copy is
+ /// RENAMED to chat-bg.<ext>, so without this the gallery couldn't mark which tile is
+ /// active after a restart.
+ public static string? ChatBackgroundBuiltIn { get; private set; }
+
/// Opacity of the chat background image layer only (0.05–1.0). Text never
/// fades — the slider dims the picture, not the conversation.
public static double ChatBackgroundOpacity { get; private set; } = 0.30;
@@ -273,9 +280,15 @@ public static void SetBoxedMessages(bool on)
/// the window constructor, before first render.
public static void Initialize(FrameworkElement root)
{
+ // A MISSING settings file is the fresh-install signal, deliberately narrower than "we ended
+ // up on the defaults": a file that exists but won't parse belongs to someone who has used
+ // the app, and handing them a background they never chose would read as the corruption
+ // doing something rather than the app recovering from it.
+ var freshInstall = !File.Exists(SettingsPath);
+
try
{
- if (File.Exists(SettingsPath))
+ if (!freshInstall)
{
var saved = JsonSerializer.Deserialize(File.ReadAllText(SettingsPath));
Current = UiTheme.All.FirstOrDefault(t => t.Name == saved?.Theme) ?? Current;
@@ -287,14 +300,40 @@ public static void Initialize(FrameworkElement root)
{
var bg = Path.Combine(UserDataFolder, saved.ChatBackground);
if (File.Exists(bg)) ChatBackgroundFile = bg;
+ // Only meaningful while that copy is still on disk — otherwise the gallery would
+ // mark a tile as active with no background actually showing.
+ if (ChatBackgroundFile != null) ChatBackgroundBuiltIn = saved.ChatBgBuiltIn;
}
}
}
catch { /* unreadable settings file — fall back to the defaults */ }
+
+ if (freshInstall) ApplyFirstRunBackground();
+
ApplyCore(Current, root);
Save(); // first run (or corrupt file): materialize the defaults on disk
}
+ ///
+ /// Opens a brand-new install on the first bundled background instead of a bare theme, so the app
+ /// has a look out of the box and the gallery isn't a feature only the curious ever find.
+ /// is left at its 30% default — the same value a
+ /// user-picked image gets.
+ ///
+ /// "First" means first in gallery order, so the 01- prefix picks the first-run image as
+ /// well as the tile order: a future release changes the default by renumbering, not by editing
+ /// this. A build that ships no backgrounds is a no-op and opens on the flat theme, exactly as
+ /// before.
+ ///
+ /// Runs on the first launch ONLY. Any later change — including removing the background — writes
+ /// a settings file, and this never runs again, so a user's "no background" choice sticks.
+ ///
+ private static void ApplyFirstRunBackground()
+ {
+ if (BuiltInBackgrounds.All.FirstOrDefault() is not { } first) return;
+ SetChatBackground(first.FullPath, first.FileName);
+ }
+
public static void Apply(UiTheme theme, FrameworkElement root)
{
Current = theme;
@@ -311,8 +350,14 @@ public static void SetWindowOpacity(double value)
/// Copies the picked image into and remembers it,
/// or clears the background when is null. The caller
- /// re-scripts open transcripts (MainWindow.ApplyThemeToAllTabs).
- public static void SetChatBackground(string? sourcePath)
+ /// re-scripts open transcripts (MainWindow.ApplyThemeToAllTabs).
+ ///
+ /// is the shipped image's file name when the source came from
+ /// the gallery, and null for a file the user picked — the copy is renamed on the way in, so
+ /// this is the only record of which tile is active. Copying rather than referencing the install
+ /// folder is deliberate: the background survives the app being reinstalled or updated
+ /// underneath it, and every downstream consumer keeps working off one path.
+ public static void SetChatBackground(string? sourcePath, string? builtInName = null)
{
try
{
@@ -335,6 +380,10 @@ public static void SetChatBackground(string? sourcePath)
}
catch { /* unreadable source — behave as if cleared */ }
}
+
+ // Tied to the copy actually landing: a source that failed to copy leaves no background, so
+ // claiming a gallery tile is active would mark a tile that isn't showing anything.
+ ChatBackgroundBuiltIn = ChatBackgroundFile == null ? null : builtInName;
Save();
}
@@ -369,6 +418,7 @@ private static void Save()
Theme = Current.Name,
Opacity = WindowOpacity,
ChatBackground = ChatBackgroundFile == null ? null : Path.GetFileName(ChatBackgroundFile),
+ ChatBgBuiltIn = ChatBackgroundBuiltIn,
ChatBgOpacity = ChatBackgroundOpacity,
Boxed = BoxedMessages,
}));
@@ -390,6 +440,7 @@ private static void ApplyCore(UiTheme t, FrameworkElement root)
SetBrush(res, "MandoBorderBrush", t.Border);
SetBrush(res, "MandoTextBrush", t.Text);
SetBrush(res, "MandoDimBrush", t.Dim);
+ SetBrush(res, "MandoRaisedBrush", Raised(t));
// Accent family: Light2 feeds accent fills in dark themes, Dark1 in light
// themes — both pinned to the exact brand accent so fills never drift.
@@ -447,9 +498,99 @@ public static string BuildTranscriptScript(UiTheme t) =>
: "document.documentElement.removeAttribute('data-cards');") +
"})();";
+ /// Accent tint used for the raised surface. The strong value is what nearly every theme
+ /// gets; the gentle one is the floor a low-contrast theme backs off to (see
+ /// ).
+ private const double RaisedTintStrong = 0.18;
+ private const double RaisedTintGentle = 0.08;
+
+ /// Text-on-card contrast the tint must preserve where the theme allows it — the WCAG AA
+ /// floor for body text.
+ private const double RaisedTextContrastFloor = 4.5;
+
+ /// Panel-vs-Background separation at which a theme's Panel already reads as a raised
+ /// surface on its own, and tinting it would do more harm than good.
+ private const double RaisedAlreadySeparated = 1.5;
+
+ ///
+ /// The raised-surface color for a theme: blended toward that theme's
+ /// own . Used by transient cards that float OVER the transcript (the
+ /// snapshot offer), which need to read as ON TOP OF the conversation rather than part of it —
+ /// and Panel can't do that job, because Panel and Background are only a few points apart in most
+ /// themes (Visual Studio Dark is #252526 on #1E1E1E).
+ ///
+ /// Derived rather than a hand-authored hex per theme for two reasons. It's one less value to keep
+ /// in sync every time a theme is added or retuned; and blending toward the theme's OWN accent
+ /// means each theme gets a shade that belongs to it instead of a generic gray — the tint stays
+ /// grayscale in E-Ink Paper (whose accent is desaturated ink), goes navy in W98, and goes
+ /// phosphor green in Phosphor Fwog, all for free.
+ ///
+ /// Deliberately NOT a lift toward white/black: W98's Panel is pure white on a silver Background,
+ /// so darkening it would push the card TOWARD the background it needs to separate from.
+ ///
+ /// The tint backs off when a full-strength one would hurt legibility. Solarized Light is the case
+ /// that needs it: its Text/Panel pair is famously low-contrast and already sits below AA, so the
+ /// strong tint would take a marginal theme to genuinely hard to read. It lands on the gentle tint
+ /// instead, which still separates from its background about as well as W98 does at full strength.
+ ///
+ private static Color Raised(UiTheme t)
+ {
+ var panel = C(t.Panel);
+ var accent = C(t.Accent);
+ var text = C(t.Text);
+
+ // Some themes already put real distance between Panel and Background, and tinting those
+ // makes things worse rather than better. W98 is the case: its white content wells sit
+ // 1.82:1 off the silver desktop — already the most separated surface of any theme — and
+ // tinting turned a period-correct white dialog into a pale lavender one belonging to no
+ // era, while REDUCING the separation to 1.21:1. Every other theme is at most 1.36:1, so
+ // this only ever exempts a theme whose own palette has done the job.
+ if (Contrast(panel, C(t.Background)) >= RaisedAlreadySeparated) return panel;
+
+ for (var tint = RaisedTintStrong; tint > RaisedTintGentle; tint -= 0.02)
+ {
+ var candidate = MixToward(panel, accent, tint);
+ if (Contrast(text, candidate) >= RaisedTextContrastFloor) return candidate;
+ }
+
+ // No tint in range clears the floor (the theme starts below it) — take the gentlest, which
+ // costs the least contrast while still being a distinct surface.
+ return MixToward(panel, accent, RaisedTintGentle);
+ }
+
+ /// WCAG relative luminance of an sRGB color.
+ private static double Luminance(Color c)
+ {
+ static double Ch(byte v)
+ {
+ var s = v / 255.0;
+ return s <= 0.03928 ? s / 12.92 : Math.Pow((s + 0.055) / 1.055, 2.4);
+ }
+ return 0.2126 * Ch(c.R) + 0.7152 * Ch(c.G) + 0.0722 * Ch(c.B);
+ }
+
+ /// WCAG contrast ratio between two colors, 1.0 (identical) to 21.0 (black on white).
+ private static double Contrast(Color a, Color b)
+ {
+ var (la, lb) = (Luminance(a), Luminance(b));
+ return (Math.Max(la, lb) + 0.05) / (Math.Min(la, lb) + 0.05);
+ }
+
private static void SetBrush(ResourceDictionary res, string key, string hex) =>
((SolidColorBrush)res[key]).Color = C(hex);
+ private static void SetBrush(ResourceDictionary res, string key, Color color) =>
+ ((SolidColorBrush)res[key]).Color = color;
+
+ /// Blends toward by
+ /// (0–1). The result always lands between the two channels, so the
+ /// byte casts can't overflow.
+ private static Color MixToward(Color from, Color to, double amount)
+ {
+ byte Ch(byte a, byte b) => (byte)(a + (b - a) * amount);
+ return Color.FromArgb(255, Ch(from.R, to.R), Ch(from.G, to.G), Ch(from.B, to.B));
+ }
+
public static Color C(string hex) => Color.FromArgb(
255,
Convert.ToByte(hex.Substring(1, 2), 16),
@@ -470,6 +611,10 @@ private sealed class UiSettings
public string? Theme { get; set; }
public double Opacity { get; set; } = 1.0;
public string? ChatBackground { get; set; } // file name inside UserDataFolder
+ /// Shipped image this came from, or null for the user's own file. Absent in a
+ /// settings file written before the gallery existed, which reads as "your own file" — the
+ /// safe answer, since it only means no tile is marked.
+ public string? ChatBgBuiltIn { get; set; }
public double ChatBgOpacity { get; set; } = 0.30;
/// Nullable on purpose: absent (pre-feature settings file) means "use the
/// current default", so changing the default never fights a user's explicit choice.