diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 670628b..09cce67 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,6 +25,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
+ 8.0.x
10.0.x
- name: Restore dependencies
@@ -221,6 +222,20 @@ jobs:
grep -Fq 'state=open' "$BUNDLE" || (echo "ERROR: bundle missing data-[state=open] arbitrary-value modifier"; exit 1)
grep -Fq 'px-2\.5' "$BUNDLE" || (echo "ERROR: bundle missing Badge padding — .cs variant scan regression?"; exit 1)
+ # The package also ships net8.0 and net9.0 builds.
+ for fw in net8.0 net9.0; do
+ cd "$TMPDIR/app"
+ dotnet new blazor -o "NuGetSmoke-$fw" -f "$fw" --no-restore
+ cd "NuGetSmoke-$fw"
+ cp ../NuGetSmoke/NuGet.config .
+ dotnet add package ShellUI.Components --prerelease
+ echo '@using ShellUI.Components' >> Components/_Imports.razor
+ echo '@page "/shellui"' > Components/Pages/ShellUISmoke.razor
+ echo ' ok' >> Components/Pages/ShellUISmoke.razor
+ dotnet build -c Debug
+ test -f wwwroot/shellui-classes.txt
+ done
+
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a3e9d1a..b332542 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -32,7 +32,9 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
- dotnet-version: '10.0.x'
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
- name: Check package version matches tag
run: |
diff --git a/README.md b/README.md
index 9ec8fce..8c03f85 100644
--- a/README.md
+++ b/README.md
@@ -24,18 +24,18 @@
| Channel | Version | Notes |
|---|---|---|
-| Latest stable (recommended) | `0.3.4` | Tailwind CSS `4.3.2` |
+| Latest stable (recommended) | `0.3.5` | Tailwind CSS `4.3.2` |
| Prerelease | `0.4.0-alpha.3` | This branch; install with `--version 0.4.0-alpha.3` |
-The CLI needs the .NET 10 SDK and works in Blazor projects on .NET 8, 9 and 10. The `ShellUI.Components` NuGet package targets .NET 10 only; on .NET 8 or 9, use the CLI.
+The CLI runs on .NET 8 or later and works in Blazor projects on .NET 8, 9 and 10. The `ShellUI.Components` NuGet package targets .NET 8, 9 and 10. (`0.4.0-alpha.3` and the 0.3 line need .NET 10 for both.)
-`main` is the 0.4 line, so this README and `docs/` describe `0.4.0-alpha.3`, including features that are not in a stable release yet, such as `shellui init --dashboard` and the auth blocks. For the stable version, read the [v0.3.4 docs](https://github.com/shellui-dev/shellui/tree/v0.3.4). Prereleases need `--version` to install.
+`main` is the 0.4 line, so this README and `docs/` describe `0.4.0-alpha.3`, including features that are not in a stable release yet, such as `shellui init --dashboard` and the auth blocks. For the stable version, read the [v0.3.5 docs](https://github.com/shellui-dev/shellui/tree/v0.3.5). Prereleases need `--version` to install.
ShellUI is pre-1.0, so APIs and generated output can still change between minor versions. Validate it in your target Blazor and hosting environments before relying on it.
## Capabilities
-- The CLI commands are `init`, `add`, `list`, `remove`, and `update`, plus `theme init`, `theme apply`, and `theme update`.
+- The CLI commands are `init`, `add`, `list`, `remove`, `update` and `doctor`, plus `theme init`, `theme apply`, and `theme update`.
- The component registry has **243 entries**: **93 direct install targets** and **150 hidden dependency entries**. `list` shows direct targets; `add` resolves hidden dependencies.
- `0.4.0-alpha.1` adds `kbd`, `aspect-ratio`, `button-group`, `toggle-group`, `input-group`, `number-input`, `stat-card`, `timeline`, `tree-view`, `qr-code`, `image-viewer`, `chat`, `chat-message`, `chat-input`, and the `auth-01`, `auth-02` and `auth-03` blocks.
- `0.3.0` added `typed-select`, `command-palette`, `data-picker`, `multi-select`, `tag-input`, `donut-chart`, `radar-chart`, and `radial-chart`.
@@ -45,8 +45,8 @@ ShellUI is pre-1.0, so APIs and generated output can still change between minor
## Requirements
-- The .NET 10 SDK
-- A Blazor project on .NET 8, 9 or 10 with the CLI, or on .NET 10 with the NuGet package
+- The .NET 8 SDK or later
+- A Blazor project on .NET 8, 9 or 10, with the CLI or the NuGet package
- Tailwind CSS `4.3.2` via either:
- the standalone CLI, which does not require Node.js; or
- npm, which requires Node.js and npm
diff --git a/ShellUI.Tests/AddAllTests.cs b/ShellUI.Tests/AddAllTests.cs
new file mode 100644
index 0000000..55e5a50
--- /dev/null
+++ b/ShellUI.Tests/AddAllTests.cs
@@ -0,0 +1,33 @@
+using ShellUI.CLI.Services;
+using ShellUI.Templates;
+
+namespace ShellUI.Tests;
+
+public class AddAllTests
+{
+ [Fact]
+ public void LibraryComponents_IsEveryListedTargetExceptLayoutBlocks()
+ {
+ var all = ComponentInstaller.LibraryComponents().ToList();
+ var listed = ComponentRegistry.Components.Values.Where(m => m.IsAvailable).ToList();
+
+ Assert.Equal(listed.Count(m => !m.IsLayoutBlock), all.Count);
+ Assert.Contains("button", all);
+ Assert.Contains("chart", all);
+ Assert.DoesNotContain("dashboard-02", all);
+ Assert.DoesNotContain("auth-01", all);
+ Assert.DoesNotContain("sidebar-account", all);
+ Assert.DoesNotContain("tab-models", all);
+ }
+
+ [Theory]
+ [InlineData("bar-chart", "chart", true)]
+ [InlineData("multi-series-chart", "chart", true)]
+ [InlineData("button", "chart", false)]
+ [InlineData("dialog", "button", false)]
+ public void DependsOnAny_FollowsDependenciesTransitively(string name, string excluded, bool expected)
+ {
+ var names = new HashSet(StringComparer.OrdinalIgnoreCase) { excluded };
+ Assert.Equal(expected, ComponentInstaller.DependsOnAny(name, names));
+ }
+}
diff --git a/ShellUI.Tests/DoctorTests.cs b/ShellUI.Tests/DoctorTests.cs
new file mode 100644
index 0000000..b24202f
--- /dev/null
+++ b/ShellUI.Tests/DoctorTests.cs
@@ -0,0 +1,115 @@
+using System.Text.Json;
+using ShellUI.CLI.Services;
+using ShellUI.Core.Models;
+
+namespace ShellUI.Tests;
+
+public class DoctorTests : IDisposable
+{
+ private readonly string _root = Path.Combine(Path.GetTempPath(), $"doctor-{Guid.NewGuid():N}");
+
+ public DoctorTests()
+ {
+ Directory.CreateDirectory(Path.Combine(_root, "Components", "UI", "Variants"));
+ Directory.CreateDirectory(Path.Combine(_root, "wwwroot"));
+ Directory.CreateDirectory(Path.Combine(_root, "Build"));
+ File.WriteAllText(Path.Combine(_root, "MyApp.csproj"), "");
+ File.WriteAllText(Path.Combine(_root, "Build", "ShellUI.targets"), "");
+ File.WriteAllText(Path.Combine(_root, "Components", "UI", "Button.razor"), "");
+ File.WriteAllText(Path.Combine(_root, "Components", "UI", "Variants", "ButtonVariants.cs"), "");
+ File.WriteAllText(Path.Combine(_root, "Components", "_Imports.razor"), "@using MyApp.Components.UI\n@using MyApp.Components.UI.Variants\n");
+ File.WriteAllText(Path.Combine(_root, "Components", "App.razor"),
+ "\n" +
+ "");
+ File.WriteAllText(Path.Combine(_root, "wwwroot", "input.css"), "@import \"tailwindcss\";");
+ File.WriteAllText(Path.Combine(_root, "wwwroot", "app.css"), "");
+ File.SetLastWriteTimeUtc(Path.Combine(_root, "wwwroot", "app.css"), DateTime.UtcNow.AddMinutes(1));
+ WriteConfig("button", "button-variants");
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
+ }
+
+ private void WriteConfig(params string[] installed)
+ {
+ var config = new ShellUIConfig { InstalledComponents = installed.Select(n => new InstalledComponent { Name = n, Version = "0.4.0" }).ToList() };
+ File.WriteAllText(Path.Combine(_root, "shellui.json"), JsonSerializer.Serialize(config));
+ }
+
+ private List Errors() => DoctorService.Diagnose(_root).Where(f => f.Severity == DoctorSeverity.Error).ToList();
+
+ [Fact]
+ public void HealthyProject_HasNoErrors()
+ {
+ Assert.Empty(Errors());
+ }
+
+ [Fact]
+ public void MissingShelluiJson_IsReported()
+ {
+ File.Delete(Path.Combine(_root, "shellui.json"));
+
+ Assert.Contains(Errors(), f => f.Message.Contains("shellui.json"));
+ }
+
+ [Fact]
+ public void NoRenderMode_IsReported()
+ {
+ var app = Path.Combine(_root, "Components", "App.razor");
+ File.WriteAllText(app, File.ReadAllText(app).Replace(" @rendermode=\"InteractiveServer\"", ""));
+
+ Assert.Contains(Errors(), f => f.Message.Contains("render mode"));
+ }
+
+ [Fact]
+ public void HostPageWithoutShelluiJs_IsReported()
+ {
+ var app = Path.Combine(_root, "Components", "App.razor");
+ File.WriteAllText(app, File.ReadAllText(app).Replace("", ""));
+
+ Assert.Contains(Errors(), f => f.Message.Contains("shellui.js"));
+ }
+
+ [Fact]
+ public void MissingDependency_IsReported()
+ {
+ WriteConfig("button");
+
+ Assert.Contains(Errors(), f => f.Message.Contains("button-variants"));
+ }
+
+ [Fact]
+ public void DeletedComponentFile_IsReported()
+ {
+ File.Delete(Path.Combine(_root, "Components", "UI", "Button.razor"));
+
+ Assert.Contains(Errors(), f => f.Message.Contains("file is gone") && f.Message.Contains("button"));
+ }
+
+ [Fact]
+ public void MissingUsing_IsReported()
+ {
+ File.WriteAllText(Path.Combine(_root, "Components", "_Imports.razor"), "@using MyApp.Components.UI\n");
+
+ Assert.Contains(Errors(), f => f.Message.Contains("MyApp.Components.UI.Variants"));
+ }
+
+ [Fact]
+ public void PackageAndCliTogether_IsAWarning()
+ {
+ File.WriteAllText(Path.Combine(_root, "MyApp.csproj"),
+ "");
+
+ Assert.Contains(DoctorService.Diagnose(_root), f => f.Severity == DoctorSeverity.Warning && f.Message.Contains("ShellUI.Components package"));
+ }
+
+ [Fact]
+ public void StaleAppCss_IsAWarning()
+ {
+ File.SetLastWriteTimeUtc(Path.Combine(_root, "wwwroot", "app.css"), DateTime.UtcNow.AddHours(-1));
+
+ Assert.Contains(DoctorService.Diagnose(_root), f => f.Severity == DoctorSeverity.Warning && f.Message.Contains("older than"));
+ }
+}
diff --git a/ShellUI.Tests/PackageBehaviorTests.cs b/ShellUI.Tests/PackageBehaviorTests.cs
index 39dfce5..ab4b685 100644
--- a/ShellUI.Tests/PackageBehaviorTests.cs
+++ b/ShellUI.Tests/PackageBehaviorTests.cs
@@ -1,6 +1,7 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
+using Microsoft.Extensions.DependencyInjection;
using ShellUI.Components;
using ShellUI.Components.Models;
@@ -218,4 +219,47 @@ public void TimePicker_ShowsASetValue()
Assert.Contains("09:05", picker.Markup);
}
+
+ [Fact]
+ public async Task Sonner_ClearFadesEveryToastThenRemovesIt()
+ {
+ using var ctx = new BunitContext();
+ ctx.Services.AddShellUISonner();
+ var service = ctx.Services.GetRequiredService();
+ var sonner = ctx.Render();
+
+ await sonner.InvokeAsync(() => service.Show("Saved", duration: TimeSpan.Zero));
+ await sonner.InvokeAsync(() => service.Error("Failed", duration: TimeSpan.Zero));
+ await sonner.InvokeAsync(service.Clear);
+
+ Assert.All(service.Toasts, t => Assert.True(t.Dismissing));
+ Assert.Equal(2, sonner.FindAll(".opacity-0").Count);
+ sonner.WaitForAssertion(() => Assert.Empty(service.Toasts));
+ Assert.DoesNotContain("Saved", sonner.Markup);
+ }
+
+ [Fact]
+ public void ContextMenuOption_Destructive_ReplacesTheAccentColours()
+ {
+ using var ctx = new BunitContext();
+ var option = ctx.Render(p => p.Add(c => c.Destructive, true).Add(c => c.ChildContent, "Delete"));
+
+ var classes = option.Find("button").ClassList;
+ Assert.Contains("text-destructive", classes);
+ Assert.Contains("hover:bg-destructive/10", classes);
+ Assert.DoesNotContain("hover:bg-accent", classes);
+ }
+
+ [Fact]
+ public void ContextMenu_DestructiveItem_IsRed()
+ {
+ using var ctx = new BunitContext();
+ var menu = ctx.Render(p => p
+ .Add(c => c.IsOpen, true)
+ .Add(c => c.Items, new List { new() { Label = "Edit" }, new() { Label = "Delete", Destructive = true } }));
+
+ var buttons = menu.FindAll("button");
+ Assert.DoesNotContain("text-destructive", buttons[0].ClassList);
+ Assert.Contains("text-destructive", buttons[1].ClassList);
+ }
}
diff --git a/ShellUI.Tests/SafelistDriftTests.cs b/ShellUI.Tests/SafelistDriftTests.cs
index 3f3ae50..f55f69c 100644
--- a/ShellUI.Tests/SafelistDriftTests.cs
+++ b/ShellUI.Tests/SafelistDriftTests.cs
@@ -39,6 +39,28 @@ public void Safelist_MatchesGeneratedFromCurrentSources()
BuildDiffMessage(addedSinceCommit, removedSinceCommit));
}
+ [Fact]
+ public void Generator_HarvestsClassesInRazorExpressions()
+ {
+ var file = Path.Combine(Path.GetTempPath(), $"safelist-{Guid.NewGuid():N}.razor");
+ File.WriteAllLines(file, new[]
+ {
+ "",
+ "",
+ "@code { string Tone(string v) => v switch { \"ok\" => \"bg-green-500/20\", _ => \"bg-background\" }; }",
+ });
+ try
+ {
+ var classes = Program.GenerateSafelist(new[] { file });
+ foreach (var expected in new[] { "translate-y-[-50%]", "sm:rounded-lg", "text-destructive", "text-muted-foreground", "bg-green-500/20", "bg-background" })
+ Assert.Contains(expected, classes);
+ }
+ finally
+ {
+ File.Delete(file);
+ }
+ }
+
[Fact]
public void GeneratedTargetsFile_IsValidXml()
{
diff --git a/ShellUI.Tests/ShellUI.Tests.csproj b/ShellUI.Tests/ShellUI.Tests.csproj
index f7fee2a..0fda3c1 100644
--- a/ShellUI.Tests/ShellUI.Tests.csproj
+++ b/ShellUI.Tests/ShellUI.Tests.csproj
@@ -1,7 +1,7 @@
- net10.0
+ net8.0;net10.0
enable
enable
false
diff --git a/VERSIONING_STRATEGY.md b/VERSIONING_STRATEGY.md
index 33221b5..6ab7987 100644
--- a/VERSIONING_STRATEGY.md
+++ b/VERSIONING_STRATEGY.md
@@ -6,8 +6,8 @@ ShellUI uses one centralized version for the source templates, CLI tool, and pac
| Scope | Value |
|---|---|
-| Version | `0.4.0-alpha.3` (prerelease; latest stable `0.3.4`) |
-| Target framework | .NET 10 |
+| Version | `0.4.0-alpha.3` (prerelease; latest stable `0.3.5`) |
+| Target framework | .NET 8 and .NET 10 (`ShellUI.Components` also .NET 9) |
| Tailwind version | `4.3.2` |
`0.3.0-rc.1` was the last release targeting .NET 9.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 5ea66e5..df6d89c 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -7,7 +7,7 @@ This document describes the ShellUI source tree.
| Area | Current value |
|---|---|
| Version | See `Directory.Build.props` |
-| Target framework | .NET 10 (`net10.0`) |
+| Target framework | .NET 8 and .NET 10 (`net8.0;net10.0`); `ShellUI.Components` also `net9.0` |
| Tailwind CSS | `4.3.2` |
| Solution | `ShellUI.slnx` |
| Solution projects | 6 |
@@ -30,7 +30,7 @@ flowchart LR
Safelist[ShellUI.SafelistGenerator
internal build utility]
end
- Project[Consumer project
net10.0]
+ Project[Consumer project
net8.0 to net10.0]
Demo[NET10/BlazorInteractiveServer
demo outside the solution]
Developer --> CLI
diff --git a/docs/CLI_INSTALLATION.md b/docs/CLI_INSTALLATION.md
index b6e52c8..b4bd853 100644
--- a/docs/CLI_INSTALLATION.md
+++ b/docs/CLI_INSTALLATION.md
@@ -4,12 +4,12 @@
| Context | Version |
|---|---|
-| Latest stable (recommended) | `0.3.4`, needs the .NET 10 runtime |
+| Latest stable (recommended) | `0.3.5`, needs the .NET 10 runtime |
| Prerelease | `0.4.0-alpha.3`, install with `--version 0.4.0-alpha.3` |
| Previous stable | `0.2.1` |
| Tailwind | `4.3.2` |
-The tool needs the .NET 10 runtime. The projects it sets up can target .NET 8, 9 or 10.
+The tool runs on .NET 8 or later (`0.4.0-alpha.3` and the 0.3 line need the .NET 10 runtime). The projects it sets up can target .NET 8, 9 or 10.
## Global installation
@@ -44,7 +44,7 @@ The manifest is `.config/dotnet-tools.json` and uses the installed package versi
"isRoot": true,
"tools": {
"shellui.cli": {
- "version": "0.3.4",
+ "version": "0.3.5",
"commands": ["shellui"]
}
}
@@ -65,7 +65,7 @@ Commit the manifest so every developer and CI job uses the same tool version.
## Building from source
-The repository targets `net10.0`. Build it with the .NET 10 SDK:
+The repository targets `net8.0` and `net10.0`. Build it with the .NET 10 SDK; running the tests on both frameworks also needs the .NET 8 runtime:
```bash
dotnet --version
diff --git a/docs/CLI_SYNTAX.md b/docs/CLI_SYNTAX.md
index e5b937c..b776b02 100644
--- a/docs/CLI_SYNTAX.md
+++ b/docs/CLI_SYNTAX.md
@@ -1,6 +1,6 @@
# ShellUI CLI Syntax
-This reference covers CLI `0.4.0-alpha.3` (prerelease): it runs on .NET 10, sets up Blazor projects on .NET 8, 9 and 10, uses Tailwind CSS `4.3.2`, and has 93 direct component targets. Some options, such as `--dashboard`, are not in the stable 0.3.4; see the [v0.3.4 reference](https://github.com/shellui-dev/shellui/blob/v0.3.4/docs/CLI_SYNTAX.md) for that version.
+This reference covers CLI `0.4.0-alpha.3` (prerelease): it runs on .NET 8 or later (`0.4.0-alpha.3` itself needs .NET 10), sets up Blazor projects on .NET 8, 9 and 10, uses Tailwind CSS `4.3.2`, and has 93 direct component targets. Some options, such as `--dashboard`, are not in the stable 0.3.4; see the [v0.3.4 reference](https://github.com/shellui-dev/shellui/blob/v0.3.4/docs/CLI_SYNTAX.md) for that version.
## Command prefix
@@ -17,10 +17,11 @@ shellui --version
```text
shellui init [--force] [--style