From 5840cc772d792a7657e370952a06774bd7d303c1 Mon Sep 17 00:00:00 2001 From: DevMando Date: Wed, 9 Sep 2026 23:38:46 -0700 Subject: [PATCH] Add nine medium themes, a readability floor, and a wordmark typeface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three strands, all in the theme system. READABILITY. Secondary text sat below the WCAG AA floor in half the shipped themes. Most of those are faithful — Dracula and One Dark ship famously faint comment colours upstream — so the palettes stay as authored and UiTheme.ReadableDim lifts the value instead, blending toward that theme's own text so a nudged palette stays its own colour rather than drifting grey. It takes the FIRST step that clears, because a palette should be nudged into legibility, not flattened into it. A theme already above the floor comes back byte-identical. Themes read through an overlay are held higher. Every other contrast figure in the app compares a colour against what is BEHIND it; the CRT's scanlines are painted in FRONT, so a tube palette's measured contrast is not what a reader gets. Body text there has huge headroom and never notices, dim text does. UiTheme moves to its own file so the shipped palettes are reachable from tests without dragging WinUI along, and the contrast arithmetic moves to ColorMath so there is one implementation. The sweep asserts every theme by name, which is what stops the NEXT theme landing below the floor. NINE MEDIUM THEMES. Each reproduces how its medium actually made an image rather than borrowing its colours: the LCD ghosts rather than glows because liquid crystal twists light instead of emitting it; the split-flap board can only show uppercase because a flap carries one printed character; the cyanotype is a negative, so emphasis is a whiter stroke, not a heavier one. The CRT's bloom now takes the glyph's own colour — it was hardcoded blue, so red errors carried a blue halo, the one place that overlay disobeyed its own physics. Background images get the same treatment behind an Appearance toggle, defaulted on: someone who would enjoy the effect would never go looking for a switch to enable it, while someone who wants their picture untouched finds the toggle immediately. Deliberately no glow on the background — on real hardware the segments emit, the glass behind them does not. WORDMARK. Permanent Marker, bundled rather than fetched, for the same reason the web assets are: a CDN font puts a network dependency on the app's own branding. 24px is a contrast decision, not a typographic one — the mark paints in each theme's accent, four of which fall under 4.5:1, and at 24px the bar drops to the large-text 3:1 that every theme clears. Lifting those accents was the other fix and the wrong one: #007ACC IS the Visual Studio blue. .gitattributes is scoped to fonts only. A repo-wide eol policy would rewrite every existing file, which is its own change; this guarantees the one thing that would silently corrupt an asset. --- .gitattributes | 9 + CHANGELOG.md | 29 + THIRD-PARTY-NOTICES.txt | 34 ++ .../MandoCode.Desktop.Tests.csproj | 2 + .../ThemeReadabilityTests.cs | 99 ++++ .../Assets/fonts/LICENSE.txt | 202 +++++++ .../Assets/fonts/PermanentMarker-Regular.ttf | Bin 0 -> 74632 bytes .../Assets/web/transcript/transcript.css | 327 +++++++++++- .../MainWindow.Appearance.cs | 9 +- src/MandoCode.Desktop/MainWindow.Skills.cs | 4 +- .../MainWindow.ViewModels.cs | 6 +- src/MandoCode.Desktop/MainWindow.xaml | 38 +- src/MandoCode.Desktop/MainWindow.xaml.cs | 3 +- .../MandoCode.Desktop.csproj | 10 +- src/MandoCode.Desktop/Services/ColorMath.cs | 82 +++ .../Services/ThemeManager.cs | 292 +++------- .../Services/TranscriptHtmlBuilder.cs | 223 +++++++- src/MandoCode.Desktop/Services/UiTheme.cs | 500 ++++++++++++++++++ 18 files changed, 1623 insertions(+), 246 deletions(-) create mode 100644 .gitattributes create mode 100644 src/MandoCode.Desktop.Tests/ThemeReadabilityTests.cs create mode 100644 src/MandoCode.Desktop/Assets/fonts/LICENSE.txt create mode 100644 src/MandoCode.Desktop/Assets/fonts/PermanentMarker-Regular.ttf create mode 100644 src/MandoCode.Desktop/Services/ColorMath.cs create mode 100644 src/MandoCode.Desktop/Services/UiTheme.cs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e793aab --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Scoped deliberately to the bundled fonts rather than declaring rules for the whole repo: +# a repo-wide text/eol policy would rewrite line endings across every existing file, which is +# its own change with its own review. This only guarantees the one thing that would silently +# corrupt an asset — a font is binary, and any newline "normalisation" applied to it produces a +# file that still has the right name and no longer renders. +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b75459..c05aa7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,23 @@ for every approved plan. Desktop's version follows the engine generation, so it 0.15.0. ### Added +- **Nine themes that imitate a physical medium, not just a colour scheme.** Alongside the existing + e-ink and CRT looks, MandoCode Desktop now ships a monochrome amber terminal, a vacuum-fluorescent + panel, a vector scope, a passive-matrix LCD, a Solari split-flap board, a cyanotype blueprint, a + two-ink risograph print, continuous-feed printer paper, and microfiche. Each reproduces how its + medium actually made an image rather than only borrowing its colours — the LCD ghosts instead of + glowing, the split-flap board can only show uppercase because a flap carries one printed + character, and the cyanotype is a negative, so emphasis is a whiter stroke rather than a heavier + one. The theme list is ordered so neighbours share a mechanism, which keeps it skimmable now that + there are twenty-five entries. +- **Your chat background can match the theme.** Themes that imitate a screen or a printing process + also process your background image the way that medium would have reproduced it — quantised to a + panel's few shades, rendered as amber phosphor, or laid down as two-ink halftone. On by default, + with a switch in Settings → Appearance to keep the picture in full colour. It affects only those + themes, and only when a background is set. +- **The MandoCode wordmark has its own typeface.** The name in the top-left is now set in Permanent + Marker, bundled with the app rather than fetched from a font service, so it renders identically + offline. It takes each theme's accent colour, so it re-inks itself as you switch themes. - **Watch the agent's shell commands run.** The terminal panel gains a read-only tab per agent showing every command that agent runs — the command and the folder it runs in, its output line by line as it arrives, and whether it finished, failed, or was killed for taking too long. A long @@ -192,6 +209,18 @@ for every approved plan. Desktop's version follows the engine generation, so it a sticky header, or the suggestion list a field opens when it is filled. ### Fixed +- **Secondary text is readable in every theme.** Timestamps, file paths, status lines and the hints + under headings were below the accessibility contrast floor in half the shipped themes — several + faithfully so, since palettes like Dracula and One Dark ship famously faint comment colours + upstream. Those palettes are unchanged; the text is now lifted just far enough to clear the floor, + blended toward each theme's own text colour so it stays that theme's shade rather than drifting + grey. A theme that was already readable is left exactly as it was. Themes viewed through an + overlay, such as the CRT tube, are held to a higher bar, because scanlines take contrast the + palette's own numbers do not account for. +- **The CRT theme's phosphor glow follows the text it surrounds.** Every glyph glowed the same + blue regardless of its colour, so red errors carried a blue halo. On a real tube the glow is the + phosphor being excited, so error text now glows red and success green. Code blocks keep a tighter + glow, since a full halo turns syntax highlighting into a smear. - **The "no agents open" message stays readable over a chat background image.** With a background image set, closing the last agent left the message painted straight onto the picture, where a busy or light image could make it hard to read. The message now sits in a translucent card — the diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt index ac3ae2f..a370897 100644 --- a/THIRD-PARTY-NOTICES.txt +++ b/THIRD-PARTY-NOTICES.txt @@ -14,6 +14,17 @@ highlight.js — https://highlightjs.org Copyright (c) 2006, Ivan Sagalaev License: BSD 3-Clause (Appendix C) +-------------------------------------------------------------------------- +Bundled fonts (shipped in Assets/fonts, used for the application wordmark) +-------------------------------------------------------------------------- + +Permanent Marker — https://fonts.google.com/specimen/Permanent+Marker + Copyright (c) 2010 Font Diner, Inc. + License: Apache License 2.0 (Appendix D) + The unmodified font file ships with its full license text alongside it, at + Assets/fonts/LICENSE.txt. Upstream publishes no NOTICE file for this family, + so section 4(d) of the license imposes no further attribution. + -------------------------------------------------------------------------- .NET package dependencies (compiled into the application) -------------------------------------------------------------------------- @@ -152,3 +163,26 @@ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------- +Appendix D — Apache License 2.0 (Permanent Marker) +-------------------------------------------------------------------------- + +Licensed under the Apache License, Version 2.0 (the "License"); you may not +use this file except in compliance with the License. You may obtain a copy of +the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations under +the License. + +The complete, unabridged license text is distributed with the font itself, at +Assets/fonts/LICENSE.txt, and is installed alongside the application. + +Apache-2.0 is a permissive, non-copyleft license. Including this font does not +place any condition on the terms of MandoCode Desktop's own source, which +remains under the MIT License (see LICENSE). diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index 965e803..e097e44 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -60,6 +60,8 @@ + + - + + @@ -1216,6 +1234,22 @@ + + + + + + + + + + + + + WinExe @@ -33,6 +33,14 @@ PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/src/MandoCode.Desktop/Services/ColorMath.cs b/src/MandoCode.Desktop/Services/ColorMath.cs new file mode 100644 index 0000000..3db6ed7 --- /dev/null +++ b/src/MandoCode.Desktop/Services/ColorMath.cs @@ -0,0 +1,82 @@ +namespace MandoCode.Desktop.Services; + +/// +/// WCAG contrast arithmetic over #RRGGBB strings. Deliberately free of any UI color type so the +/// theme palette can be checked without a UI thread — the shipped themes are asserted against the +/// readability floor in tests, which is what stops a new theme landing below it. +/// +public static class ColorMath +{ + /// WCAG AA for normal-size text. Secondary/"dim" text is still text. + public const double AaNormalText = 4.5; + + /// + /// The floor for text read THROUGH a darkening overlay — currently the CRT tube's scanlines and + /// vignette. Raised by a third rather than computed from the overlay's exact alpha: the + /// attenuation varies by scanline row and by distance from the tube's centre, so a single + /// precise number would be false precision. This is a margin, and it is honest about being one. + /// + public const double AaNormalTextThroughGlass = 6.0; + + public static (int R, int G, int B) Parse(string hex) + { + var h = hex.TrimStart('#'); + return (Convert.ToInt32(h[..2], 16), Convert.ToInt32(h.Substring(2, 2), 16), Convert.ToInt32(h.Substring(4, 2), 16)); + } + + public static string ToHex(int r, int g, int b) => $"#{r:X2}{g:X2}{b:X2}"; + + /// WCAG relative luminance of an sRGB color. + public static double Luminance(int r, int g, int b) + { + static double Ch(int 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(r) + 0.7152 * Ch(g) + 0.0722 * Ch(b); + } + + /// WCAG contrast ratio, 1.0 (identical) to 21.0 (black on white). + public static double Contrast(string aHex, string bHex) + { + var (ar, ag, ab) = Parse(aHex); + var (br, bg, bb) = Parse(bHex); + var (la, lb) = (Luminance(ar, ag, ab), Luminance(br, bg, bb)); + return (Math.Max(la, lb) + 0.05) / (Math.Min(la, lb) + 0.05); + } + + /// Blends toward by 0–1. + public static string MixToward(string fromHex, string toHex, double amount) + { + var (fr, fg, fb) = Parse(fromHex); + var (tr, tg, tb) = Parse(toHex); + int Ch(int a, int b) => (int)Math.Round(a + (b - a) * amount); + return ToHex(Ch(fr, tr), Ch(fg, tg), Ch(fb, tb)); + } + + /// + /// Lifts until it clears against + /// , blending toward — normally the + /// theme's own body text, so the result stays that theme's colour rather than drifting to grey. + /// + /// Returns the color untouched when it already clears, so a theme whose palette is already + /// readable is never repainted. The walk is in small steps and takes the FIRST value that + /// clears, which is the least change that fixes the problem — a palette should be nudged into + /// legibility, not flattened into it. + /// + /// Terminates because is body text, which every theme already + /// keeps readable against its own background; the loop's final candidate is that color exactly. + /// + public static string Readable(string colorHex, string backgroundHex, string towardHex, double minContrast) + { + if (Contrast(colorHex, backgroundHex) >= minContrast) return colorHex; + + for (var amount = 0.05; amount < 1.0; amount += 0.05) + { + var candidate = MixToward(colorHex, towardHex, amount); + if (Contrast(candidate, backgroundHex) >= minContrast) return candidate; + } + return towardHex; + } +} diff --git a/src/MandoCode.Desktop/Services/ThemeManager.cs b/src/MandoCode.Desktop/Services/ThemeManager.cs index 85c2c61..b2bd2d5 100644 --- a/src/MandoCode.Desktop/Services/ThemeManager.cs +++ b/src/MandoCode.Desktop/Services/ThemeManager.cs @@ -5,220 +5,6 @@ namespace MandoCode.Desktop.Services; -/// -/// One selectable UI theme. Hex values (#RRGGBB) are used verbatim as the transcript's -/// CSS variables and parsed into XAML brushes, so the WebView2 chat and the native -/// pages always agree on every surface color. -/// -public sealed record UiTheme -{ - public required string Name { get; init; } - public required string Description { get; init; } - public bool IsLight { get; init; } - public required string Background { get; init; } - public required string Panel { get; init; } - public required string Border { get; init; } - public required string Text { get; init; } - public required string Dim { get; init; } - public required string Accent { get; init; } - public required string Gold { get; init; } - public required string Sky { get; init; } - public required string Green { get; init; } - 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; } - - /// When true, the transcript wears Windows-98 chrome: square corners, two-tone - /// 3D bevels lit from the top-left, navy title-bar gradients on panels, Tahoma, classic - /// chunky scrollbars. Gated in the WebView via an html[data-win98] attribute. - public bool Win98 { get; init; } - - public static readonly IReadOnlyList All = new[] - { - // First entry is the default for fresh installs (ThemeManager falls back to All[0]). - new UiTheme - { - Name = "Visual Studio Dark", - Description = "The classic VS / VS Code dark — charcoal and signature blue.", - Background = "#1E1E1E", Panel = "#252526", Border = "#3F3F46", - Text = "#D4D4D4", Dim = "#858585", - Accent = "#007ACC", Gold = "#DCDCAA", Sky = "#9CDCFE", - Green = "#89D185", Red = "#F44747", DiffAdd = "#4EC9B0", - }, - new UiTheme - { - Name = "Mando Dark", - Description = "The classic MandoCode look — deep purple, gold, and sky.", - Background = "#16121F", Panel = "#201A2E", Border = "#362C4D", - Text = "#E6E1F0", Dim = "#8F87A3", - Accent = "#C864FF", Gold = "#FFC850", Sky = "#38B6FF", - Green = "#4EC94E", Red = "#E05252", DiffAdd = "#87CEFA", - }, - new UiTheme - { - Name = "Midnight Ocean", - Description = "Deep navy and cyan — calm, cool, focused.", - Background = "#0B1622", Panel = "#132335", Border = "#27405A", - Text = "#DCE9F5", Dim = "#7C93AB", - Accent = "#22D3EE", Gold = "#FFB454", Sky = "#7AA7FF", - Green = "#34D399", Red = "#F0566A", DiffAdd = "#6EE7B7", - }, - new UiTheme - { - Name = "Phosphor Fwog", - Description = "Tree-fwog green on a dark pond — Phosphor's terminal roots, but froggier. 🐸", - Background = "#0B1410", Panel = "#15211A", Border = "#294A34", - Text = "#D8F3CF", Dim = "#7FA383", - Accent = "#63D94B", Gold = "#FFCB47", Sky = "#4FD6C2", - Green = "#63D94B", Red = "#FF6F5B", DiffAdd = "#A7E86A", - }, - new UiTheme - { - Name = "Sunset Ember", - Description = "Warm charcoal with amber and orange heat.", - Background = "#1B120D", Panel = "#281A12", Border = "#4A3222", - Text = "#F5E9DE", Dim = "#AA8F7C", - Accent = "#FF8C42", Gold = "#FFC850", Sky = "#5CB3FF", - Green = "#58C97C", Red = "#E8524A", DiffAdd = "#FFD08A", - }, - new UiTheme - { - Name = "One Dark Pro", - Description = "Atom's One Dark — the most-installed VS Code theme.", - Background = "#282C34", Panel = "#21252B", Border = "#3E4451", - Text = "#ABB2BF", Dim = "#5C6370", - Accent = "#61AFEF", Gold = "#E5C07B", Sky = "#56B6C2", - Green = "#98C379", Red = "#E06C75", DiffAdd = "#98C379", - }, - new UiTheme - { - Name = "Dracula", - Description = "The famous purple-tinted vampire palette.", - Background = "#282A36", Panel = "#343746", Border = "#44475A", - Text = "#F8F8F2", Dim = "#6272A4", - Accent = "#BD93F9", Gold = "#F1FA8C", Sky = "#8BE9FD", - Green = "#50FA7B", Red = "#FF5555", DiffAdd = "#50FA7B", - }, - new UiTheme - { - Name = "Monokai", - Description = "Sublime's legendary pink-and-lime classic.", - Background = "#272822", Panel = "#3E3D32", Border = "#49483E", - Text = "#F8F8F2", Dim = "#75715E", - Accent = "#F92672", Gold = "#E6DB74", Sky = "#66D9EF", - Green = "#A6E22E", Red = "#FF6159", DiffAdd = "#A6E22E", - }, - new UiTheme - { - Name = "Tokyo Night", - Description = "Moody indigo night with neon pastels.", - Background = "#1A1B26", Panel = "#24283B", Border = "#3B4261", - Text = "#C0CAF5", Dim = "#565F89", - Accent = "#7AA2F7", Gold = "#E0AF68", Sky = "#7DCFFF", - 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 - { - // The whole palette is era-authentic: 3D-face silver surfaces, white sunken - // content wells, the 16-color navy/olive/teal-adjacent accents (hyperlink blue - // for links), black text. FlatMotion is period-correct — nothing animated in - // 1998. The real costume is the data-win98 CSS in TranscriptHtmlBuilder: - // square corners, two-tone bevels, and navy title-bar gradients on every panel. - Name = "W98 - Y2K", - Description = "Silver bevels, navy title bars, teal desktop. Party like it's 1998. 🖥️", - IsLight = true, - FlatMotion = true, - Win98 = true, - Background = "#C0C0C0", Panel = "#FFFFFF", Border = "#808080", - Text = "#000000", Dim = "#5A5A5A", - Accent = "#000080", Gold = "#806000", Sky = "#0000CC", - Green = "#008000", Red = "#B00000", DiffAdd = "#008000", - }, - new UiTheme - { - Name = "Paper Light", - Description = "A clean light theme with royal purple accents.", - IsLight = true, - Background = "#F6F4FA", Panel = "#FFFFFF", Border = "#DCD4EA", - Text = "#241C33", Dim = "#6E6584", - Accent = "#7C2FE0", Gold = "#A87400", Sky = "#0069C2", - Green = "#1D8A3C", Red = "#C43333", DiffAdd = "#0069C2", - }, - new UiTheme - { - Name = "Solarized Light", - Description = "The warm, low-contrast cream classic.", - IsLight = true, - Background = "#FDF6E3", Panel = "#EEE8D5", Border = "#D9CFB0", - Text = "#586E75", Dim = "#93A1A1", - Accent = "#268BD2", Gold = "#B58900", Sky = "#2AA198", - Green = "#859900", Red = "#DC322F", DiffAdd = "#859900", - }, - new UiTheme - { - Name = "GitHub Light", - Description = "Bright and familiar — straight from github.com.", - IsLight = true, - Background = "#FFFFFF", Panel = "#F6F8FA", Border = "#D0D7DE", - Text = "#1F2328", Dim = "#656D76", - Accent = "#0969DA", Gold = "#9A6700", Sky = "#0550AE", - Green = "#1A7F37", Red = "#CF222E", DiffAdd = "#1A7F37", - }, - new UiTheme - { - Name = "One Light", - Description = "Atom's gentle gray-on-white counterpart to One Dark.", - IsLight = true, - Background = "#FAFAFA", Panel = "#EAEAEB", Border = "#DBDBDC", - Text = "#383A42", Dim = "#A0A1A7", - Accent = "#4078F2", Gold = "#986801", Sky = "#0184BC", - Green = "#50A14F", Red = "#E45649", DiffAdd = "#50A14F", - }, - }; -} - /// /// Applies a UiTheme across the whole app: the shared Mando* brushes (mutated in /// place, so every StaticResource reference recolors live), the system accent family @@ -256,6 +42,28 @@ public static class ThemeManager /// the CSS uses only theme variables. W98 ignores this: its message windows are bespoke. public static bool BoxedMessages { get; private set; } = true; + /// + /// When on, a chat background image is processed to match themes that simulate a display or a + /// printing process — quantised to a passive-matrix panel's four shades, laid down as riso + /// halftone, and so on. ON by default: these themes are chosen for the illusion, and a + /// full-colour photograph behind one is the thing that breaks it. Someone who wants the picture + /// untouched turns it off, which is a discoverable one-click reversal; someone who would have + /// loved the effect would never have gone looking for a switch to enable it. + /// + /// Only affects the themes that simulate a medium, and only when a background image is + /// set, so for most users and most themes this setting does nothing at all. + /// + /// E-Ink is not governed by this — its picture is grayscale-dithered unconditionally, + /// because there the halftone is not an effect applied to the theme, it IS the theme. + /// + public static bool MediaBackground { get; private set; } = true; + + public static void SetMediaBackground(bool on) + { + MediaBackground = on; + Save(); + } + public static void SetBoxedMessages(bool on) { BoxedMessages = on; @@ -295,6 +103,7 @@ public static void Initialize(FrameworkElement root) if (saved?.Opacity is > 0) WindowOpacity = Math.Clamp(saved.Opacity, 0.3, 1.0); // Null = setting predates the feature (or fresh file): take the current default. BoxedMessages = saved?.Boxed ?? true; + MediaBackground = saved?.MediaBg ?? true; if (saved?.ChatBgOpacity is > 0) ChatBackgroundOpacity = Math.Clamp(saved.ChatBgOpacity, 0.05, 1.0); if (!string.IsNullOrEmpty(saved?.ChatBackground)) { @@ -426,6 +235,7 @@ private static void Save() ChatBgBuiltIn = ChatBackgroundBuiltIn, ChatBgOpacity = ChatBackgroundOpacity, Boxed = BoxedMessages, + MediaBg = MediaBackground, })); } catch { /* persistence is best-effort; the setting is still applied */ } @@ -444,7 +254,7 @@ private static void ApplyCore(UiTheme t, FrameworkElement root) SetBrush(res, "MandoPanelBrush", t.Panel); SetBrush(res, "MandoBorderBrush", t.Border); SetBrush(res, "MandoTextBrush", t.Text); - SetBrush(res, "MandoDimBrush", t.Dim); + SetBrush(res, "MandoDimBrush", t.ReadableDim); SetBrush(res, "MandoRaisedBrush", Raised(t)); // Panel with alpha, for native surfaces painted over the chat background image. // 0xE6 rather than the transcript's 82%, because XAML has no backdrop blur to @@ -482,7 +292,7 @@ public static string BuildTranscriptScript(UiTheme t) => "(function(){var s=document.documentElement.style;" + $"s.setProperty('--bg','{t.Background}');" + $"s.setProperty('--fg','{t.Text}');" + - $"s.setProperty('--dim','{t.Dim}');" + + $"s.setProperty('--dim','{t.ReadableDim}');" + $"s.setProperty('--accent','{t.Accent}');" + $"s.setProperty('--gold','{t.Gold}');" + $"s.setProperty('--sky','{t.Sky}');" + @@ -502,9 +312,39 @@ public static string BuildTranscriptScript(UiTheme t) => (t.Win98 ? "document.documentElement.setAttribute('data-win98','1');" : "document.documentElement.removeAttribute('data-win98');") + + (t.Monochrome + ? "document.documentElement.setAttribute('data-mono','1');" + : "document.documentElement.removeAttribute('data-mono');") + + (t.Fanfold + ? "document.documentElement.setAttribute('data-fanfold','1');" + : "document.documentElement.removeAttribute('data-fanfold');") + + (t.Lcd + ? "document.documentElement.setAttribute('data-lcd','1');" + : "document.documentElement.removeAttribute('data-lcd');") + + (t.Riso + ? "document.documentElement.setAttribute('data-riso','1');" + : "document.documentElement.removeAttribute('data-riso');") + + (t.Vfd + ? "document.documentElement.setAttribute('data-vfd','1');" + : "document.documentElement.removeAttribute('data-vfd');") + + (t.SplitFlap + ? "document.documentElement.setAttribute('data-splitflap','1');" + : "document.documentElement.removeAttribute('data-splitflap');") + + (t.Fiche + ? "document.documentElement.setAttribute('data-fiche','1');" + : "document.documentElement.removeAttribute('data-fiche');") + + (t.Cyanotype + ? "document.documentElement.setAttribute('data-cyano','1');" + : "document.documentElement.removeAttribute('data-cyano');") + + (t.Vector + ? "document.documentElement.setAttribute('data-vector','1');" + : "document.documentElement.removeAttribute('data-vector');") + (BoxedMessages ? "document.documentElement.setAttribute('data-cards','1');" : "document.documentElement.removeAttribute('data-cards');") + + (MediaBackground + ? "document.documentElement.setAttribute('data-mediabg','1');" + : "document.documentElement.removeAttribute('data-mediabg');") + "})();"; /// Accent tint used for the raised surface. The strong value is what nearly every theme @@ -567,21 +407,11 @@ private static Color Raised(UiTheme t) 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). + /// WCAG contrast ratio between two colors. Thin wrapper over , + /// which owns the arithmetic so the palette can be checked without a UI type in the way. private static double Contrast(Color a, Color b) { - var (la, lb) = (Luminance(a), Luminance(b)); + var (la, lb) = (ColorMath.Luminance(a.R, a.G, a.B), ColorMath.Luminance(b.R, b.G, b.B)); return (Math.Max(la, lb) + 0.05) / (Math.Min(la, lb) + 0.05); } @@ -628,5 +458,9 @@ private sealed class UiSettings /// Nullable on purpose: absent (pre-feature settings file) means "use the /// current default", so changing the default never fights a user's explicit choice. public bool? Boxed { get; set; } + /// Nullable for the same reason as : absent means the current + /// default rather than a stored "off", so an existing settings file written before this + /// setting existed picks the default up instead of being pinned to the old behaviour. + public bool? MediaBg { get; set; } } } diff --git a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs index c01077c..b920cb2 100644 --- a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs +++ b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs @@ -374,7 +374,7 @@ private static string ReadWebAsset(string fileName) => File.ReadAllText( /// UiTheme; ThemeManager.BuildTranscriptScript re-points the same CSS variables at runtime. public static string BaseDocument(UiTheme theme) => $$""" - + @@ -382,7 +382,7 @@ public static string BaseDocument(UiTheme theme) => $$""" :root { --bg: {{theme.Background}}; --fg: {{theme.Text}}; - --dim: {{theme.Dim}}; + --dim: {{theme.ReadableDim}}; --accent: {{theme.Accent}}; --gold: {{theme.Gold}}; --sky: {{theme.Sky}}; @@ -423,6 +423,225 @@ public static string BaseDocument(UiTheme theme) => $$""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/MandoCode.Desktop/Services/UiTheme.cs b/src/MandoCode.Desktop/Services/UiTheme.cs new file mode 100644 index 0000000..dc85e09 --- /dev/null +++ b/src/MandoCode.Desktop/Services/UiTheme.cs @@ -0,0 +1,500 @@ +namespace MandoCode.Desktop.Services; + +/// +/// One selectable UI theme. Hex values (#RRGGBB) are used verbatim as the transcript's +/// CSS variables and parsed into XAML brushes, so the WebView2 chat and the native +/// pages always agree on every surface color. +/// +public sealed record UiTheme +{ + public required string Name { get; init; } + public required string Description { get; init; } + public bool IsLight { get; init; } + public required string Background { get; init; } + public required string Panel { get; init; } + public required string Border { get; init; } + public required string Text { get; init; } + public required string Dim { get; init; } + public required string Accent { get; init; } + public required string Gold { get; init; } + public required string Sky { get; init; } + public required string Green { get; init; } + 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; } + + /// When true, the transcript wears Windows-98 chrome: square corners, two-tone + /// 3D bevels lit from the top-left, navy title-bar gradients on panels, Tahoma, classic + /// chunky scrollbars. Gated in the WebView via an html[data-win98] attribute. + public bool Win98 { get; init; } + + /// + /// When true, the transcript has ONE hue available and syntax highlighting must carry meaning + /// through brightness and weight instead of colour — the discipline a single-phosphor terminal + /// forces on you. Pairs with rather than replacing it: an amber tube is still + /// a tube, so it keeps the scanlines and the bloom. Gated via html[data-mono]. + /// + public bool Monochrome { get; init; } + + /// + /// When true, the transcript is continuous-feed printer paper: alternating pale bands locked to + /// the text's own line grid, and sprocket-hole margins down both edges. Gated via + /// html[data-fanfold]. + /// + public bool Fanfold { get; init; } + + /// + /// When true, the transcript is a passive-matrix LCD: a visible pixel grid and a smeared ghost + /// instead of a glow. The deliberate opposite of — liquid crystal does not + /// emit light, it twists it, so nothing blooms and nothing is ever truly black. Gated via + /// html[data-lcd]. + /// + public bool Lcd { get; init; } + + /// + /// When true, the transcript is a two-ink risograph print: toothy paper, mottled ink coverage, + /// and a one-pixel misregistration between the two drums. Gated via html[data-riso]. + /// + public bool Riso { get; init; } + + /// + /// When true, the transcript is a vacuum-fluorescent display — the cyan-green of a VCR clock. + /// Emissive like a tube but with none of a tube's geometry: no scanlines, no grille, no + /// curvature. A segment either lights or it doesn't, so the glow has a hard edge rather than a + /// swept beam's halo. Gated via html[data-vfd]. + /// + public bool Vfd { get; init; } + + /// + /// When true, the transcript is a Solari split-flap board. The only MECHANICAL surface here: + /// each character is a printed flap, so text is forced to uppercase and monospace — a flap + /// carries one glyph and the machine has no others. Gated via html[data-splitflap]. + /// + public bool SplitFlap { get; init; } + + /// + /// When true, the transcript is film in a microfiche reader: cool, soft-focus, heavily + /// vignetted by the lens. The tell is that dust and scratches sit on the READER'S GLASS, not on + /// the film — so they stay put while the page scrolls beneath them. Gated via html[data-fiche]. + /// + public bool Fiche { get; init; } + + /// + /// When true, the transcript is a cyanotype. The one NEGATIVE surface: glyphs are where the + /// light did not reach, so text is unexposed paper on Prussian blue and "bolder" means less + /// exposure, not more ink. Gated via html[data-cyano]. + /// + public bool Cyanotype { get; init; } + + /// + /// When true, the transcript is drawn by an electrostatically deflected beam on a long- + /// persistence scope: no raster at all, so no scanlines — just a bright trace and the fading + /// ghost of where the beam has already been. Gated via html[data-vector]. + /// + public bool Vector { get; init; } + + /// + /// , raised if necessary until it is actually readable on this theme's own + /// background. Every consumer should use this rather than directly. + /// + /// Why: secondary text — timestamps, file paths, status lines, the hint under a heading — + /// is still text, and half the shipped palettes put Dim below the WCAG AA floor of 4.5:1. Most + /// of those are faithful to their upstream schemes (Dracula and One Dark ship famously faint + /// comment colours), so the palettes are kept as authored and corrected here instead. A theme + /// already above the floor is returned untouched. + /// + /// Blended toward this theme's own , so a lifted Dim stays recognisably + /// that theme's colour instead of drifting toward grey. + /// + public string ReadableDim => ColorMath.Readable(Dim, Background, Text, DimContrastFloor); + + /// + /// The contrast this theme's secondary text has to clear. Normally the WCAG AA floor, but + /// themes are held higher. + /// + /// Why: every other contrast figure in this app compares a colour against the colour + /// BEHIND it. The CRT overlay is painted in FRONT — a 22%-black scanline covering half the rows, + /// plus an edge vignette — so a CRT palette's measured contrast is not the contrast a reader + /// actually gets. Body text has enormous headroom there and never notices; dim text is the + /// thinnest thing on the screen and does. The overlay's attenuation is fixed and known, so the + /// floor can simply account for it rather than pretending the glass isn't there. + /// + private double DimContrastFloor => Crt ? ColorMath.AaNormalTextThroughGlass : ColorMath.AaNormalText; + + public static readonly IReadOnlyList All = new[] + { + // Ordered dark → light, and within each half: ordinary colour schemes first, then the + // themes that imitate a physical medium. The media run is sequenced by HOW THE IMAGE IS + // MADE rather than by how it looks — swept beam, steered beam, lit segment, moving part, + // chemical process, then the transmissive and printed surfaces. Neighbours in this list + // are therefore neighbours in mechanism, which is what makes the picker skimmable once + // there are two dozen entries: Cathode Ray sits beside the other tubes and not beside the + // other green things. + // + // First entry is the default for fresh installs (ThemeManager falls back to All[0]). + new UiTheme + { + Name = "Visual Studio Dark", + Description = "The classic VS / VS Code dark — charcoal and signature blue.", + Background = "#1E1E1E", Panel = "#252526", Border = "#3F3F46", + Text = "#D4D4D4", Dim = "#858585", + Accent = "#007ACC", Gold = "#DCDCAA", Sky = "#9CDCFE", + Green = "#89D185", Red = "#F44747", DiffAdd = "#4EC9B0", + }, + new UiTheme + { + Name = "Mando Dark", + Description = "The classic MandoCode look — deep purple, gold, and sky.", + Background = "#16121F", Panel = "#201A2E", Border = "#362C4D", + Text = "#E6E1F0", Dim = "#8F87A3", + Accent = "#C864FF", Gold = "#FFC850", Sky = "#38B6FF", + Green = "#4EC94E", Red = "#E05252", DiffAdd = "#87CEFA", + }, + new UiTheme + { + Name = "Midnight Ocean", + Description = "Deep navy and cyan — calm, cool, focused.", + Background = "#0B1622", Panel = "#132335", Border = "#27405A", + Text = "#DCE9F5", Dim = "#7C93AB", + Accent = "#22D3EE", Gold = "#FFB454", Sky = "#7AA7FF", + Green = "#34D399", Red = "#F0566A", DiffAdd = "#6EE7B7", + }, + new UiTheme + { + Name = "Sunset Ember", + Description = "Warm charcoal with amber and orange heat.", + Background = "#1B120D", Panel = "#281A12", Border = "#4A3222", + Text = "#F5E9DE", Dim = "#AA8F7C", + Accent = "#FF8C42", Gold = "#FFC850", Sky = "#5CB3FF", + Green = "#58C97C", Red = "#E8524A", DiffAdd = "#FFD08A", + }, + new UiTheme + { + Name = "One Dark Pro", + Description = "Atom's One Dark — the most-installed VS Code theme.", + Background = "#282C34", Panel = "#21252B", Border = "#3E4451", + Text = "#ABB2BF", Dim = "#5C6370", + Accent = "#61AFEF", Gold = "#E5C07B", Sky = "#56B6C2", + Green = "#98C379", Red = "#E06C75", DiffAdd = "#98C379", + }, + new UiTheme + { + Name = "Dracula", + Description = "The famous purple-tinted vampire palette.", + Background = "#282A36", Panel = "#343746", Border = "#44475A", + Text = "#F8F8F2", Dim = "#6272A4", + Accent = "#BD93F9", Gold = "#F1FA8C", Sky = "#8BE9FD", + Green = "#50FA7B", Red = "#FF5555", DiffAdd = "#50FA7B", + }, + new UiTheme + { + Name = "Monokai", + Description = "Sublime's legendary pink-and-lime classic.", + Background = "#272822", Panel = "#3E3D32", Border = "#49483E", + Text = "#F8F8F2", Dim = "#75715E", + Accent = "#F92672", Gold = "#E6DB74", Sky = "#66D9EF", + Green = "#A6E22E", Red = "#FF6159", DiffAdd = "#A6E22E", + }, + new UiTheme + { + Name = "Tokyo Night", + Description = "Moody indigo night with neon pastels.", + Background = "#1A1B26", Panel = "#24283B", Border = "#3B4261", + Text = "#C0CAF5", Dim = "#565F89", + Accent = "#7AA2F7", Gold = "#E0AF68", Sky = "#7DCFFF", + Green = "#9ECE6A", Red = "#F7768E", DiffAdd = "#9ECE6A", + }, + new UiTheme + { + Name = "Phosphor Fwog", + Description = "Tree-fwog green on a dark pond — Phosphor's terminal roots, but froggier. 🐸", + Background = "#0B1410", Panel = "#15211A", Border = "#294A34", + Text = "#D8F3CF", Dim = "#7FA383", + Accent = "#63D94B", Gold = "#FFCB47", Sky = "#4FD6C2", + Green = "#63D94B", Red = "#FF6F5B", DiffAdd = "#A7E86A", + }, + 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. + 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 + { + // Pheteven Phosphor — a single-gun monochrome terminal. Crt=true because an amber tube is + // still a tube (scanlines, grille, damper wires, bloom all apply); Monochrome=true adds + // the constraint the hardware imposes: one phosphor, so syntax highlighting carries + // meaning in brightness and weight instead of hue. That constraint IS the theme — + // amber over a colour scheme would just be a filter. + // + // #FFB000 is the P3 amber those tubes actually used. P3 has a noticeably longer decay + // than the P1 green next door, which is why the bloom reads wider and warmer here even + // though both themes share the CRT block. Every "accent" is a different amber rather + // than a different colour, for the same reason E-Ink collapses its accents to ink. + // Sits beside Sunset Ember, the warm dark theme it shares a temperature with. + // The name is a nod, and it hides in plain sight: anyone who doesn't know Pheteven reads + // it as another Ph- pun in the Phosphor family and moves on. Sits well beside Phosphor + // Fwog, which set that precedent. + Name = "Pheteven Phosphor", + Description = "One-gun amber tube — brightness carries meaning, because hue can't. 🟠", + Crt = true, + Monochrome = true, + Background = "#100A02", Panel = "#1A1206", Border = "#3A2A0C", + Text = "#FFB000", Dim = "#B07704", + Accent = "#FFD07A", Gold = "#FFC340", Sky = "#FFCE6B", + Green = "#FFC340", Red = "#FF8A3D", DiffAdd = "#FFD07A", + }, + new UiTheme + { + // Vector Scope — an electrostatically deflected beam on a long-persistence phosphor. + // The one emissive theme with NO scanlines, because there is no raster: the beam is + // steered straight to where the stroke goes. What it leaves is persistence, the fading + // ghost of where it has already been, which the CSS offsets slightly up and left. + // + // Built deliberately without the round aperture and the scintillation noise a real + // scope has. Both are period-correct and both are hostile to prose — a circular mask + // wastes a rectangular pane and the noise fights the text. This keeps the phosphor, + // which is the part that reads well. + Name = "Vector Scope", + Description = "Steered beam on long-persistence phosphor — no raster, all trace. 📡", + Vector = true, + // Softened from the first pass, which was accurate and unreadable. Neon green on + // near-pure black, glowing, is genuinely what a scope looks like and genuinely painful + // to read for more than a minute. The background lifts off absolute black and the + // phosphor comes down off full saturation — still unmistakably a scope, no longer a + // light source pointed at you. + Background = "#060F09", Panel = "#0C1A11", Border = "#1B4227", + Text = "#79D992", Dim = "#3C7A4F", + Accent = "#A9E070", Gold = "#CFE07E", Sky = "#79D9BE", + Green = "#79D992", Red = "#D9907C", DiffAdd = "#A9E070", + }, + new UiTheme + { + // VFD — a vacuum-fluorescent display, the cyan-green of a VCR clock or a car stereo. + // Fills the gap between the tubes and the panel: emissive like a CRT, but with none of + // a tube's geometry, because there is no beam being swept. A segment is energised or it + // is not, so the CSS gives it a hard-edged glow rather than a phosphor halo. + // + // The unlit field is nearly black on purpose — a VFD sits behind a smoked filter, so + // the panel reads as a hole cut into the device rather than a dark grey screen. + Name = "VFD", + Description = "Vacuum-fluorescent cyan behind smoked glass — the VCR clock look. 📟", + Vfd = true, + Background = "#05080A", Panel = "#0B1114", Border = "#1B2B30", + Text = "#7FFFE4", Dim = "#3F8F82", + Accent = "#5BE8FF", Gold = "#FFD86B", Sky = "#5BE8FF", + Green = "#7FFFE4", Red = "#FF7A6B", DiffAdd = "#7FFFE4", + }, + new UiTheme + { + // Split-Flap — a Solari departure board, and the only mechanical surface in the set. + // Its constraint is about which GLYPHS exist rather than which colours do: a flap + // carries one printed character, so the board can only show the set it was built with. + // The CSS forces uppercase and monospace for that reason — the machine's real limit, + // not a styling choice — and draws the hinge seam through the waist of every cell. + // + // Source code is the one place that breaks: uppercasing it would corrupt it, so code + // keeps its own case. A board that had to print a stack trace would have the same + // problem and no way to solve it. + Name = "Split-Flap", + Description = "Solari departure board — hinged flaps, all caps, mechanical clatter. 🛫", + SplitFlap = true, + Background = "#101014", Panel = "#191920", Border = "#2E2E38", + Text = "#F5EAC8", Dim = "#8E866F", + Accent = "#FFD24A", Gold = "#FFD24A", Sky = "#EDE3C4", + Green = "#BFE08A", Red = "#E8836E", DiffAdd = "#BFE08A", + }, + new UiTheme + { + // Cyanotype — a contact print in a tray, and the only NEGATIVE surface here. The + // process exposes everywhere light reaches and washes out everywhere it does not, so + // the glyphs are unexposed paper and the field is Prussian blue. That inverts what + // emphasis means: bolder is LESS exposure, so the CSS renders weight as a whiter + // stroke rather than a heavier one. + // + // No glow of any kind. Every other dark theme in this set emitted light; this one was + // developed in water and hung up to dry. + Name = "Cyanotype", + Description = "Blueprint negative — unexposed paper on Prussian blue, brushed on. 📐", + Cyanotype = true, + Background = "#0E3A5F", Panel = "#13456D", Border = "#2A6389", + Text = "#EDF4F8", Dim = "#9DBED4", + Accent = "#FFFFFF", Gold = "#E6D9A8", Sky = "#C9E2F0", + Green = "#C4E5D2", Red = "#F3B5A8", DiffAdd = "#C4E5D2", + }, + new UiTheme + { + // Matrix LCD — a passive-matrix panel, and the deliberate opposite of Cathode Ray. + // Liquid crystal doesn't emit light, it twists a backlight through a polarizer, so + // nothing blooms. What a passive matrix does instead is SMEAR: row/column addressing + // can't switch a cell cleanly, so the previous frame lingers a moment behind the new + // one. The CSS renders that as a hard ghost offset one pixel down-right. + // + // Nothing here is true black — "off" is the polarizer at its darkest, which on these + // panels is a muddy olive, and the background is the backlight leaking through. Getting + // that wrong (pure black, bright green) is what makes most LCD themes look like a + // colour scheme rather than a panel. + Name = "Matrix LCD", + Description = "Passive-matrix panel — pixel grid, ghosting smear, no glow at all. 🎮", + IsLight = true, + Lcd = true, + Background = "#C4CFA1", Panel = "#B7C393", Border = "#8A9A6B", + Text = "#1B2410", Dim = "#4A5730", + Accent = "#33421C", Gold = "#3E4D22", Sky = "#33421C", + Green = "#33421C", Red = "#4F3A18", DiffAdd = "#33421C", + }, + new UiTheme + { + // Warm paper + black ink, fully grayscale — reads like a Kindle page. 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 + { + // Risograph — a two-drum stencil duplicator. One spot ink per drum, one pass each, and + // the paper shifts a hair between passes, so the two never line up. The CSS puts that + // misregistration one pixel off in the second ink; it's the entire signature, and no + // amount of paper texture reads as riso without it. + // + // Two drums means exactly two inks, so the palette has two and the highlighter is + // re-mapped rather than recoloured — a third hue would need a third pass the machine + // doesn't have loaded. Federal Blue and Fluorescent Pink are the two most-printed riso + // inks, which is why the combination looks instantly familiar. + Name = "Risograph", + Description = "Two-ink stencil print — mottled coverage and a pixel of misregistration. 🎨", + IsLight = true, + Riso = true, + Background = "#F3EFE6", Panel = "#E7E1D3", Border = "#C0B7A4", + Text = "#241F1B", Dim = "#6E6558", + Accent = "#1A4A8F", Gold = "#8A5A1C", Sky = "#1A4A8F", + Green = "#1A4A8F", Red = "#C42A63", DiffAdd = "#1A4A8F", + }, + new UiTheme + { + // Green-Bar Fanfold — continuous-feed printer paper. FlatMotion because paper doesn't + // animate, and the CSS locks the pale bands to the text's own line grid (six lines per + // band) rather than to pixels: bands that drift across the lines read as wallpaper, + // bands that contain exactly six lines read as fanfold. Sprocket margins and the + // tear-off perforations run down both edges. + // + // The palette is deliberately ink-on-paper rather than colourful: a line printer had + // one ribbon, so "colour" here means how hard the hammer struck. Red is the one + // exception, and it earns it — two-colour ribbons (black over red) were real, and + // errors are exactly what the red half was for. + Name = "Green-Bar Fanfold", + Description = "Tractor-feed printer paper — green bars, sprocket holes, ribbon ink. 🖨️", + IsLight = true, + FlatMotion = true, + Fanfold = true, + Background = "#F4F1E4", Panel = "#EAE6D5", Border = "#C3BFA8", + Text = "#2A2822", Dim = "#6B6656", + Accent = "#3E6B47", Gold = "#8A6A22", Sky = "#3E6B47", + Green = "#3E6B47", Red = "#9E3227", DiffAdd = "#3E6B47", + }, + new UiTheme + { + // Microfiche — a projected positive in a reader: cool, soft, and vignetted hard by the + // lens. Cheapest of these to build, because the tell was already available: dust and + // scratches belong to the reader's GLASS, not to the film, so they hold still while the + // page scrolls beneath them. The overlay was already fixed; only the interpretation is + // new. + // + // Light, because a fiche reader projects a positive onto a lit screen — making this + // dark would be a photographic negative, which is a different object entirely. + Name = "Microfiche", + Description = "Film in a reader — soft focus, lens vignette, dust on the glass. 🔍", + IsLight = true, + Fiche = true, + Background = "#D9DFE2", Panel = "#CBD3D7", Border = "#A5B0B6", + Text = "#16212A", Dim = "#556570", + Accent = "#2C5468", Gold = "#6B5A33", Sky = "#2C5468", + Green = "#2F5A4A", Red = "#7A3229", DiffAdd = "#2F5A4A", + }, + new UiTheme + { + // The whole palette is era-authentic: 3D-face silver surfaces, white sunken + // content wells, the 16-color navy/olive/teal-adjacent accents (hyperlink blue + // for links), black text. FlatMotion is period-correct — nothing animated in + // 1998. The real costume is the data-win98 CSS in TranscriptHtmlBuilder: + // square corners, two-tone bevels, and navy title-bar gradients on every panel. + Name = "W98 - Y2K", + Description = "Silver bevels, navy title bars, teal desktop. Party like it's 1998. 🖥️", + IsLight = true, + FlatMotion = true, + Win98 = true, + Background = "#C0C0C0", Panel = "#FFFFFF", Border = "#808080", + Text = "#000000", Dim = "#5A5A5A", + Accent = "#000080", Gold = "#806000", Sky = "#0000CC", + Green = "#008000", Red = "#B00000", DiffAdd = "#008000", + }, + new UiTheme + { + Name = "Paper Light", + Description = "A clean light theme with royal purple accents.", + IsLight = true, + Background = "#F6F4FA", Panel = "#FFFFFF", Border = "#DCD4EA", + Text = "#241C33", Dim = "#6E6584", + Accent = "#7C2FE0", Gold = "#A87400", Sky = "#0069C2", + Green = "#1D8A3C", Red = "#C43333", DiffAdd = "#0069C2", + }, + new UiTheme + { + Name = "Solarized Light", + Description = "The warm, low-contrast cream classic.", + IsLight = true, + Background = "#FDF6E3", Panel = "#EEE8D5", Border = "#D9CFB0", + Text = "#586E75", Dim = "#93A1A1", + Accent = "#268BD2", Gold = "#B58900", Sky = "#2AA198", + Green = "#859900", Red = "#DC322F", DiffAdd = "#859900", + }, + new UiTheme + { + Name = "GitHub Light", + Description = "Bright and familiar — straight from github.com.", + IsLight = true, + Background = "#FFFFFF", Panel = "#F6F8FA", Border = "#D0D7DE", + Text = "#1F2328", Dim = "#656D76", + Accent = "#0969DA", Gold = "#9A6700", Sky = "#0550AE", + Green = "#1A7F37", Red = "#CF222E", DiffAdd = "#1A7F37", + }, + new UiTheme + { + Name = "One Light", + Description = "Atom's gentle gray-on-white counterpart to One Dark.", + IsLight = true, + Background = "#FAFAFA", Panel = "#EAEAEB", Border = "#DBDBDC", + Text = "#383A42", Dim = "#A0A1A7", + Accent = "#4078F2", Gold = "#986801", Sky = "#0184BC", + Green = "#50A14F", Red = "#E45649", DiffAdd = "#50A14F", + }, + }; + +}