diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml
index 1c99468..5e15690 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml
+++ b/src/MandoCode.Desktop/MainWindow.xaml
@@ -452,24 +452,35 @@
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
diff --git a/src/MandoCode.Desktop/Services/ThemeManager.cs b/src/MandoCode.Desktop/Services/ThemeManager.cs
index aaba31b..1590321 100644
--- a/src/MandoCode.Desktop/Services/ThemeManager.cs
+++ b/src/MandoCode.Desktop/Services/ThemeManager.cs
@@ -27,6 +27,16 @@ public sealed record UiTheme
public required string Red { get; init; }
public required string DiffAdd { get; init; }
+ /// When true, the transcript suppresses all motion — no fade-in on new
+ /// blocks, no hover transitions, no smooth scroll — for the still, instant repaint
+ /// of an e-reader page. Gated in the WebView via an html[data-flat] attribute.
+ public bool FlatMotion { get; init; }
+
+ /// When true, the transcript wears a CRT-tube overlay (scanlines, aperture
+ /// grille, Trinitron damper wires, vignette, phosphor bloom) — all STATIC gradients,
+ /// no animation. Gated in the WebView via an html[data-crt] attribute.
+ public bool Crt { get; init; }
+
public static readonly IReadOnlyList All = new[]
{
// First entry is the default for fresh installs (ThemeManager falls back to All[0]).
@@ -112,6 +122,39 @@ public sealed record UiTheme
Green = "#9ECE6A", Red = "#F7768E", DiffAdd = "#9ECE6A",
},
new UiTheme
+ {
+ // Cathode Ray — a Trinitron-style aperture-grille CRT: a deep, near-black picture
+ // tube with vivid, saturated phosphors that glow. Crt=true drapes the transcript in
+ // the tube overlay (scanlines + aperture grille + the two damper wires + vignette +
+ // bloom), all static gradients. The palette is punchy on purpose — CRT phosphors are
+ // high-saturation and the scanlines darken everything, so colors need headroom.
+ // Positioned as the last of the dark themes.
+ Name = "Cathode Ray (CRT)",
+ Description = "Deep-black picture tube with glowing phosphors and cathode-ray scanlines. 📺",
+ Crt = true,
+ Background = "#0B0B0D", Panel = "#131318", Border = "#2A2A33",
+ Text = "#E9EEEC", Dim = "#7C8A86",
+ Accent = "#33CCFF", Gold = "#FFC747", Sky = "#6FD3FF",
+ Green = "#43E37A", Red = "#FF5B54", DiffAdd = "#43E37A",
+ },
+ new UiTheme
+ {
+ // Warm paper + black ink, fully grayscale — reads like a Kindle page. Positioned as
+ // the first of the light themes. Every accent collapses to a shade of warm ink (the
+ // desaturation is what sells "e-ink," more than the cream background does), and
+ // FlatMotion strips the transcript's animation so pages repaint still and instant
+ // like an e-reader. Diffs read as a paper-edit metaphor: changed lines are dark ink
+ // over faded context; the +/- glyphs (not hue) carry add-vs-remove.
+ Name = "E-Ink Paper",
+ Description = "Warm paper, black ink, grayscale, no motion — reads like a Kindle. 📖",
+ IsLight = true,
+ FlatMotion = true,
+ Background = "#E9E5DB", Panel = "#DED9CC", Border = "#C6BFAE",
+ Text = "#211C16", Dim = "#726B5B",
+ Accent = "#3B342A", Gold = "#6A5E48", Sky = "#4C4636",
+ Green = "#5A5240", Red = "#2E251E", DiffAdd = "#3B342A",
+ },
+ new UiTheme
{
Name = "Paper Light",
Description = "A clean light theme with royal purple accents.",
@@ -351,6 +394,12 @@ public static string BuildTranscriptScript(UiTheme t) =>
$"s.setProperty('--diffadd','{t.DiffAdd}');" +
$"s.setProperty('--chat-bg-image','{ChatBackgroundCssValue()}');" +
$"s.setProperty('--chat-bg-opacity','{ChatBackgroundOpacityCss()}');" +
+ (t.FlatMotion
+ ? "document.documentElement.setAttribute('data-flat','1');"
+ : "document.documentElement.removeAttribute('data-flat');") +
+ (t.Crt
+ ? "document.documentElement.setAttribute('data-crt','1');"
+ : "document.documentElement.removeAttribute('data-crt');") +
"})();";
private static void SetBrush(ResourceDictionary res, string key, string hex) =>
diff --git a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
index 1443c0d..210fbfc 100644
--- a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
+++ b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
@@ -246,7 +246,7 @@ public string HelpCard(IEnumerable<(string Command, string Description)> rows)
/// the same CSS variables when the theme changes at runtime.
public static string BaseDocument(UiTheme theme) => $$"""
-
+
@@ -284,6 +284,65 @@ @keyframes rise {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
+ /* E-ink / flat-motion themes: no fade-in, no hover transitions, no smooth scroll — the
+ transcript repaints instantly and stays still, the way an e-reader page does. The
+ attribute is set at build time and toggled live by ThemeManager.BuildTranscriptScript. */
+ html[data-flat] #log > * { animation: none; }
+ html[data-flat] *, html[data-flat] { transition: none !important; scroll-behavior: auto !important; }
+ /* E-ink background image: treat the (static) chat-background layer like a Kindle image —
+ grayscale + contrast + 1-bit Bayer ordered dithering into black/white halftone dots.
+ Applied ONLY to #bg (never the text) and ONLY under the flat/e-ink theme. The layer is
+ fixed and repaints once, so even this heavy filter costs nothing per frame. */
+ html[data-flat] #bg { filter: url(#eink); }
+ /* Color emoji is the loudest break in the paper illusion, so desaturate every emoji-bearing
+ surface to grayscale ink: the chrome (react ghost, reaction pills, picker) AND the inline
+ emoji in message text (.md) and user echoes. Scoped to the flat/e-ink theme only. Safe and
+ static — assistant turns are appended as COMPLETE blocks (ChatController flushes each turn
+ via AssistantCard; no token-by-token DOM streaming), so each subtree is filtered once on
+ append and never re-rasterized by later appends. Under e-ink every other glyph is already
+ ink-gray, so the only visible effect is draining the color out of emoji. */
+ html[data-flat] .react-ghost,
+ html[data-flat] .rx-pill,
+ html[data-flat] #rx-pop .rx,
+ html[data-flat] .md,
+ html[data-flat] .user-echo { filter: grayscale(1); }
+
+ /* ---- CRT picture-tube overlay (aperture-grille tube) ----------------------------------
+ Scoped to html[data-crt]. Drawn on two fixed, pointer-events:none pseudo-layers OVER the
+ transcript, so the "glass" sits in front of the text. EVERYTHING here is STATIC — no moving
+ scanline, no flicker (that is the continuous-repaint trap we keep avoiding); the tube look
+ is fixed gradients only, one paint. The set's native chrome outside the WebView is untouched,
+ exactly like a real TV where only the picture tube carries scanlines. */
+ html[data-crt] body {
+ /* phosphor bloom on every glyph — a tight bright core + a wider soft halo reads more
+ like real phosphor than one big blur (and keeps text legible). Static, so no per-frame
+ cost even though it rides the streaming-text repaint. */
+ text-shadow: 0 0 2px rgba(120, 210, 255, 0.55), 0 0 9px rgba(120, 210, 255, 0.42),
+ 0 0 18px rgba(120, 210, 255, 0.22);
+ }
+ html[data-crt] body::before {
+ content: ""; position: fixed; inset: 0; z-index: 9998; pointer-events: none;
+ background:
+ /* horizontal scanlines (4px period: 2px gap + 2px line) */
+ repeating-linear-gradient(to bottom,
+ rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px,
+ rgba(0,0,0,0.22) 2px, rgba(0,0,0,0.22) 4px),
+ /* aperture grille — faint vertical RGB stripes (the aperture-grille tell, not a dot mask) */
+ repeating-linear-gradient(to right,
+ rgba(255,0,64,0.05) 0, rgba(0,255,128,0.05) 1px,
+ rgba(64,128,255,0.05) 2px, rgba(0,0,0,0) 3px);
+ }
+ html[data-crt] body::after {
+ content: ""; position: fixed; inset: 0; z-index: 9999; pointer-events: none;
+ background:
+ /* the two signature aperture-grille damper wires */
+ linear-gradient(to bottom,
+ transparent calc(33.3% - 1px), rgba(0,0,0,0.30) 33.3%, transparent calc(33.3% + 1px)),
+ linear-gradient(to bottom,
+ transparent calc(66.6% - 1px), rgba(0,0,0,0.30) 66.6%, transparent calc(66.6% + 1px)),
+ /* tube-edge vignette */
+ radial-gradient(ellipse 100% 100% at center, transparent 60%, rgba(0,0,0,0.55) 100%);
+ }
.user-echo { color: var(--gold); font-weight: 600; white-space: pre-wrap; margin-top: 14px; }
.assistant { margin-top: 4px; position: relative; }
.assistant-label { color: var(--green); font-weight: 700; margin-bottom: 2px; }
@@ -483,6 +542,32 @@ the run is active and collapses once a non-operation block lands after it. */
+
+