Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions ThemeProvider.Analysis/AnalysisModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.ThemeProvider.Analysis;

using System.Collections.Generic;
using System.Linq;

/// <summary>The outcome of a single check.</summary>
internal enum CheckStatus
{
/// <summary>The check met its gate.</summary>
Pass,

/// <summary>The check is within a soft tolerance but worth attention.</summary>
Warn,

/// <summary>The check failed its gate.</summary>
Fail,
}

/// <summary>A single issue surfaced by a check (only warnings and failures are recorded).</summary>
/// <param name="Category">The check that produced the finding.</param>
/// <param name="Status">The severity.</param>
/// <param name="Message">A human-readable description with the measured value and gate.</param>
internal sealed record Finding(string Category, CheckStatus Status, string Message);

/// <summary>The headline numbers shown in the per-theme summary row. Arrows in the field
/// names indicate the direction that is worse (lower contrast, higher drift, etc.).</summary>
/// <param name="MinTextContrast">Lowest text-over-surface contrast ratio.</param>
/// <param name="MinTextPair">The foreground/background pair that produced it.</param>
/// <param name="MinGlyphContrast">Lowest UI-glyph contrast ratio.</param>
/// <param name="MinGlyphPair">The foreground/background pair that produced it.</param>
/// <param name="MinStateDelta">Smallest Oklab lightness delta between adjacent interaction states.</param>
/// <param name="MinStatePair">The interaction-state pair that produced it.</param>
/// <param name="MaxHueDrift">Largest accent hue drift from the source palette, in degrees.</param>
/// <param name="MinChromaRetention">Smallest accent chroma retention ratio.</param>
/// <param name="UnusedMeanings">Semantic meanings the theme defines but the ImGui mapper never consumes.</param>
internal sealed record ThemeMetrics(
double MinTextContrast,
string MinTextPair,
double MinGlyphContrast,
string MinGlyphPair,
double MinStateDelta,
string MinStatePair,
double MaxHueDrift,
double MinChromaRetention,
IReadOnlyList<SemanticMeaning> UnusedMeanings);

/// <summary>The full analysis result for one theme.</summary>
/// <param name="Name">The theme's display name.</param>
/// <param name="Family">The theme family.</param>
/// <param name="IsDark">Whether the theme is dark.</param>
/// <param name="Metrics">The headline numbers.</param>
/// <param name="Findings">The warnings and failures.</param>
internal sealed record ThemeAnalysis(
string Name,
string Family,
bool IsDark,
ThemeMetrics Metrics,
IReadOnlyList<Finding> Findings)
{
/// <summary>The worst status across all findings.</summary>
public CheckStatus Status =>
Findings.Any(f => f.Status == CheckStatus.Fail) ? CheckStatus.Fail
: Findings.Any(f => f.Status == CheckStatus.Warn) ? CheckStatus.Warn
: CheckStatus.Pass;
}
34 changes: 34 additions & 0 deletions ThemeProvider.Analysis/AnalysisThresholds.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.ThemeProvider.Analysis;

/// <summary>
/// The fixed gate values every theme is measured against. Tune them here; the report and the
/// process exit code both derive from these. Contrast values are WCAG contrast ratios (1..21),
/// lightness deltas are in Oklab L (0..1), hue drift is in degrees, chroma retention is a ratio.
/// </summary>
internal static class AnalysisThresholds
{
/// <summary>Minimum contrast for body text over the surfaces it is drawn on (WCAG AA, normal text).</summary>
public const double TextContrastAa = 4.5;

/// <summary>Floor for intentionally-muted disabled text: below this it reads as invisible rather than dimmed.</summary>
public const double DisabledTextFloor = 2.0;

/// <summary>Minimum contrast for UI glyphs such as the checkmark (WCAG 1.4.11 non-text contrast).</summary>
public const double GlyphContrast = 3.0;

/// <summary>Minimum Oklab lightness difference between adjacent interaction states to be perceptible.</summary>
public const double MinStateLightnessDelta = 0.015;

/// <summary>Maximum hue drift (degrees) an accent color may wander from the theme's source palette.</summary>
public const double MaxAccentHueDriftDegrees = 12.0;

/// <summary>Warn when a derived accent keeps less than this fraction of the source palette's chroma.</summary>
public const double MinAccentChromaRetention = 0.40;

/// <summary>Chroma below which a color is treated as neutral and its hue is not meaningful.</summary>
public const double MeaningfulChroma = 0.02;
}
115 changes: 115 additions & 0 deletions ThemeProvider.Analysis/MarkdownReport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.ThemeProvider.Analysis;

using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;

/// <summary>Renders a set of theme analyses as a single deterministic markdown document.</summary>
internal static class MarkdownReport
{
/// <summary>Builds the markdown report.</summary>
/// <param name="analyses">The per-theme results, in the order they should appear.</param>
/// <returns>The report text.</returns>
public static string Build(IReadOnlyList<ThemeAnalysis> analyses)
{
StringBuilder sb = new();

int failed = analyses.Count(a => a.Status == CheckStatus.Fail);
int warned = analyses.Count(a => a.Status == CheckStatus.Warn);
int passed = analyses.Count - failed - warned;

sb.AppendLine("# Theme palette analysis");
sb.AppendLine();
sb.AppendLine(Inv($"{analyses.Count} themes analyzed: {passed} pass, {warned} warn, {failed} fail."));
sb.AppendLine();
AppendLegend(sb);

sb.AppendLine("## Summary");
sb.AppendLine();
sb.AppendLine("| Theme | Mode | Text | Glyph | State delta | Hue drift | Chroma | Status |");
sb.AppendLine("| --- | --- | --- | --- | --- | --- | --- | --- |");
foreach (ThemeAnalysis a in analyses)
{
ThemeMetrics m = a.Metrics;
string mode = a.IsDark ? "dark" : "light";
sb.AppendLine(Inv($"| {a.Name} | {mode} | {m.MinTextContrast:0.0}:1 | {m.MinGlyphContrast:0.0}:1 | {m.MinStateDelta:0.000} | {m.MaxHueDrift:0}deg | {m.MinChromaRetention * 100.0:0}% | {Label(a.Status)} |"));
}

sb.AppendLine();
sb.AppendLine("## Findings");
sb.AppendLine();

IReadOnlyList<ThemeAnalysis> withFindings = [.. analyses.Where(a => a.Findings.Count > 0)];
if (withFindings.Count == 0)
{
sb.AppendLine("No warnings or failures.");
}
else
{
foreach (ThemeAnalysis a in withFindings)
{
sb.AppendLine(Inv($"### {a.Name} — {Label(a.Status)}"));
sb.AppendLine();
foreach (Finding f in a.Findings.OrderByDescending(f => f.Status))
{
sb.AppendLine(Inv($"- **{Label(f.Status)}** [{f.Category}] {f.Message}"));
}

sb.AppendLine();
}
}

AppendUnusedMeanings(sb, analyses);

return sb.ToString();
}

private static void AppendLegend(StringBuilder sb)
{
sb.AppendLine("## What is measured");
sb.AppendLine();
sb.AppendLine(Inv($"- **Text** — lowest WCAG contrast of body text over any surface it renders on. Gate: {AnalysisThresholds.TextContrastAa:0.0}:1 (AA)."));
sb.AppendLine(Inv($"- **Glyph** — lowest contrast of UI glyphs (checkmark). Gate: {AnalysisThresholds.GlyphContrast:0.0}:1 (WCAG 1.4.11)."));
sb.AppendLine(Inv($"- **State delta** — smallest Oklab lightness step between adjacent interaction states (button/frame hover/active). Gate: {AnalysisThresholds.MinStateLightnessDelta:0.000}."));
sb.AppendLine(Inv($"- **Hue drift** — largest angle an accent wanders from the theme's source palette. Gate: {AnalysisThresholds.MaxAccentHueDriftDegrees:0}deg."));
sb.AppendLine(Inv($"- **Chroma** — smallest fraction of source accent chroma retained. Warns below {AnalysisThresholds.MinAccentChromaRetention * 100.0:0}%."));
sb.AppendLine();
sb.AppendLine("Disabled text is checked against an invisibility floor only, since it is meant to read as muted.");
sb.AppendLine();
}

private static void AppendUnusedMeanings(StringBuilder sb, IReadOnlyList<ThemeAnalysis> analyses)
{
IReadOnlyList<ThemeAnalysis> withUnused = [.. analyses.Where(a => a.Metrics.UnusedMeanings.Count > 0)];
if (withUnused.Count == 0)
{
return;
}

sb.AppendLine("## Semantic meanings defined but unused by the ImGui mapper");
sb.AppendLine();
sb.AppendLine("These are computed by the theme but never mapped to an ImGui slot, so status colors (success/warning/error/etc.) are unavailable to applications through the mapper.");
sb.AppendLine();
foreach (ThemeAnalysis a in withUnused)
{
string meanings = string.Join(", ", a.Metrics.UnusedMeanings.Select(m => m.ToString()));
sb.AppendLine(Inv($"- {a.Name}: {meanings}"));
}

sb.AppendLine();
}

private static string Label(CheckStatus status) => status switch
{
CheckStatus.Fail => "FAIL",
CheckStatus.Warn => "WARN",
_ => "PASS",
};

private static string Inv(FormattableString text) => text.ToString(CultureInfo.InvariantCulture);
}
Loading
Loading