diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bbb88c..45d9210 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,12 +10,25 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
+- **More theme colours.** `ThemeRole` gains `Subtle` (text quieter than muted), `Link`, `Highlight` (a
+ background behind marked text) and eight hues, `Red`, `Orange`, `Yellow`, `Green`, `Cyan`, `Blue`,
+ `Purple` and `Pink`, which each theme draws in its own way, as a terminal colour scheme draws its red.
+ Every preset, `FromBase16` (`base02` highlight, `base08` to `base0E` the hues) and `Generate` set them;
+ a JSON palette works out the ones it leaves out (`ThemePalette.Completed`).
+- **A page can theme the sixteen ANSI colours.** HTML writes a standard colour as
+ `var(--ms-ansi-N, #hex)`, so a page that sets `--ms-ansi-0` to `--ms-ansi-15` gives `ansi()` colours
+ its own scheme, and `--ms-ansi-bg-N` for backgrounds. A page that sets none shows the same colours as before.
- **Text for a client without Unicode.** `AsciiFold` replaces each character an ASCII-only or
Latin-1 client cannot show with the nearest one it can (`·` to `*`, `—` to `-`, `é` to `e`, `?` for
the rest), keeping markup and, for the built-in stand-ins, each character's width. A game's own
stand-ins come first. `LayoutContext.Fold` folds the text inside every block before it is
measured, through the new `Block.MapText`, so tables stay aligned whatever the stand-ins' widths.
+### Changed
+
+- **A base16 palette's tertiary colour is `base09` (orange),** not `base0C`, which info already used,
+ so bullets and info text are no longer the same colour.
+
## 2.16.0 — 2026-10-09
### Added
diff --git a/MarkupString.Ansi/AnsiCss.cs b/MarkupString.Ansi/AnsiCss.cs
index 26dd99f..e9ffd86 100644
--- a/MarkupString.Ansi/AnsiCss.cs
+++ b/MarkupString.Ansi/AnsiCss.cs
@@ -6,6 +6,14 @@ namespace MarkupString.Ansi;
/// inline instead (see AnsiHtmlEmitter). A page that renders
/// includes this once.
///
+///
+/// The sixteen standard colours are written as var(--ms-ansi-0, #000000) to
+/// var(--ms-ansi-15, #ffffff): 0-7 black, red, green, yellow, blue, magenta, cyan and white, 8-15
+/// their bright forms. A page sets those properties to give ansi() colours its own scheme, as a
+/// terminal's colour scheme does; one that sets none shows the usual colours. A background is written as
+/// var(--ms-ansi-bg-N, var(--ms-ansi-N, #hex)), so a page whose colours are made to read as text can
+/// set darker or lighter ones for behind text.
+///
public static class AnsiCss
{
///
diff --git a/MarkupString.Ansi/Emitters/AnsiHtmlEmitter.cs b/MarkupString.Ansi/Emitters/AnsiHtmlEmitter.cs
index b322aa9..0b63e2e 100644
--- a/MarkupString.Ansi/Emitters/AnsiHtmlEmitter.cs
+++ b/MarkupString.Ansi/Emitters/AnsiHtmlEmitter.cs
@@ -6,7 +6,10 @@ namespace MarkupString.Ansi;
/// Renders a run as one HTML element: the folded style becomes a single
/// <span style="…" class="…">, with colours as inline hex (they are open-ended, so a
/// stylesheet cannot enumerate them) and attributes as ms-* classes (a fixed set, which a
-/// stylesheet can). A link becomes the anchor inside that span.
+/// stylesheet can). A link becomes the anchor inside that span. The sixteen standard colours are
+/// written as var(--ms-ansi-N, #hex), and as a background var(--ms-ansi-bg-N, var(--ms-ansi-N, #hex)),
+/// so a page can set its own as a terminal's colour scheme does (), and shows the usual
+/// colour when it does not.
///
public sealed class AnsiHtmlEmitter : IMarkupSetEmitter
{
@@ -35,6 +38,8 @@ private static void WriteSpan(in AnsiStyle style, ReadOnlySpan body, in Em
// own colour shows through.
var foregroundHex = foreground?.ToHex() ?? string.Empty;
var backgroundHex = background?.ToHex() ?? string.Empty;
+ var foregroundSlot = SlotOf(foreground);
+ var backgroundSlot = SlotOf(background);
var hasStyle = foregroundHex.Length > 0 || backgroundHex.Length > 0;
var hasClasses = HasClasses(style, hasStyle);
@@ -52,13 +57,13 @@ private static void WriteSpan(in AnsiStyle style, ReadOnlySpan body, in Em
if (foregroundHex.Length > 0)
{
output.Write("color: ");
- output.Write(foregroundHex);
+ WriteColor(foregroundSlot, foregroundHex, background: false, output);
}
if (foregroundHex.Length > 0 && backgroundHex.Length > 0) output.Write("; ");
if (backgroundHex.Length > 0)
{
output.Write("background-color: ");
- output.Write(backgroundHex);
+ WriteColor(backgroundSlot, backgroundHex, background: true, output);
}
output.Write("\"");
}
@@ -75,6 +80,33 @@ private static void WriteSpan(in AnsiStyle style, ReadOnlySpan body, in Em
output.Write("");
}
+ /// The standard colour, 0-15, is, or -1 for any other.
+ private static int SlotOf(AnsiColor? color) => color switch
+ {
+ AnsiColor.Standard standard => standard.Index + (standard.Bright ? 8 : 0),
+ AnsiColor.Xterm { Index: < 16 } xterm => xterm.Index,
+ _ => -1,
+ };
+
+ /// The custom property a page sets standard colour N with: --ms-ansi-N.
+ private static readonly string[] SlotProperties = [.. Enumerable.Range(0, 16).Select(slot => $"var(--ms-ansi-{slot}, ")];
+
+ /// The one it sets standard colour N as a background with, --ms-ansi-bg-N, falling back on --ms-ansi-N.
+ private static readonly string[] BackgroundSlotProperties = [.. Enumerable.Range(0, 16).Select(slot => $"var(--ms-ansi-bg-{slot}, var(--ms-ansi-{slot}, ")];
+
+ /// A colour: a standard one as its custom property with to fall back on, any other as .
+ private static void WriteColor(int slot, string hex, bool background, IBufferWriter output)
+ {
+ if (slot < 0)
+ {
+ output.Write(hex);
+ return;
+ }
+ output.Write(background ? BackgroundSlotProperties[slot] : SlotProperties[slot]);
+ output.Write(hex);
+ output.Write(background ? "))" : ")");
+ }
+
///
/// Whether the run needs a class attribute at all. only earns
/// one when there is no colour for it to swap — ms-invert takes over the swap itself,
diff --git a/MarkupString.AotSmoke/Program.cs b/MarkupString.AotSmoke/Program.cs
index bf0167a..6b1908b 100644
--- a/MarkupString.AotSmoke/Program.cs
+++ b/MarkupString.AotSmoke/Program.cs
@@ -92,7 +92,7 @@
Expect(rendered["plain"], "日本語テキスト", "plain");
Expect(rendered["ansi"], "\e[1;31m", "ansi");
Expect(rendered["html"], "", "html");
-Expect(rendered["html"], "color: #ff5555", "html");
+Expect(rendered["html"], "color: var(--ms-ansi-9, #ff5555)", "html");
Expect(rendered["pueblo"], "x");
+ .IsEqualTo("x");
}
// ── A foreign layer between two of this package's ────────────────────────────
@@ -127,7 +127,7 @@ public async Task Html_ForeignLayerBetweenTwoAnsiLayers_KeepsItBetweenThem()
var text = MarkupText.Wrap(Red, MarkupText.Wrap(new Tag("t"), MarkupText.Wrap(Bold, "x")));
await Assert.That(Render(text, MarkupFormat.Html))
- .IsEqualTo("x");
+ .IsEqualTo("x");
}
///
@@ -228,6 +228,6 @@ public async Task Html_FormatSpecificStyleSourceWithAnsiLayer_TagSitsInsideTheCo
{
var text = MarkupText.Wrap(Red, MarkupText.Wrap(new BoldTag(), "x"));
await Assert.That(Render(text, MarkupFormat.Html))
- .IsEqualTo("x");
+ .IsEqualTo("x");
}
}
diff --git a/MarkupString.Tests/Ansi/AnsiMarkupCodecTests.cs b/MarkupString.Tests/Ansi/AnsiMarkupCodecTests.cs
index 8f19272..c8d60ee 100644
--- a/MarkupString.Tests/Ansi/AnsiMarkupCodecTests.cs
+++ b/MarkupString.Tests/Ansi/AnsiMarkupCodecTests.cs
@@ -193,7 +193,7 @@ public async Task Read_LegacyPayload_StillRenders()
{
var text = MarkupTextSerializer.Deserialize(Palette("\"f\":[1,31],\"lu\":\"look\",\"lk\":1"), Registry);
await Assert.That(text.Render(MarkupFormat.Html, Registry))
- .IsEqualTo("x");
+ .IsEqualTo("x");
}
[Test]
diff --git a/MarkupString.Tests/Ansi/AnsiRenderTests.cs b/MarkupString.Tests/Ansi/AnsiRenderTests.cs
index 10dae17..082fa2b 100644
--- a/MarkupString.Tests/Ansi/AnsiRenderTests.cs
+++ b/MarkupString.Tests/Ansi/AnsiRenderTests.cs
@@ -118,6 +118,14 @@ public async Task Html_XtermForeground_IsResolvedToHex()
await Assert.That(Render(text, MarkupFormat.Html)).IsEqualTo("x");
}
+ [Test]
+ public async Task Html_StandardColours_AreAPropertyThePageCanSet()
+ {
+ var text = MarkupText.Wrap(AnsiMarkup.Create(foreground: new AnsiColor.Xterm(3), background: new AnsiColor.Standard(4, false)), "x");
+ await Assert.That(Render(text, MarkupFormat.Html))
+ .IsEqualTo("x");
+ }
+
[Test]
public async Task Html_DefaultForeground_CarriesNoStyle()
{
@@ -129,7 +137,7 @@ public async Task Html_DefaultForeground_CarriesNoStyle()
public async Task Html_HighlightedRed_IsTheBrightVariant()
{
var text = MarkupText.Wrap(AnsiCodeParser.Parse("hr"), "x");
- await Assert.That(Render(text, MarkupFormat.Html)).IsEqualTo("x");
+ await Assert.That(Render(text, MarkupFormat.Html)).IsEqualTo("x");
}
[Test]
@@ -225,7 +233,7 @@ public async Task Html_BodyText_IsEncoded()
{
var text = MarkupText.Wrap(Red, "x");
await Assert.That(Render(text, MarkupFormat.Html))
- .IsEqualTo("<b>x</b>");
+ .IsEqualTo("<b>x</b>");
}
[Test]
diff --git a/MarkupString.Tests/Html/HtmlMarkupTests.cs b/MarkupString.Tests/Html/HtmlMarkupTests.cs
index 1fa68ba..496b59d 100644
--- a/MarkupString.Tests/Html/HtmlMarkupTests.cs
+++ b/MarkupString.Tests/Html/HtmlMarkupTests.cs
@@ -102,7 +102,7 @@ public async Task BoldInsideRed_FoldsIntoOneSgrSequence()
// inside the colour's because that is where it was put (see AnsiForeignLayerTests.
// Html_FormatSpecificStyleSourceWithAnsiLayer_TagSitsInsideTheColourSpan).
await Assert.That(Render(text, MarkupFormat.Html))
- .IsEqualTo("x");
+ .IsEqualTo("x");
await Assert.That(Render(text, MarkupFormat.BBCode)).IsEqualTo("[color=#aa0000][b]x[/b][/color]");
}
diff --git a/MarkupString.Tests/Layout/ThemeTests.cs b/MarkupString.Tests/Layout/ThemeTests.cs
index 9ebc313..1acdcbd 100644
--- a/MarkupString.Tests/Layout/ThemeTests.cs
+++ b/MarkupString.Tests/Layout/ThemeTests.cs
@@ -71,7 +71,7 @@ public async Task Generate_FullContrastReachesSevenToOne()
{
var palette = ThemePalette.Generate(Hex("#5e81ac"), ThemeHarmony.Triadic, ThemeMode.Light, contrast: 1);
- foreach (var role in Enum.GetValues().Where(role => role is not (ThemeRole.Background or ThemeRole.Surface)))
+ foreach (var role in Enum.GetValues().Where(role => role is not (ThemeRole.Background or ThemeRole.Surface or ThemeRole.Highlight)))
await Assert.That(ColorMath.Contrast(palette[role]!.Value.Resolved, palette.BackgroundColor)).IsGreaterThanOrEqualTo(7);
}
@@ -110,6 +110,91 @@ public async Task Base16_MapsByItsOwnGuide()
await Assert.That(nord[ThemeRole.Error]).IsEqualTo(new ThemeColor(Hex("#bf616a"), 1));
await Assert.That(nord.Mode).IsEqualTo(ThemeMode.Dark);
await Assert.That(ThemePalette.Preset("solarized-light")!.Mode).IsEqualTo(ThemeMode.Light);
+ await Assert.That(nord[ThemeRole.Tertiary]).IsEqualTo(new ThemeColor(Hex("#d08770"), 3));
+ await Assert.That(nord[ThemeRole.Info]).IsEqualTo(new ThemeColor(Hex("#88c0d0"), 6));
+ await Assert.That(nord[ThemeRole.Highlight]).IsEqualTo(new ThemeColor(Hex("#434c5e"), 8));
+ await Assert.That(nord[ThemeRole.Purple]).IsEqualTo(new ThemeColor(Hex("#b48ead"), 5));
+ }
+
+ [Test]
+ public async Task EveryPreset_TellsTertiaryAndInfoApart()
+ {
+ foreach (var palette in ThemePalette.Presets)
+ await Assert.That(palette[ThemeRole.Tertiary]).IsNotEqualTo(palette[ThemeRole.Info]);
+ }
+
+ [Test]
+ public async Task EveryPreset_SetsEveryRole()
+ {
+ var missing = ThemePalette.Presets.SelectMany(palette => Enum.GetValues().Where(role => palette[role] is null).Select(role => $"{palette.Name}.{role}"));
+ // The terminal preset stripes nothing, leaving the client's own background alone.
+ await Assert.That(missing).IsEquivalentTo(["terminal.Surface"]);
+ }
+
+ [Test]
+ [Arguments(ThemeRole.Red, 27)]
+ [Arguments(ThemeRole.Orange, 55)]
+ [Arguments(ThemeRole.Yellow, 95)]
+ [Arguments(ThemeRole.Green, 145)]
+ [Arguments(ThemeRole.Cyan, 200)]
+ [Arguments(ThemeRole.Blue, 255)]
+ [Arguments(ThemeRole.Purple, 305)]
+ [Arguments(ThemeRole.Pink, 350)]
+ public async Task Generate_KeepsEachHueItsName(ThemeRole role, double hue)
+ {
+ foreach (var mode in Enum.GetValues())
+ {
+ var palette = ThemePalette.Generate(Hex("#7aa2f7"), ThemeHarmony.Analogous, mode);
+ var made = ColorMath.ToOklch(palette[role]!.Value.Rgb!.Value).H;
+ await Assert.That(Math.Abs(((made - hue) % 360 + 540) % 360 - 180)).IsLessThanOrEqualTo(25);
+ }
+ }
+
+ [Test]
+ public async Task Generate_TheForegroundReadsOnTheHighlight()
+ {
+ foreach (var mode in Enum.GetValues())
+ {
+ var palette = ThemePalette.Generate(Hex("#d6577c"), ThemeHarmony.Split, mode);
+ var highlight = palette[ThemeRole.Highlight]!.Value.Resolved;
+ await Assert.That(ColorMath.Contrast(palette[ThemeRole.Foreground]!.Value.Resolved, highlight)).IsGreaterThanOrEqualTo(4.5);
+ await Assert.That(highlight).IsNotEqualTo(palette.BackgroundColor);
+ }
+ }
+
+ [Test]
+ public async Task Json_WorksOutTheRolesItLeavesOut()
+ {
+ await Assert.That(ThemePalette.TryParse("""{"colors":{"background":"#1a1b26","foreground":"#c0caf5","primary":"#7aa2f7","muted":"#565f89","pink":null}}""", out var palette, out _)).IsTrue();
+
+ await Assert.That(palette![ThemeRole.Pink]).IsNull();
+ await Assert.That(ThemePalette.TryParse(palette.ToJson(), out var reread, out _)).IsTrue();
+ await Assert.That(reread).IsEqualTo(palette).Because("pink stays unset when it is written and read back");
+ await Assert.That(palette[ThemeRole.Link]).IsNotNull();
+ await Assert.That(palette[ThemeRole.Subtle]).IsNotNull();
+ await Assert.That(palette[ThemeRole.Highlight]).IsNotNull();
+ foreach (var hue in new[] { ThemeRole.Red, ThemeRole.Orange, ThemeRole.Yellow, ThemeRole.Green, ThemeRole.Cyan, ThemeRole.Blue, ThemeRole.Purple })
+ await Assert.That(ColorMath.Contrast(palette[hue]!.Value.Resolved, palette.BackgroundColor)).IsGreaterThanOrEqualTo(4.5);
+ }
+
+ [Test]
+ public async Task Json_TheWorkedOutSubtleIsStillReadable()
+ {
+ await Assert.That(ThemePalette.TryParse("""{"colors":{"background":"#ffffff","muted":"#777777"}}""", out var palette, out _)).IsTrue();
+
+ await Assert.That(ColorMath.Contrast(palette![ThemeRole.Subtle]!.Value.Resolved, palette.BackgroundColor)).IsGreaterThanOrEqualTo(3);
+ await Assert.That(ColorMath.Contrast(palette[ThemeRole.Subtle]!.Value.Resolved, palette.BackgroundColor))
+ .IsLessThan(ColorMath.Contrast(palette[ThemeRole.Muted]!.Value.Resolved, palette.BackgroundColor));
+ }
+
+ [Test]
+ public async Task Json_OfStandardColours_WorksOutStandardColours()
+ {
+ await Assert.That(ThemePalette.TryParse("""{"colors":{"background":0,"primary":6}}""", out var palette, out _)).IsTrue();
+
+ await Assert.That(palette![ThemeRole.Red]).IsEqualTo(ThemeColor.Standard(9));
+ await Assert.That(palette[ThemeRole.Link]).IsEqualTo(ThemeColor.Standard(6));
+ await Assert.That(palette[ThemeRole.Highlight]).IsEqualTo(ThemeColor.Standard(8));
}
[Test]
@@ -137,7 +222,7 @@ public async Task Json_ReadsEveryForm()
[Arguments("\"nowhere\"", "no theme named 'nowhere'")]
[Arguments("""{"seed":"blue"}""", "seed is a colour like #7aa2f7")]
[Arguments("""{"seed":"#123456","preset":"nord"}""", "use one of preset, base16 and seed")]
- [Arguments("""{"colors":{"accent":"#fff"}}""", "'accent' is not a role; the roles are background, surface, foreground, primary, secondary, tertiary, muted, success, warning, error, info")]
+ [Arguments("""{"colors":{"accent":"#fff"}}""", "'accent' is not a role; the roles are background, surface, foreground, primary, secondary, tertiary, muted, success, warning, error, info, subtle, link, highlight, red, orange, yellow, green, cyan, blue, purple, pink")]
[Arguments("""{"colors":{"primary":16}}""", "'16' is not a colour: use #rrggbb, a standard colour 0 to 15, or {\"rgb\":..,\"slot\":..}")]
[Arguments("""{"border":"red"}""", "a theme has no 'border'")]
public async Task Json_SaysWhatIsWrong(string json, string error)
@@ -165,7 +250,7 @@ public async Task ThePalette_ColoursTheParts()
await Assert.That(ansi).Contains("\u001b[38;2;129;161;193m┌");
await Assert.That(ansi).Contains("\u001b[1;38;2;180;142;173mSheet");
- await Assert.That(ansi).Contains("\u001b[38;2;136;192;208m•");
+ await Assert.That(ansi).Contains("\u001b[38;2;208;135;112m•");
await Assert.That(ansi).Contains("\u001b[38;2;180;142;173mName");
await Assert.That(BlockLayout.Build(box.Themed(theme), 20).ToPlainText()).IsEqualTo(BlockLayout.Build(box, 20).ToPlainText());
}
diff --git a/MarkupString.Tests/Snapshots/FormatSnapshotTests.EveryFixture_RendersStably_formatName=html.verified.txt b/MarkupString.Tests/Snapshots/FormatSnapshotTests.EveryFixture_RendersStably_formatName=html.verified.txt
index 63358ed..f4e7a12 100644
--- a/MarkupString.Tests/Snapshots/FormatSnapshotTests.EveryFixture_RendersStably_formatName=html.verified.txt
+++ b/MarkupString.Tests/Snapshots/FormatSnapshotTests.EveryFixture_RendersStably_formatName=html.verified.txt
@@ -7,22 +7,22 @@
07 blink | blink
08 inverted | inverted
09 strikethrough | strikethrough
-10 fg-standard | red
-11 fg-bright | bright red
+10 fg-standard | red
+11 fg-bright | bright red
12 fg-xterm-200 | xterm 200
13 fg-rgb | rgb
14 fg-default | default fg
-15 bg-standard | on blue
-16 bg-bright | on bright blue
+15 bg-standard | on blue
+16 bg-bright | on bright blue
17 bg-xterm-24 | on xterm 24
18 bg-rgb | on rgb
19 bg-default | default bg
20 link-url | example
21 link-command | who
-22 nested-three-deep | deep
-23 adjacent-runs | red-bold greenunder
+22 nested-three-deep | deep
+23 adjacent-runs | red-bold greenunder
24 html-send | north
-25 html-b-in-colour | bold green
-26 unicode | 日本語 😀 café
+25 html-b-in-colour | bold green
+26 unicode | 日本語 😀 café
27 needs-encoding | <a href="x"> & 'y' </a>
-28 clear-after-colour | redcleared
+28 clear-after-colour | redcleared
diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt
index b29dae6..d05ae06 100644
--- a/MarkupString/PublicAPI.Unshipped.txt
+++ b/MarkupString/PublicAPI.Unshipped.txt
@@ -26,3 +26,15 @@ override MarkupString.Layout.Themed.MapText(System.Func! map) -> MarkupString.Layout.Block!
static MarkupString.AsciiFold.Default.get -> MarkupString.AsciiFold!
virtual MarkupString.Layout.Block.MapText(System.Func! map) -> MarkupString.Layout.Block!
+MarkupString.ThemePalette.Completed() -> MarkupString.ThemePalette!
+MarkupString.ThemeRole.Blue = 19 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Cyan = 18 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Green = 17 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Highlight = 13 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Link = 12 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Orange = 15 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Pink = 21 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Purple = 20 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Red = 14 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Subtle = 11 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Yellow = 16 -> MarkupString.ThemeRole
diff --git a/MarkupString/ThemePalette.cs b/MarkupString/ThemePalette.cs
index 76e7dc8..5d9d8da 100644
--- a/MarkupString/ThemePalette.cs
+++ b/MarkupString/ThemePalette.cs
@@ -73,6 +73,39 @@ public enum ThemeRole
/// Things to know.
Info,
+
+ /// Text quieter than : timestamps, hints, what can be skipped.
+ Subtle,
+
+ /// Links and command links.
+ Link,
+
+ /// A background behind marked text: a search hit, the current item, something new. Painted as a background.
+ Highlight,
+
+ /// Red, as this theme draws it.
+ Red,
+
+ /// Orange, as this theme draws it.
+ Orange,
+
+ /// Yellow, as this theme draws it.
+ Yellow,
+
+ /// Green, as this theme draws it.
+ Green,
+
+ /// Cyan, as this theme draws it.
+ Cyan,
+
+ /// Blue, as this theme draws it.
+ Blue,
+
+ /// Purple, as this theme draws it.
+ Purple,
+
+ /// Pink, as this theme draws it.
+ Pink,
}
/// How asks for a colour to be painted.
@@ -172,18 +205,18 @@ public ThemePalette With(ThemeRole role, ThemeColor? color) =>
public RgbColor BackgroundColor =>
this[ThemeRole.Background]?.Resolved ?? (Mode == ThemeMode.Dark ? new RgbColor(0, 0, 0) : new RgbColor(255, 255, 255));
- /// The contrast a role should have with the background: 3 for lines, 4.5 for text.
+ /// The contrast a role should have with the background: 3 for lines and quiet text, 4.5 for text.
public static double Required(ThemeRole role) => role switch
{
- ThemeRole.Background or ThemeRole.Surface => 1,
- ThemeRole.Primary or ThemeRole.Muted => 3,
+ ThemeRole.Background or ThemeRole.Surface or ThemeRole.Highlight => 1,
+ ThemeRole.Primary or ThemeRole.Muted or ThemeRole.Subtle => 3,
_ => 4.5,
};
///
/// The roles set whose contrast with the background is under . A standard colour
/// alone, or a standard background, looks however the reader's client makes it, so it is not measured;
- /// nor is the surface, which is a background too.
+ /// nor are the surface and the highlight, which are backgrounds too.
///
public IReadOnlyList Check()
{
@@ -192,7 +225,7 @@ public IReadOnlyList Check()
if (this[ThemeRole.Background] is { Rgb: null }) return shortfalls;
foreach (var role in Enum.GetValues())
{
- if (role is ThemeRole.Background or ThemeRole.Surface || this[role] is not { Rgb: not null } color) continue;
+ if (role is ThemeRole.Background or ThemeRole.Surface or ThemeRole.Highlight || this[role] is not { Rgb: not null } color) continue;
var ratio = ColorMath.Contrast(color.Resolved, background);
if (ratio < Required(role)) shortfalls.Add(new ContrastShortfall(role, ratio, Required(role)));
}
@@ -228,7 +261,7 @@ public LayoutTheme ToTheme(Func paint)
///
/// Standard colours alone, so each reader sees the game in the colours their own client is set to:
- /// cyan borders, bright white titles, yellow bullets, dark grey guides.
+ /// cyan borders, bright white titles, yellow bullets, dark grey guides, and each hue the client's own.
///
public static ThemePalette Terminal { get; } = new()
{
@@ -245,6 +278,17 @@ public LayoutTheme ToTheme(Func paint)
[ThemeRole.Warning] = ThemeColor.Standard(3),
[ThemeRole.Error] = ThemeColor.Standard(1),
[ThemeRole.Info] = ThemeColor.Standard(12),
+ [ThemeRole.Subtle] = ThemeColor.Standard(8),
+ [ThemeRole.Link] = ThemeColor.Standard(14),
+ [ThemeRole.Highlight] = ThemeColor.Standard(8),
+ [ThemeRole.Red] = ThemeColor.Standard(9),
+ [ThemeRole.Orange] = ThemeColor.Standard(3),
+ [ThemeRole.Yellow] = ThemeColor.Standard(11),
+ [ThemeRole.Green] = ThemeColor.Standard(10),
+ [ThemeRole.Cyan] = ThemeColor.Standard(14),
+ [ThemeRole.Blue] = ThemeColor.Standard(12),
+ [ThemeRole.Purple] = ThemeColor.Standard(5),
+ [ThemeRole.Pink] = ThemeColor.Standard(13),
}),
};
@@ -340,9 +384,12 @@ private static ThemePalette Base16(string name, string colors) =>
///
/// A base16 scheme (base00 to base0F) as a palette, by base16's own guide: background
/// base00, foreground base05, muted base03, primary base0D (blue),
- /// secondary base0E (magenta), tertiary base0C (cyan), success base0B, warning
- /// base0A, error base08, info base0C. Each keeps the standard colour base16
- /// gives its place in a terminal; dark or light by the background.
+ /// secondary base0E (magenta), tertiary base09 (orange), success base0B, warning
+ /// base0A, error base08, info base0C (cyan), link base0D, highlight
+ /// base02 (the selection background), and the hues red base08 to purple base0E in
+ /// base16's order. Subtle is muted taken a third of the way to the background, and pink lies between red
+ /// and purple. Each keeps the standard colour base16 gives its place in a terminal; dark or light by the
+ /// background.
///
/// There are not sixteen colours.
public static ThemePalette FromBase16(string name, IReadOnlyList colors)
@@ -350,23 +397,35 @@ public static ThemePalette FromBase16(string name, IReadOnlyList color
ArgumentNullException.ThrowIfNull(colors);
if (colors.Count != 16) throw new ArgumentException("A base16 scheme has sixteen colours.", nameof(colors));
ThemeColor At(int index, int slot) => new(colors[index], slot);
+ var dark = ColorMath.Luminance(colors[0]) < 0.18;
return new ThemePalette
{
Name = name,
- Mode = ColorMath.Luminance(colors[0]) < 0.18 ? ThemeMode.Dark : ThemeMode.Light,
+ Mode = dark ? ThemeMode.Dark : ThemeMode.Light,
Colors = ImmutableDictionary.CreateRange(new Dictionary
{
[ThemeRole.Background] = At(0x0, 0),
- [ThemeRole.Surface] = At(0x1, ColorMath.Luminance(colors[0]) < 0.18 ? 8 : 7),
+ [ThemeRole.Surface] = At(0x1, dark ? 8 : 7),
[ThemeRole.Foreground] = At(0x5, 7),
[ThemeRole.Muted] = At(0x3, 8),
[ThemeRole.Primary] = At(0xD, 4),
[ThemeRole.Secondary] = At(0xE, 5),
- [ThemeRole.Tertiary] = At(0xC, 6),
+ [ThemeRole.Tertiary] = At(0x9, 3),
[ThemeRole.Success] = At(0xB, 2),
[ThemeRole.Warning] = At(0xA, 3),
[ThemeRole.Error] = At(0x8, 1),
[ThemeRole.Info] = At(0xC, 6),
+ [ThemeRole.Subtle] = new(Quieter(colors[0x3], colors[0x0]), 8),
+ [ThemeRole.Link] = At(0xD, 4),
+ [ThemeRole.Highlight] = At(0x2, dark ? 8 : 7),
+ [ThemeRole.Red] = At(0x8, 1),
+ [ThemeRole.Orange] = At(0x9, 3),
+ [ThemeRole.Yellow] = At(0xA, 3),
+ [ThemeRole.Green] = At(0xB, 2),
+ [ThemeRole.Cyan] = At(0xC, 6),
+ [ThemeRole.Blue] = At(0xD, 4),
+ [ThemeRole.Purple] = At(0xE, 5),
+ [ThemeRole.Pink] = new(Between(colors[0x8], colors[0xE]), 5),
}),
};
}
@@ -376,8 +435,9 @@ public static ThemePalette FromBase16(string name, IReadOnlyList color
/// says and its chroma, and each is made lighter or darker until it stands
/// out from the background enough: 3:1 for lines, 4.5:1 for text, both raised toward 7:1 by
/// . Success, warning, error and info keep green, amber, red and blue,
- /// turned a little toward the seed. The background and foreground are near black and near white
- /// tinted with the seed.
+ /// turned a little toward the seed, as do the hues red to pink. Subtle is a dimmer muted, the link is
+ /// the primary hue at text contrast, and the highlight a background tinted with the seed. The background
+ /// and foreground are near black and near white tinted with the seed.
///
/// The colour it is made from.
/// How the accents' hues are picked.
@@ -442,10 +502,139 @@ ThemeColor Status(double conventional)
[ThemeRole.Warning] = Status(80),
[ThemeRole.Error] = Status(27),
[ThemeRole.Info] = info is { } turn ? Accent(turn, 0.76, 1, text) : Status(245),
+ [ThemeRole.Subtle] = Accent(0, 0.42, chroma == 0 ? 0 : 0.1, lines),
+ [ThemeRole.Link] = Accent(0, 0.8, 1, text),
+ [ThemeRole.Highlight] = new(HighlightOn(background, foreground, chroma, hue), dark ? 8 : 7),
+ [ThemeRole.Red] = Status(Hues.Angles[ThemeRole.Red]),
+ [ThemeRole.Orange] = Status(Hues.Angles[ThemeRole.Orange]),
+ [ThemeRole.Yellow] = Status(Hues.Angles[ThemeRole.Yellow]),
+ [ThemeRole.Green] = Status(Hues.Angles[ThemeRole.Green]),
+ [ThemeRole.Cyan] = Status(Hues.Angles[ThemeRole.Cyan]),
+ [ThemeRole.Blue] = Status(Hues.Angles[ThemeRole.Blue]),
+ [ThemeRole.Purple] = Status(Hues.Angles[ThemeRole.Purple]),
+ [ThemeRole.Pink] = Status(Hues.Angles[ThemeRole.Pink]),
}),
};
}
+ /// The hues, apart so they are set before the presets that are made with them.
+ private static class Hues
+ {
+ /// Where each hue sits on the OKLCH wheel.
+ public static readonly ImmutableDictionary Angles = ImmutableDictionary.CreateRange(new Dictionary
+ {
+ [ThemeRole.Red] = 27,
+ [ThemeRole.Orange] = 55,
+ [ThemeRole.Yellow] = 95,
+ [ThemeRole.Green] = 145,
+ [ThemeRole.Cyan] = 200,
+ [ThemeRole.Blue] = 255,
+ [ThemeRole.Purple] = 305,
+ [ThemeRole.Pink] = 350,
+ });
+
+ /// The standard colour a sixteen-colour client is sent for each hue.
+ public static readonly ImmutableDictionary Slots = ImmutableDictionary.CreateRange(new Dictionary
+ {
+ [ThemeRole.Red] = 9,
+ [ThemeRole.Orange] = 3,
+ [ThemeRole.Yellow] = 11,
+ [ThemeRole.Green] = 10,
+ [ThemeRole.Cyan] = 14,
+ [ThemeRole.Blue] = 12,
+ [ThemeRole.Purple] = 5,
+ [ThemeRole.Pink] = 13,
+ });
+ }
+
+ ///
+ /// A background a little toward from , tinted with
+ /// the hue, that still reads on at 4.5:1.
+ ///
+ private static RgbColor HighlightOn(RgbColor background, RgbColor foreground, double chroma, double hue)
+ {
+ var dark = ColorMath.Luminance(background) < 0.18;
+ var start = ColorMath.ToOklch(background).L;
+ var highlight = background;
+ for (var step = 1; step <= 8; step++)
+ {
+ var tried = ColorMath.FromOklch(new OklchColor(start + (dark ? 0.02 : -0.012) * step, Math.Min(chroma, 0.07), hue));
+ if (ColorMath.Contrast(foreground, tried) < 4.5) break;
+ highlight = tried;
+ }
+ return highlight;
+ }
+
+ ///
+ /// taken a third of the way to , but no further than 3:1
+ /// against it, the contrast quiet text needs; a muted colour already under 3:1 is left where it is.
+ ///
+ private static RgbColor Quieter(RgbColor muted, RgbColor background) =>
+ ColorMath.WithContrast(Mix(muted, background, 1 / 3.0), background, Math.Min(3, ColorMath.Contrast(muted, background)));
+
+ /// taken of the way to , channel by channel.
+ private static RgbColor Mix(RgbColor from, RgbColor to, double share)
+ {
+ byte Channel(byte a, byte b) => (byte)Math.Round(a + (b - a) * share);
+ return new RgbColor(Channel(from.R, to.R), Channel(from.G, to.G), Channel(from.B, to.B));
+ }
+
+ /// The colour halfway between two, the short way round the OKLCH hue wheel.
+ private static RgbColor Between(RgbColor first, RgbColor second)
+ {
+ var (a, b) = (ColorMath.ToOklch(first), ColorMath.ToOklch(second));
+ if (double.IsNaN(a.H) || double.IsNaN(b.H)) return Mix(first, second, 0.5);
+ return ColorMath.FromOklch(new OklchColor((a.L + b.L) / 2, (a.C + b.C) / 2, ColorMath.Wrap(a.H + ColorMath.Turn(a.H, b.H) / 2)));
+ }
+
+ /// Whether works out when it is unset.
+ private static bool Completable(ThemeRole role) => role is ThemeRole.Subtle or ThemeRole.Link or ThemeRole.Highlight || Hues.Angles.ContainsKey(role);
+
+ ///
+ /// This palette with subtle, link, highlight and the hues worked out from its other colours where it leaves
+ /// them unset: subtle from muted, the link from primary, the highlight from the background, and each hue at
+ /// text contrast on the background. A palette of standard colours alone gets standard colours.
+ ///
+ public ThemePalette Completed() => Completed(new HashSet());
+
+ private ThemePalette Completed(HashSet keepUnset)
+ {
+ var standard = Colors.Count > 0 && Colors.Values.All(color => color.Rgb is null);
+ var background = BackgroundColor;
+ var dark = ColorMath.Luminance(background) < 0.18;
+ var result = this;
+
+ void Fill(ThemeRole role, ThemeColor? color)
+ {
+ if (result[role] is null && !keepUnset.Contains(role) && color is { } set) result = result.With(role, set);
+ }
+
+ Fill(ThemeRole.Subtle, this[ThemeRole.Muted] switch
+ {
+ { Rgb: { } muted } => new ThemeColor(Quieter(muted, background), 8),
+ { } muted => muted,
+ null => null,
+ });
+ Fill(ThemeRole.Link, this[ThemeRole.Primary] switch
+ {
+ { Rgb: { } primary } color => new ThemeColor(ColorMath.WithContrast(primary, background, 4.5), color.Slot),
+ { } primary => primary,
+ null => null,
+ });
+ var foreground = this[ThemeRole.Foreground]?.Resolved ?? (dark ? new RgbColor(255, 255, 255) : new RgbColor(0, 0, 0));
+ var tint = ColorMath.ToOklch(this[ThemeRole.Primary]?.Resolved ?? background).H;
+ Fill(ThemeRole.Highlight, standard
+ ? ThemeColor.Standard(dark ? 8 : 7)
+ : new ThemeColor(HighlightOn(background, foreground, 0.04, double.IsNaN(tint) ? 250 : tint), dark ? 8 : 7));
+ foreach (var (role, angle) in Hues.Angles)
+ {
+ Fill(role, standard
+ ? ThemeColor.Standard(Hues.Slots[role])
+ : new ThemeColor(ColorMath.WithContrast(ColorMath.FromOklch(new OklchColor(dark ? 0.75 : 0.5, 0.14, angle)), background, 4.5), Hues.Slots[role]));
+ }
+ return result;
+ }
+
/// The role named , ignoring case.
public static bool TryParseRole(string name, out ThemeRole role) =>
Enum.TryParse(name, ignoreCase: true, out role) && Enum.IsDefined(role) && !int.TryParse(name, out _);
@@ -455,7 +644,8 @@ public static bool TryParseRole(string name, out ThemeRole role) =>
/// preset (start from that one), base16 (sixteen colours), seed with
/// harmony and contrast (generate one), mode, name, and colors
/// setting roles: "#88c0d0", a standard colour 6, {"rgb":"#88c0d0","slot":6}, or
- /// null to unset one.
+ /// null to unset one. Subtle, link, highlight and the hues it leaves unset, and does not unset with
+ /// null, are worked out from the rest ().
///
public static bool TryParse(string json, out ThemePalette? palette, out string? error)
{
@@ -586,6 +776,7 @@ public static bool TryRead(JsonElement element, out ThemePalette? palette, out s
else result = result with { Look = look!.Over(result.Look) };
}
+ var unset = new HashSet();
if (element.TryGetProperty("colors", out var colorsValue))
{
if (colorsValue.ValueKind != JsonValueKind.Object)
@@ -603,6 +794,7 @@ public static bool TryRead(JsonElement element, out ThemePalette? palette, out s
if (property.Value.ValueKind == JsonValueKind.Null)
{
result = result.With(role, null);
+ unset.Add(role);
continue;
}
if (!TryReadColor(property.Value, out var color, out error)) return false;
@@ -617,7 +809,7 @@ public static bool TryRead(JsonElement element, out ThemePalette? palette, out s
return false;
}
- palette = result;
+ palette = result.Completed(unset);
return true;
}
@@ -661,7 +853,8 @@ private static bool TryReadColor(JsonElement value, out ThemeColor color, out st
///
/// The palette as JSON that reads back:
- /// its name, mode, and each role set as {"rgb":"#88c0d0","slot":6}.
+ /// its name, mode, and each role set as {"rgb":"#88c0d0","slot":6}; a role reading would work out, left
+ /// unset, as null.
///
public string ToJson()
{
@@ -674,7 +867,12 @@ public string ToJson()
json.WriteStartObject("colors");
foreach (var role in Enum.GetValues())
{
- if (this[role] is not { } color) continue;
+ if (this[role] is not { } color)
+ {
+ // A role reading would work out is written unset, so it stays unset when read back.
+ if (Completable(role)) json.WriteNull(RoleName(role));
+ continue;
+ }
json.WriteStartObject(RoleName(role));
if (color.Rgb is { } rgb) json.WriteString("rgb", rgb.ToHex());
if (color.Slot is { } slot) json.WriteNumber("slot", slot);
diff --git a/docs/layout.md b/docs/layout.md
index de11292..51bb15f 100644
--- a/docs/layout.md
+++ b/docs/layout.md
@@ -536,16 +536,22 @@ A `LayoutTheme` sets the colour of each part as well as its characters: `BorderC
`GaugeFilledColor`, `GaugeEmptyColor` and `StripeColor`. Each is a markup layer, so a title can be bold as well as
coloured, and colour a piece sets itself still wins. Nothing is coloured by default.
-A `ThemePalette` names eleven colours by what they are for (`ThemeRole`: background, surface,
-foreground, primary, secondary, tertiary, muted, success, warning, error, info), and `ToTheme` maps
-them onto the parts: borders and gauge bars primary, titles secondary and bold, labels secondary,
-bullets tertiary, headings primary and bold, guides and separators muted, stripes on the surface. The
-ANSI package does the painting:
+A `ThemePalette` names its colours by what they are for (`ThemeRole`: background, surface,
+foreground, primary, secondary, tertiary, muted, success, warning, error, info, subtle, link and
+highlight), and `ToTheme` maps them onto the parts: borders and gauge bars primary, titles secondary and
+bold, labels secondary, bullets tertiary, headings primary and bold, guides and separators muted,
+stripes on the surface. The ANSI package does the painting:
```csharp
var sheet = character.Bordered(MarkupText.Plain("Ann")).Themed(ThemePalette.Preset("nord")!.ToLayoutTheme());
```
+Subtle is text quieter than muted (timestamps, hints), link the colour of links, and highlight a
+background behind marked text, such as a search hit; it is the one role besides the background and
+surface that is painted behind text. A palette also names eight hues, `red`, `orange`, `yellow`,
+`green`, `cyan`, `blue`, `purple` and `pink`, the way a terminal's colour scheme decides what its red
+looks like: code that asks for the theme's red gets a red that reads on that theme's background.
+
Each palette colour is a `ThemeColor`: an exact colour, the standard colour (0-15) a sixteen-colour
client is sent instead, or both. The standard colour is picked by kind (`ColorMath.StandardSlot`), so a
pastel blue is blue on a sixteen-colour client rather than the grey nearest it by RGB. A palette of
@@ -559,7 +565,8 @@ Four ways to make one:
(`ThemePalette.Genres`), each with a look of its own (below); and `catppuccin-mocha`, `catppuccin-latte`, `dracula`, `gruvbox-dark`, `nord`,
`solarized-dark`, `solarized-light`, `tokyo-night` (`ThemePalette.Preset(name)`).
- **base16**: `ThemePalette.FromBase16(name, colors)` takes any of the hundreds of base16 schemes,
- mapped by base16's own guide (`base0D` primary, `base03` muted, `base08` error, ...).
+ mapped by base16's own guide (`base0D` primary, `base03` muted, `base08` error, `base02` highlight,
+ `base08` to `base0E` the hues red to purple, ...).
- **From one colour**: `ThemePalette.Generate(seed, harmony, mode, contrast)`. The accents' hues come
from the seed's by `ThemeHarmony` (monochrome, analogous, complementary, split, triadic, tetradic),
and each is made lighter or darker, keeping its hue, until its WCAG contrast with the background
@@ -567,7 +574,14 @@ Four ways to make one:
warning, error and info stay green, amber, red and blue, turned a little toward the seed.
- **JSON**: `ThemePalette.TryParse` reads a preset's name, or an object with one of `preset`,
`base16` or `seed` (with `harmony`, `contrast`), and `mode`, `name` and `colors` to set roles:
- `{"preset":"nord","colors":{"primary":"#bf616a","muted":8}}`. `ToJson` writes one back.
+ `{"preset":"nord","colors":{"primary":"#bf616a","muted":8}}`. `ToJson` writes one back. Subtle,
+ link, highlight and the hues that `colors` leaves out are worked out from the rest
+ (`ThemePalette.Completed`): subtle from muted, the link from primary, and each hue at 4.5:1 on the
+ background. Set one to `null` to leave it unset.
+
+In HTML, the sixteen standard ANSI colours are written as `var(--ms-ansi-N, #hex)`, so a page sets
+`--ms-ansi-0` to `--ms-ansi-15` to give `ansi()` colours its own scheme; a page that sets none shows the
+usual colours (`AnsiCss`). Backgrounds read `--ms-ansi-bg-N` first, so a page can keep text readable on them.
A theme is more than its colours. `ThemePalette.Look`, a `ThemeLook`, sets the shapes too: a border
preset, its corners, edges and sides, the ornaments round a title (`"╡ ❖ "`, `" ❖ ╞"`), the tree