From e981a68325ecf9253b11f89e678de192df6b1af8 Mon Sep 17 00:00:00 2001
From: Karen Lai <7976322+karkarl@users.noreply.github.com>
Date: Wed, 8 Jul 2026 12:43:49 -0700
Subject: [PATCH 1/3] Mirror connection status dot onto lobster tray and
desktop icons
Compose the lobster mascot with a coloured status dot in its bottom-right
corner (green=connected, amber=connecting/attention, red=error, gray=
disconnected), mirroring the companion-app HubWindow status pill onto both
the tray icon and the desktop/taskbar window icon.
- Add StatusBadgeIconFactory: builds cached multi-resolution .ico files per
status accent by badging the unplated lobster PNG; thread-safe, falls back
to the plain app icon on failure without pinning the fallback in cache.
- Add ConnectionStatusPresenter.Accent() so tray and window derive the same
accent used by the visible status pill.
- Wire TrayIconCoordinator and HubWindow (both UpdateTitleBarStatus overloads
and initial setup) to apply the badged icon; initialise the tray icon with
the badged neutral lobster from first paint.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/App.xaml.cs | 5 +-
.../Helpers/StatusBadgeIconFactory.cs | 187 ++++++++++++++++++
.../Services/ConnectionStatusPresenter.cs | 20 ++
.../Services/TrayIconCoordinator.cs | 10 +-
.../Windows/HubWindow.xaml.cs | 18 +-
.../AppRefactorContractTests.cs | 28 +++
.../ConnectionStatusPresenterTests.cs | 29 +++
.../OpenClaw.Tray.Tests.csproj | 2 +
.../StatusBadgeIconFactoryTests.cs | 93 +++++++++
9 files changed, 385 insertions(+), 7 deletions(-)
create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs
create mode 100644 tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index abbc39cda..63ce622ab 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -760,7 +760,10 @@ private void InitializeTrayIcon()
// Pre-create tray menu window at startup to avoid creation crashes later
InitializeTrayMenuWindow();
- var iconPath = IconHelper.GetStatusIconPath(ConnectionStatus.Disconnected);
+ // Start with the status-badged lobster (neutral/gray dot) so the tray icon
+ // mirrors the companion-app status from first paint, even before the first
+ // connection-state update arrives.
+ var iconPath = StatusBadgeIconFactory.GetBadgedIconPath(ConnectionStatusAccent.Neutral);
_trayIcon = new TrayIcon(1, iconPath, BuildTrayTooltip());
_trayIconCoordinator = new TrayIconCoordinator(
_trayIcon,
diff --git a/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs b/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs
new file mode 100644
index 000000000..56d735780
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs
@@ -0,0 +1,187 @@
+using OpenClawTray.Services;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.Drawing.Imaging;
+using System.IO;
+using System.Runtime.Versioning;
+
+namespace OpenClawTray.Helpers;
+
+///
+/// Builds application icons that mirror the companion-app connection status dot:
+/// the lobster mascot with a coloured status dot rendered in the bottom-right
+/// corner. Composed icons are cached per and
+/// written to a temp folder as multi-resolution .ico files so both the tray icon
+/// () and the desktop/taskbar window
+/// icon (Window.SetIcon(string)) can consume them.
+///
+[SupportedOSPlatform("windows")]
+internal static class StatusBadgeIconFactory
+{
+ private static readonly string AssetsPath = Path.Combine(AppContext.BaseDirectory, "Assets");
+ private static readonly string OutputDir = Path.Combine(Path.GetTempPath(), "OpenClawTray", "StatusIcons");
+ private static readonly object Gate = new();
+ private static readonly ConcurrentDictionary Cache = new();
+
+ // Cover the sizes the shell requests for the tray (16-32 by DPI) and the
+ // taskbar/window (up to 256), so LoadImage always finds a crisp match.
+ private static readonly int[] Sizes = { 16, 20, 24, 32, 40, 48, 64, 128, 256 };
+
+ ///
+ /// Returns a filesystem path to a lobster icon badged with the status dot for
+ /// . Falls back to the plain app icon on failure.
+ ///
+ public static string GetBadgedIconPath(ConnectionStatusAccent accent)
+ {
+ if (Cache.TryGetValue(accent, out var cached) && File.Exists(cached))
+ return cached;
+
+ lock (Gate)
+ {
+ if (Cache.TryGetValue(accent, out cached) && File.Exists(cached))
+ return cached;
+
+ var (path, isFallback) = Build(accent);
+
+ // Only cache successfully composed icons. Caching the fallback would
+ // permanently pin the un-badged icon after a transient GDI+ failure.
+ if (!isFallback)
+ Cache[accent] = path;
+
+ return path;
+ }
+ }
+
+ /// Maps a status accent to its dot colour (mirrors the companion-app dot).
+ public static Color DotColor(ConnectionStatusAccent accent) => accent switch
+ {
+ ConnectionStatusAccent.Success => Color.FromArgb(76, 175, 80), // Green – connected
+ ConnectionStatusAccent.Caution => Color.FromArgb(255, 193, 7), // Amber – connecting / attention
+ ConnectionStatusAccent.Critical => Color.FromArgb(244, 67, 54), // Red – error
+ _ => Color.FromArgb(158, 158, 158), // Gray – disconnected / neutral
+ };
+
+ private static (string Path, bool IsFallback) Build(ConnectionStatusAccent accent)
+ {
+ var fallback = Path.Combine(AssetsPath, "openclaw.ico");
+ try
+ {
+ Directory.CreateDirectory(OutputDir);
+ var outputPath = Path.Combine(OutputDir, $"openclaw-{accent}".ToLowerInvariant() + ".ico");
+
+ using var baseImage = LoadBaseImage();
+ File.WriteAllBytes(outputPath, CreateIcoBytes(baseImage, DotColor(accent), Sizes));
+ return (outputPath, false);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Failed to build status-badged icon for {accent}: {ex.Message}");
+ return (fallback, true);
+ }
+ }
+
+ ///
+ /// Composes the badged lobster at every requested size and packs the frames
+ /// into a single multi-resolution .ico byte array. Exposed for testing.
+ ///
+ internal static byte[] CreateIcoBytes(Bitmap baseImage, Color dotColor, IReadOnlyList sizes)
+ {
+ var frames = new List(sizes.Count);
+ foreach (var size in sizes)
+ {
+ using var composed = Compose(baseImage, size, dotColor);
+ using var ms = new MemoryStream();
+ composed.Save(ms, ImageFormat.Png);
+ frames.Add(ms.ToArray());
+ }
+
+ return BuildIco(frames, sizes);
+ }
+
+ /// The icon sizes baked into every composed .ico container.
+ internal static IReadOnlyList IconSizes => Sizes;
+
+ private static Bitmap LoadBaseImage()
+ {
+ // Prefer the high-resolution unplated mascot PNG (transparent background)
+ // for crisp scaling; fall back to the multi-size app .ico.
+ var png = Path.Combine(AssetsPath, "Square44x44Logo.targetsize-256_altform-unplated.png");
+ if (File.Exists(png))
+ return new Bitmap(png);
+
+ var ico = Path.Combine(AssetsPath, "openclaw.ico");
+ using var icon = new Icon(ico, 256, 256);
+ return icon.ToBitmap();
+ }
+
+ internal static Bitmap Compose(Bitmap baseImage, int size, Color dotColor)
+ {
+ var bmp = new Bitmap(size, size, PixelFormat.Format32bppArgb);
+ bmp.SetResolution(96, 96);
+
+ using var g = Graphics.FromImage(bmp);
+ g.SmoothingMode = SmoothingMode.AntiAlias;
+ g.InterpolationMode = InterpolationMode.HighQualityBicubic;
+ g.PixelOffsetMode = PixelOffsetMode.HighQuality;
+ g.CompositingQuality = CompositingQuality.HighQuality;
+ g.Clear(Color.Transparent);
+
+ g.DrawImage(baseImage, new Rectangle(0, 0, size, size));
+
+ // Dot geometry: bottom-right corner with a white ring for contrast against
+ // both the red mascot and arbitrary taskbar backgrounds.
+ float ring = Math.Max(1f, size * 0.06f);
+ float pad = size * 0.02f;
+ float dot = size * 0.44f;
+ float outer = dot + (ring * 2f);
+ float outerX = size - outer - pad;
+ float outerY = size - outer - pad;
+
+ using (var ringBrush = new SolidBrush(Color.White))
+ g.FillEllipse(ringBrush, outerX, outerY, outer, outer);
+
+ using (var dotBrush = new SolidBrush(dotColor))
+ g.FillEllipse(dotBrush, outerX + ring, outerY + ring, dot, dot);
+
+ return bmp;
+ }
+
+ ///
+ /// Packs PNG frames into a Vista-style .ico container (PNG-compressed entries,
+ /// supported by LoadImage on Windows 10+).
+ ///
+ private static byte[] BuildIco(IReadOnlyList pngFrames, IReadOnlyList sizes)
+ {
+ using var ms = new MemoryStream();
+ using var writer = new BinaryWriter(ms);
+
+ var count = (ushort)pngFrames.Count;
+ writer.Write((ushort)0); // idReserved
+ writer.Write((ushort)1); // idType = icon
+ writer.Write(count); // idCount
+
+ var offset = 6 + (16 * count);
+ for (var i = 0; i < pngFrames.Count; i++)
+ {
+ var dim = (byte)(sizes[i] >= 256 ? 0 : sizes[i]); // 0 encodes 256
+ writer.Write(dim); // bWidth
+ writer.Write(dim); // bHeight
+ writer.Write((byte)0); // bColorCount
+ writer.Write((byte)0); // bReserved
+ writer.Write((ushort)1); // wPlanes
+ writer.Write((ushort)32); // wBitCount
+ writer.Write((uint)pngFrames[i].Length); // dwBytesInRes
+ writer.Write((uint)offset); // dwImageOffset
+ offset += pngFrames[i].Length;
+ }
+
+ foreach (var frame in pngFrames)
+ writer.Write(frame);
+
+ writer.Flush();
+ return ms.ToArray();
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs b/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs
index bd4da850d..0b1ec60fb 100644
--- a/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs
@@ -64,6 +64,26 @@ public static bool IsOperatorChannelLive(GatewayConnectionSnapshot snapshot) =>
},
};
+ ///
+ /// Resolves the status accent (Success/Caution/Critical/Neutral) used for the
+ /// companion-app status dot, so the tray and desktop icons can mirror it.
+ /// Prefers the richer when available and
+ /// falls back to the legacy .
+ ///
+ public static ConnectionStatusAccent Accent(OverallConnectionState? overall, ConnectionStatus fallback)
+ {
+ if (overall is OverallConnectionState state)
+ return Pill(state).Accent;
+
+ return fallback switch
+ {
+ ConnectionStatus.Connected => ConnectionStatusAccent.Success,
+ ConnectionStatus.Connecting => ConnectionStatusAccent.Caution,
+ ConnectionStatus.Error => ConnectionStatusAccent.Critical,
+ _ => ConnectionStatusAccent.Neutral,
+ };
+ }
+
public static (string LabelKey, ConnectionStatusAccent Accent) Pill(OverallConnectionState overall) => overall switch
{
OverallConnectionState.Connected or OverallConnectionState.Ready =>
diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayIconCoordinator.cs b/src/OpenClaw.Tray.WinUI/Services/TrayIconCoordinator.cs
index 1471b11a2..37a40d40f 100644
--- a/src/OpenClaw.Tray.WinUI/Services/TrayIconCoordinator.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/TrayIconCoordinator.cs
@@ -1,4 +1,5 @@
using Microsoft.UI.Dispatching;
+using OpenClawTray.Helpers;
using System;
using WinUIEx;
@@ -39,8 +40,10 @@ internal void UpdateTrayIcon()
if (!_isAlive())
return;
- var iconPath = System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "openclaw.ico");
- var tooltip = BuildTrayTooltip();
+ var snapshot = _captureSnapshot();
+ var accent = ConnectionStatusPresenter.Accent(snapshot.OverallState, snapshot.Status);
+ var iconPath = StatusBadgeIconFactory.GetBadgedIconPath(accent);
+ var tooltip = new TrayTooltipBuilder(snapshot).Build();
try
{
@@ -60,7 +63,4 @@ internal void ApplyTrayTooltip(string tooltip)
_trayIcon.Tooltip = tooltip;
}
-
- private string BuildTrayTooltip() =>
- new TrayTooltipBuilder(_captureSnapshot()).Build();
}
diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
index 309253625..96e91fc6b 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
@@ -114,7 +114,7 @@ public HubWindow()
this.SetWindowSize(1000, 650);
this.CenterOnScreen();
- this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected));
+ ApplyWindowStatusIcon(ConnectionStatusAccent.Neutral);
RootGrid.SizeChanged += OnRootGridSizeChanged;
@@ -771,6 +771,7 @@ private void UpdateTitleBarStatus(ConnectionStatus status)
var (text, accent) = ComputePillState(status, snapshot);
StatusPillText.Text = text;
StatusPillDot.Fill = AccentBrush(accent);
+ ApplyWindowStatusIcon(accent);
}
internal void UpdateTitleBarStatus(GatewayConnectionSnapshot snapshot, ConnectionStatus status)
@@ -778,6 +779,21 @@ internal void UpdateTitleBarStatus(GatewayConnectionSnapshot snapshot, Connectio
var (text, accent) = ComputePillState(status, snapshot);
StatusPillText.Text = text;
StatusPillDot.Fill = AccentBrush(accent);
+ ApplyWindowStatusIcon(accent);
+ }
+
+ // Mirrors the companion-app status dot onto the desktop/taskbar window icon:
+ // the lobster with a coloured dot in the bottom-right corner.
+ private void ApplyWindowStatusIcon(ConnectionStatusAccent accent)
+ {
+ try
+ {
+ this.SetIcon(StatusBadgeIconFactory.GetBadgedIconPath(accent));
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Failed to update window status icon: {ex.Message}");
+ }
}
private static (string Text, ConnectionStatusAccent Accent) ComputePillState(
diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
index 870abb364..f969dc163 100644
--- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
@@ -795,6 +795,34 @@ public void TrayCoordinator_UpdateGuardsLivenessBeforeTouchingIcon()
Assert.True(guardIndex < setIconIndex, "Liveness guard must run before SetIcon");
}
+ [Fact]
+ public void TrayCoordinator_UsesStatusBadgedLobsterIcon()
+ {
+ var method = ExtractMethod(ReadCoordinatorSource(), "UpdateTrayIcon");
+
+ // The tray lobster mirrors the companion-app status dot instead of the
+ // static openclaw.ico, so it must resolve the accent and the badged icon.
+ Assert.Contains("ConnectionStatusPresenter.Accent(", method);
+ Assert.Contains("StatusBadgeIconFactory.GetBadgedIconPath(", method);
+ Assert.DoesNotContain("\"openclaw.ico\"", method);
+ }
+
+ [Fact]
+ public void HubWindow_DesktopIconMirrorsStatusAccent()
+ {
+ var root = TestRepositoryPaths.GetRepositoryRoot();
+ var source = File.ReadAllText(Path.Combine(
+ root, "src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml.cs"));
+
+ // The desktop/taskbar icon is refreshed from the same accent as the pill.
+ var apply = ExtractMethod(source, "ApplyWindowStatusIcon");
+ Assert.Contains("StatusBadgeIconFactory.GetBadgedIconPath(accent)", apply);
+
+ // Both status-update paths must repaint the window icon.
+ var update = ExtractMethod(source, "UpdateTitleBarStatus");
+ Assert.Contains("ApplyWindowStatusIcon(accent)", update);
+ }
+
[Fact]
public void AppNotifications_ConnectionIssueUsesStableDedupeKey()
{
diff --git a/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs
index 4a2b106c8..284d71ac5 100644
--- a/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs
@@ -31,6 +31,35 @@ public void Pill_ReadyAndConnected_BothReadConnected()
ConnectionStatusPresenter.Pill(OverallConnectionState.Ready));
}
+ [Theory]
+ [InlineData(OverallConnectionState.Connected, (int)ConnectionStatusAccent.Success)]
+ [InlineData(OverallConnectionState.Ready, (int)ConnectionStatusAccent.Success)]
+ [InlineData(OverallConnectionState.Connecting, (int)ConnectionStatusAccent.Caution)]
+ [InlineData(OverallConnectionState.Degraded, (int)ConnectionStatusAccent.Caution)]
+ [InlineData(OverallConnectionState.PairingRequired, (int)ConnectionStatusAccent.Caution)]
+ [InlineData(OverallConnectionState.Error, (int)ConnectionStatusAccent.Critical)]
+ [InlineData(OverallConnectionState.Idle, (int)ConnectionStatusAccent.Neutral)]
+ [InlineData(OverallConnectionState.Disconnecting, (int)ConnectionStatusAccent.Neutral)]
+ public void Accent_PrefersOverallState_MatchingPill(OverallConnectionState overall, int expectedAccent)
+ {
+ // The tray/desktop status dot must mirror the companion-app pill accent.
+ Assert.Equal((ConnectionStatusAccent)expectedAccent,
+ ConnectionStatusPresenter.Accent(overall, ConnectionStatus.Disconnected));
+ Assert.Equal(ConnectionStatusPresenter.Pill(overall).Accent,
+ ConnectionStatusPresenter.Accent(overall, ConnectionStatus.Disconnected));
+ }
+
+ [Theory]
+ [InlineData(ConnectionStatus.Connected, (int)ConnectionStatusAccent.Success)]
+ [InlineData(ConnectionStatus.Connecting, (int)ConnectionStatusAccent.Caution)]
+ [InlineData(ConnectionStatus.Error, (int)ConnectionStatusAccent.Critical)]
+ [InlineData(ConnectionStatus.Disconnected, (int)ConnectionStatusAccent.Neutral)]
+ public void Accent_FallsBackToLegacyStatus_WhenOverallNull(ConnectionStatus status, int expectedAccent)
+ {
+ Assert.Equal((ConnectionStatusAccent)expectedAccent,
+ ConnectionStatusPresenter.Accent(null, status));
+ }
+
[Theory]
[InlineData(OverallConnectionState.Connected, ConnectionStatus.Connected)]
[InlineData(OverallConnectionState.Ready, ConnectionStatus.Connected)]
diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
index 50527f1ec..e843868fb 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
+++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
@@ -24,6 +24,7 @@
+
@@ -83,6 +84,7 @@
+
diff --git a/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs b/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs
new file mode 100644
index 000000000..c21111f29
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs
@@ -0,0 +1,93 @@
+using OpenClawTray.Helpers;
+using OpenClawTray.Services;
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.IO;
+using System.Runtime.Versioning;
+using Xunit;
+
+namespace OpenClaw.Tray.Tests;
+
+///
+/// Verifies the lobster tray/desktop icon is composed with a status dot in the
+/// bottom-right corner, mirroring the companion-app connection status.
+///
+[SupportedOSPlatform("windows")]
+public sealed class StatusBadgeIconFactoryTests
+{
+ [Theory]
+ [InlineData((int)ConnectionStatusAccent.Success, 76, 175, 80)]
+ [InlineData((int)ConnectionStatusAccent.Caution, 255, 193, 7)]
+ [InlineData((int)ConnectionStatusAccent.Critical, 244, 67, 54)]
+ [InlineData((int)ConnectionStatusAccent.Neutral, 158, 158, 158)]
+ public void DotColor_MapsAccentToStatusColor(int accent, int r, int g, int b)
+ {
+ var color = StatusBadgeIconFactory.DotColor((ConnectionStatusAccent)accent);
+ Assert.Equal(Color.FromArgb(r, g, b), color);
+ }
+
+ [Fact]
+ public void Compose_DrawsDotInBottomRightCorner()
+ {
+ const int size = 64;
+ using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb);
+ // Fully transparent base so the only opaque pixels come from the dot.
+
+ using var composed = StatusBadgeIconFactory.Compose(
+ baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Success));
+
+ // Bottom-right region carries the coloured dot.
+ var dotPixel = composed.GetPixel((int)(size * 0.85), (int)(size * 0.85));
+ Assert.True(dotPixel.A > 200, "Dot should be opaque in the bottom-right corner");
+ Assert.True(dotPixel.G > dotPixel.R && dotPixel.G > dotPixel.B, "Success dot should read green");
+
+ // Top-left stays transparent (no badge, base was empty).
+ var cornerPixel = composed.GetPixel(2, 2);
+ Assert.True(cornerPixel.A < 40, "Top-left corner should remain transparent");
+ }
+
+ [Fact]
+ public void Compose_UsesDistinctColorPerAccent()
+ {
+ const int size = 64;
+ using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb);
+ var px = (int)(size * 0.85);
+
+ using var success = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Success));
+ using var critical = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical));
+
+ var green = success.GetPixel(px, px);
+ var red = critical.GetPixel(px, px);
+ Assert.True(green.G > green.R, "Success dot is green-dominant");
+ Assert.True(red.R > red.G, "Critical dot is red-dominant");
+ }
+
+ [Fact]
+ public void CreateIcoBytes_ProducesValidMultiSizeIcon()
+ {
+ var sizes = new[] { 16, 32, 48 };
+ using var baseImage = new Bitmap(256, 256, PixelFormat.Format32bppArgb);
+
+ var bytes = StatusBadgeIconFactory.CreateIcoBytes(
+ baseImage, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Caution), sizes);
+
+ // ICONDIR header: reserved=0, type=1 (icon), count=frames.
+ Assert.Equal(0, bytes[0] | bytes[1]);
+ Assert.Equal(1, bytes[2] | (bytes[3] << 8));
+ var count = bytes[4] | (bytes[5] << 8);
+ Assert.Equal(sizes.Length, count);
+
+ // The container round-trips back into a real icon.
+ using var stream = new MemoryStream(bytes);
+ using var icon = new Icon(stream);
+ Assert.NotNull(icon);
+ }
+
+ [Fact]
+ public void IconSizes_CoverTrayAndTaskbarResolutions()
+ {
+ Assert.Contains(16, StatusBadgeIconFactory.IconSizes); // tray at 100% DPI
+ Assert.Contains(32, StatusBadgeIconFactory.IconSizes); // tray at 200% DPI / taskbar
+ Assert.Contains(256, StatusBadgeIconFactory.IconSizes); // high-DPI taskbar
+ }
+}
From c403007df7df182506806045fa3c8d1ba0d5fcc4 Mon Sep 17 00:00:00 2001
From: Karen Lai <7976322+karkarl@users.noreply.github.com>
Date: Wed, 8 Jul 2026 12:52:18 -0700
Subject: [PATCH 2/3] Scale status dot by icon size
The dot diameter now interpolates from 44%% of the icon on tiny tray icons
(<=32px, for legibility) down to 26%% on large taskbar / alt-tab icons
(>=256px, a subtler corner dot), instead of a fixed 44%%. The white ring is
tied to the dot so it shrinks proportionally. Adds DotFraction(size) with a
unit test covering the scaling curve.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
.../Helpers/StatusBadgeIconFactory.cs | 30 +++++++++++++++++--
.../StatusBadgeIconFactoryTests.cs | 21 +++++++++++--
2 files changed, 46 insertions(+), 5 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs b/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs
index 56d735780..a17ab7f07 100644
--- a/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs
+++ b/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs
@@ -117,6 +117,28 @@ private static Bitmap LoadBaseImage()
return icon.ToBitmap();
}
+ ///
+ /// The dot diameter as a fraction of the icon size. Tiny tray icons need a
+ /// proportionally larger dot to stay legible at 16-32px, while large taskbar
+ /// / alt-tab icons look cleaner with a subtler corner dot. Interpolates
+ /// between the two extremes. Exposed for testing.
+ ///
+ internal static double DotFraction(int size)
+ {
+ const double maxFraction = 0.44; // <= 32px (tray legibility)
+ const double minFraction = 0.26; // >= 256px (subtle corner dot)
+ const int minSize = 32;
+ const int maxSize = 256;
+
+ if (size <= minSize)
+ return maxFraction;
+ if (size >= maxSize)
+ return minFraction;
+
+ var t = (double)(size - minSize) / (maxSize - minSize);
+ return maxFraction + (t * (minFraction - maxFraction));
+ }
+
internal static Bitmap Compose(Bitmap baseImage, int size, Color dotColor)
{
var bmp = new Bitmap(size, size, PixelFormat.Format32bppArgb);
@@ -132,10 +154,12 @@ internal static Bitmap Compose(Bitmap baseImage, int size, Color dotColor)
g.DrawImage(baseImage, new Rectangle(0, 0, size, size));
// Dot geometry: bottom-right corner with a white ring for contrast against
- // both the red mascot and arbitrary taskbar backgrounds.
- float ring = Math.Max(1f, size * 0.06f);
+ // both the red mascot and arbitrary taskbar backgrounds. The dot scales by
+ // size so it is legible on tiny tray icons yet subtle on large icons; the
+ // ring is tied to the dot so it shrinks proportionally too.
+ float dot = size * (float)DotFraction(size);
+ float ring = Math.Max(1f, dot * 0.14f);
float pad = size * 0.02f;
- float dot = size * 0.44f;
float outer = dot + (ring * 2f);
float outerX = size - outer - pad;
float outerY = size - outer - pad;
diff --git a/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs b/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs
index c21111f29..112757e49 100644
--- a/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs
+++ b/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs
@@ -37,7 +37,7 @@ public void Compose_DrawsDotInBottomRightCorner()
baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Success));
// Bottom-right region carries the coloured dot.
- var dotPixel = composed.GetPixel((int)(size * 0.85), (int)(size * 0.85));
+ var dotPixel = composed.GetPixel((int)(size * 0.80), (int)(size * 0.80));
Assert.True(dotPixel.A > 200, "Dot should be opaque in the bottom-right corner");
Assert.True(dotPixel.G > dotPixel.R && dotPixel.G > dotPixel.B, "Success dot should read green");
@@ -46,12 +46,29 @@ public void Compose_DrawsDotInBottomRightCorner()
Assert.True(cornerPixel.A < 40, "Top-left corner should remain transparent");
}
+ [Fact]
+ public void DotFraction_ScalesLargerOnTinyIconsAndSubtlerOnLargeIcons()
+ {
+ // Tiny tray icons get the largest dot for legibility.
+ Assert.Equal(0.44, StatusBadgeIconFactory.DotFraction(16), 3);
+ Assert.Equal(0.44, StatusBadgeIconFactory.DotFraction(32), 3);
+
+ // Large taskbar / alt-tab icons get the subtlest dot.
+ Assert.Equal(0.26, StatusBadgeIconFactory.DotFraction(256), 3);
+
+ // Monotonically shrinks as the icon grows between the two extremes.
+ var f48 = StatusBadgeIconFactory.DotFraction(48);
+ var f128 = StatusBadgeIconFactory.DotFraction(128);
+ Assert.True(f48 < 0.44 && f48 > f128, "48px dot fraction sits between the extremes");
+ Assert.True(f128 > 0.26 && f128 < f48, "128px dot fraction is subtler than 48px but above the floor");
+ }
+
[Fact]
public void Compose_UsesDistinctColorPerAccent()
{
const int size = 64;
using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb);
- var px = (int)(size * 0.85);
+ var px = (int)(size * 0.80);
using var success = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Success));
using var critical = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical));
From ee3db702845378d601b206948bb7add45d02f423 Mon Sep 17 00:00:00 2001
From: Karen Lai <7976322+karkarl@users.noreply.github.com>
Date: Wed, 8 Jul 2026 16:42:35 -0700
Subject: [PATCH 3/3] Only badge the lobster for attention states
(disconnected, error)
Per group feedback a constant green connected dot is distracting. The tray
and desktop icons now show a status badge only for attention states:
- disconnected / neutral -> grey dot
- error -> red dot with a white minus (-) glyph
Connected and connecting render the plain lobster with no badge.
- StatusBadgeIconFactory.ShouldBadge()/HasDash() encode the policy; Compose
takes a nullable dot colour (null = plain lobster) and an optional dash.
- Update StatusBadgeIconFactoryTests for the new policy and dash rendering.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
.../Helpers/StatusBadgeIconFactory.cs | 60 +++++++--
.../StatusBadgeIconFactoryTests.cs | 123 +++++++++++++++---
2 files changed, 151 insertions(+), 32 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs b/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs
index a17ab7f07..113f2ff81 100644
--- a/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs
+++ b/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs
@@ -11,10 +11,16 @@
namespace OpenClawTray.Helpers;
///
-/// Builds application icons that mirror the companion-app connection status dot:
-/// the lobster mascot with a coloured status dot rendered in the bottom-right
-/// corner. Composed icons are cached per and
-/// written to a temp folder as multi-resolution .ico files so both the tray icon
+/// Builds application icons that surface the connection status on the lobster
+/// mascot as a small badge in the bottom-right corner. To avoid a distracting
+/// always-on indicator, only attention states are badged:
+///
+/// - disconnected / neutral → a grey dot,
+/// - error → a red dot with a white "-" (minus) glyph.
+///
+/// Healthy states (connected, connecting) render the plain lobster with no badge.
+/// Composed icons are cached per and written
+/// to a temp folder as multi-resolution .ico files so both the tray icon
/// () and the desktop/taskbar window
/// icon (Window.SetIcon(string)) can consume them.
///
@@ -64,6 +70,18 @@ public static string GetBadgedIconPath(ConnectionStatusAccent accent)
_ => Color.FromArgb(158, 158, 158), // Gray – disconnected / neutral
};
+ ///
+ /// Whether gets a status badge. Only attention states
+ /// are badged so the icon is quiet when everything is healthy: disconnected
+ /// (neutral) and error (critical). Connected/connecting render the plain lobster.
+ ///
+ public static bool ShouldBadge(ConnectionStatusAccent accent) =>
+ accent is ConnectionStatusAccent.Neutral or ConnectionStatusAccent.Critical;
+
+ /// Whether the badge carries the "-" (minus) glyph. Only the error state does.
+ public static bool HasDash(ConnectionStatusAccent accent) =>
+ accent is ConnectionStatusAccent.Critical;
+
private static (string Path, bool IsFallback) Build(ConnectionStatusAccent accent)
{
var fallback = Path.Combine(AssetsPath, "openclaw.ico");
@@ -72,8 +90,12 @@ private static (string Path, bool IsFallback) Build(ConnectionStatusAccent accen
Directory.CreateDirectory(OutputDir);
var outputPath = Path.Combine(OutputDir, $"openclaw-{accent}".ToLowerInvariant() + ".ico");
+ // Only attention states (neutral/critical) get a dot; healthy states
+ // render the plain lobster (null dot colour).
+ Color? dotColor = ShouldBadge(accent) ? DotColor(accent) : null;
+
using var baseImage = LoadBaseImage();
- File.WriteAllBytes(outputPath, CreateIcoBytes(baseImage, DotColor(accent), Sizes));
+ File.WriteAllBytes(outputPath, CreateIcoBytes(baseImage, dotColor, HasDash(accent), Sizes));
return (outputPath, false);
}
catch (Exception ex)
@@ -84,15 +106,16 @@ private static (string Path, bool IsFallback) Build(ConnectionStatusAccent accen
}
///
- /// Composes the badged lobster at every requested size and packs the frames
- /// into a single multi-resolution .ico byte array. Exposed for testing.
+ /// Composes the (optionally badged) lobster at every requested size and packs
+ /// the frames into a single multi-resolution .ico byte array. A null
+ /// renders the plain lobster. Exposed for testing.
///
- internal static byte[] CreateIcoBytes(Bitmap baseImage, Color dotColor, IReadOnlyList sizes)
+ internal static byte[] CreateIcoBytes(Bitmap baseImage, Color? dotColor, bool withDash, IReadOnlyList sizes)
{
var frames = new List(sizes.Count);
foreach (var size in sizes)
{
- using var composed = Compose(baseImage, size, dotColor);
+ using var composed = Compose(baseImage, size, dotColor, withDash);
using var ms = new MemoryStream();
composed.Save(ms, ImageFormat.Png);
frames.Add(ms.ToArray());
@@ -139,7 +162,7 @@ internal static double DotFraction(int size)
return maxFraction + (t * (minFraction - maxFraction));
}
- internal static Bitmap Compose(Bitmap baseImage, int size, Color dotColor)
+ internal static Bitmap Compose(Bitmap baseImage, int size, Color? dotColor, bool withDash = false)
{
var bmp = new Bitmap(size, size, PixelFormat.Format32bppArgb);
bmp.SetResolution(96, 96);
@@ -153,6 +176,10 @@ internal static Bitmap Compose(Bitmap baseImage, int size, Color dotColor)
g.DrawImage(baseImage, new Rectangle(0, 0, size, size));
+ // No badge for healthy states: render the plain lobster.
+ if (dotColor is not Color color)
+ return bmp;
+
// Dot geometry: bottom-right corner with a white ring for contrast against
// both the red mascot and arbitrary taskbar backgrounds. The dot scales by
// size so it is legible on tiny tray icons yet subtle on large icons; the
@@ -167,9 +194,20 @@ internal static Bitmap Compose(Bitmap baseImage, int size, Color dotColor)
using (var ringBrush = new SolidBrush(Color.White))
g.FillEllipse(ringBrush, outerX, outerY, outer, outer);
- using (var dotBrush = new SolidBrush(dotColor))
+ using (var dotBrush = new SolidBrush(color))
g.FillEllipse(dotBrush, outerX + ring, outerY + ring, dot, dot);
+ // Error badge carries a white "-" (minus) glyph centred on the dot.
+ if (withDash)
+ {
+ float cx = outerX + ring + (dot / 2f);
+ float cy = outerY + ring + (dot / 2f);
+ float dashW = dot * 0.52f;
+ float dashH = Math.Max(2f, dot * 0.18f);
+ using var dashBrush = new SolidBrush(Color.White);
+ g.FillRectangle(dashBrush, cx - (dashW / 2f), cy - (dashH / 2f), dashW, dashH);
+ }
+
return bmp;
}
diff --git a/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs b/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs
index 112757e49..6d67ce2bc 100644
--- a/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs
+++ b/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs
@@ -1,5 +1,6 @@
using OpenClawTray.Helpers;
using OpenClawTray.Services;
+using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
@@ -9,8 +10,9 @@
namespace OpenClaw.Tray.Tests;
///
-/// Verifies the lobster tray/desktop icon is composed with a status dot in the
-/// bottom-right corner, mirroring the companion-app connection status.
+/// Verifies the lobster tray/desktop icon badge policy: only attention states are
+/// badged (grey dot for disconnected, red dot with a "-" for error); healthy
+/// states (connected/connecting) render the plain lobster with no badge.
///
[SupportedOSPlatform("windows")]
public sealed class StatusBadgeIconFactoryTests
@@ -26,6 +28,26 @@ public void DotColor_MapsAccentToStatusColor(int accent, int r, int g, int b)
Assert.Equal(Color.FromArgb(r, g, b), color);
}
+ [Theory]
+ [InlineData((int)ConnectionStatusAccent.Neutral, true)]
+ [InlineData((int)ConnectionStatusAccent.Critical, true)]
+ [InlineData((int)ConnectionStatusAccent.Success, false)]
+ [InlineData((int)ConnectionStatusAccent.Caution, false)]
+ public void ShouldBadge_OnlyNeutralAndCritical(int accent, bool expected)
+ {
+ Assert.Equal(expected, StatusBadgeIconFactory.ShouldBadge((ConnectionStatusAccent)accent));
+ }
+
+ [Theory]
+ [InlineData((int)ConnectionStatusAccent.Critical, true)]
+ [InlineData((int)ConnectionStatusAccent.Neutral, false)]
+ [InlineData((int)ConnectionStatusAccent.Success, false)]
+ [InlineData((int)ConnectionStatusAccent.Caution, false)]
+ public void HasDash_OnlyCritical(int accent, bool expected)
+ {
+ Assert.Equal(expected, StatusBadgeIconFactory.HasDash((ConnectionStatusAccent)accent));
+ }
+
[Fact]
public void Compose_DrawsDotInBottomRightCorner()
{
@@ -34,12 +56,11 @@ public void Compose_DrawsDotInBottomRightCorner()
// Fully transparent base so the only opaque pixels come from the dot.
using var composed = StatusBadgeIconFactory.Compose(
- baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Success));
+ baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Neutral));
- // Bottom-right region carries the coloured dot.
+ // Bottom-right region carries the dot.
var dotPixel = composed.GetPixel((int)(size * 0.80), (int)(size * 0.80));
Assert.True(dotPixel.A > 200, "Dot should be opaque in the bottom-right corner");
- Assert.True(dotPixel.G > dotPixel.R && dotPixel.G > dotPixel.B, "Success dot should read green");
// Top-left stays transparent (no badge, base was empty).
var cornerPixel = composed.GetPixel(2, 2);
@@ -47,20 +68,37 @@ public void Compose_DrawsDotInBottomRightCorner()
}
[Fact]
- public void DotFraction_ScalesLargerOnTinyIconsAndSubtlerOnLargeIcons()
+ public void Compose_NullColor_RendersPlainLobsterWithNoBadge()
{
- // Tiny tray icons get the largest dot for legibility.
- Assert.Equal(0.44, StatusBadgeIconFactory.DotFraction(16), 3);
- Assert.Equal(0.44, StatusBadgeIconFactory.DotFraction(32), 3);
+ const int size = 64;
+ using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb);
+ // Transparent base + null dot colour => the whole icon stays transparent.
- // Large taskbar / alt-tab icons get the subtlest dot.
- Assert.Equal(0.26, StatusBadgeIconFactory.DotFraction(256), 3);
+ using var composed = StatusBadgeIconFactory.Compose(baseImage, size, dotColor: null);
- // Monotonically shrinks as the icon grows between the two extremes.
- var f48 = StatusBadgeIconFactory.DotFraction(48);
- var f128 = StatusBadgeIconFactory.DotFraction(128);
- Assert.True(f48 < 0.44 && f48 > f128, "48px dot fraction sits between the extremes");
- Assert.True(f128 > 0.26 && f128 < f48, "128px dot fraction is subtler than 48px but above the floor");
+ var dotPixel = composed.GetPixel((int)(size * 0.80), (int)(size * 0.80));
+ Assert.True(dotPixel.A < 40, "Healthy state should render no dot in the bottom-right corner");
+ }
+
+ [Fact]
+ public void Compose_ErrorState_DrawsRedDotWithWhiteDash()
+ {
+ const int size = 64;
+ using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb);
+
+ using var composed = StatusBadgeIconFactory.Compose(
+ baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical), withDash: true);
+
+ var (cx, cy, dot) = DotCenter(size);
+
+ // Centre of the dot is covered by the white minus glyph.
+ var centre = composed.GetPixel((int)cx, (int)cy);
+ Assert.True(centre.R > 230 && centre.G > 230 && centre.B > 230, "Dash centre should be white");
+
+ // Below the dash (still inside the dot) reads red.
+ var belowDash = composed.GetPixel((int)cx, (int)(cy + dot * 0.30f));
+ Assert.True(belowDash.A > 200 && belowDash.R > belowDash.G && belowDash.R > belowDash.B,
+ "Dot around the dash should read red");
}
[Fact]
@@ -70,13 +108,30 @@ public void Compose_UsesDistinctColorPerAccent()
using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb);
var px = (int)(size * 0.80);
- using var success = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Success));
+ using var neutral = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Neutral));
using var critical = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical));
- var green = success.GetPixel(px, px);
+ var gray = neutral.GetPixel(px, px);
var red = critical.GetPixel(px, px);
- Assert.True(green.G > green.R, "Success dot is green-dominant");
- Assert.True(red.R > red.G, "Critical dot is red-dominant");
+ Assert.True(Math.Abs(gray.R - gray.G) < 20 && Math.Abs(gray.G - gray.B) < 20, "Neutral dot is grey");
+ Assert.True(red.R > red.G && red.R > red.B, "Critical dot is red-dominant");
+ }
+
+ [Fact]
+ public void DotFraction_ScalesLargerOnTinyIconsAndSubtlerOnLargeIcons()
+ {
+ // Tiny tray icons get the largest dot for legibility.
+ Assert.Equal(0.44, StatusBadgeIconFactory.DotFraction(16), 3);
+ Assert.Equal(0.44, StatusBadgeIconFactory.DotFraction(32), 3);
+
+ // Large taskbar / alt-tab icons get the subtlest dot.
+ Assert.Equal(0.26, StatusBadgeIconFactory.DotFraction(256), 3);
+
+ // Monotonically shrinks as the icon grows between the two extremes.
+ var f48 = StatusBadgeIconFactory.DotFraction(48);
+ var f128 = StatusBadgeIconFactory.DotFraction(128);
+ Assert.True(f48 < 0.44 && f48 > f128, "48px dot fraction sits between the extremes");
+ Assert.True(f128 > 0.26 && f128 < f48, "128px dot fraction is subtler than 48px but above the floor");
}
[Fact]
@@ -86,7 +141,7 @@ public void CreateIcoBytes_ProducesValidMultiSizeIcon()
using var baseImage = new Bitmap(256, 256, PixelFormat.Format32bppArgb);
var bytes = StatusBadgeIconFactory.CreateIcoBytes(
- baseImage, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Caution), sizes);
+ baseImage, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical), withDash: true, sizes);
// ICONDIR header: reserved=0, type=1 (icon), count=frames.
Assert.Equal(0, bytes[0] | bytes[1]);
@@ -100,6 +155,19 @@ public void CreateIcoBytes_ProducesValidMultiSizeIcon()
Assert.NotNull(icon);
}
+ [Fact]
+ public void CreateIcoBytes_NullColor_ProducesValidPlainIcon()
+ {
+ var sizes = new[] { 16, 32 };
+ using var baseImage = new Bitmap(256, 256, PixelFormat.Format32bppArgb);
+
+ var bytes = StatusBadgeIconFactory.CreateIcoBytes(baseImage, dotColor: null, withDash: false, sizes);
+
+ using var stream = new MemoryStream(bytes);
+ using var icon = new Icon(stream);
+ Assert.NotNull(icon);
+ }
+
[Fact]
public void IconSizes_CoverTrayAndTaskbarResolutions()
{
@@ -107,4 +175,17 @@ public void IconSizes_CoverTrayAndTaskbarResolutions()
Assert.Contains(32, StatusBadgeIconFactory.IconSizes); // tray at 200% DPI / taskbar
Assert.Contains(256, StatusBadgeIconFactory.IconSizes); // high-DPI taskbar
}
+
+ // Mirrors the dot geometry in StatusBadgeIconFactory.Compose so tests can sample
+ // the dot centre precisely.
+ private static (float Cx, float Cy, float Dot) DotCenter(int size)
+ {
+ float dot = size * (float)StatusBadgeIconFactory.DotFraction(size);
+ float ring = Math.Max(1f, dot * 0.14f);
+ float pad = size * 0.02f;
+ float outer = dot + (ring * 2f);
+ float outerX = size - outer - pad;
+ float outerY = size - outer - pad;
+ return (outerX + ring + (dot / 2f), outerY + ring + (dot / 2f), dot);
+ }
}