diff --git a/.gitignore b/.gitignore
index 5a26525..113026f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,44 @@
+## .NET
+bin/
+obj/
+
+## Node
node_modules/
+
+## User-specific files
+*.user
+*.suo
+*.userosscache
+*.sln.docstates
+
+## Visual Studio
+.vs/
+
+## JetBrains Rider
+.idea/
+
+## Build results
+[Dd]ebug/
+[Rr]elease/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
[Bb]in/
[Oo]bj/
-package-lock.json
\ No newline at end of file
+[Ll]og/
+[Ll]ogs/
+
+## NuGet
+*.nupkg
+*.snupkg
+**/[Pp]ackages/*
+!**/[Pp]ackages/build/
+
+## OS files
+Thumbs.db
+ehthumbs.db
+Desktop.ini
+.DS_Store
diff --git a/Components/App.razor b/Components/App.razor
deleted file mode 100644
index cbca62b..0000000
--- a/Components/App.razor
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Components/Layout/MainLayout.razor b/Components/Layout/MainLayout.razor
deleted file mode 100644
index bb2a579..0000000
--- a/Components/Layout/MainLayout.razor
+++ /dev/null
@@ -1,77 +0,0 @@
-@inherits LayoutComponentBase
-
-
-
- @* Desktop sidebar — collapsible *@
-
-
- @* Mobile overlay *@
- @if (mobileOpen)
- {
-
- @* Backdrop *@
-
-
- @* Drawer *@
-
-
- }
-
- @* Main content *@
-
- @* Top bar *@
-
-
- @* Page content *@
-
- @Body
-
-
-
-
-@code {
- private bool sidebarCollapsed;
- private bool mobileOpen;
-
- private void ToggleSidebar() => sidebarCollapsed = !sidebarCollapsed;
- private void OpenMobile() => mobileOpen = true;
- private void CloseMobile() => mobileOpen = false;
-}
diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor
deleted file mode 100644
index 3a252f2..0000000
--- a/Components/Layout/NavMenu.razor
+++ /dev/null
@@ -1,73 +0,0 @@
-@* Sidebar header *@
-
-
-@* Navigation *@
-
- @if (!Collapsed)
- {
-
- Navigation
-
- }
-
-
-
- @if (!Collapsed)
- {
- Home
- }
-
-
-
-
- @if (!Collapsed)
- {
- Weather
- }
-
-
-
-@code {
- [Parameter] public bool Collapsed { get; set; }
- [Parameter] public EventCallback OnToggleSidebar { get; set; }
- [Parameter] public EventCallback OnNavigated { get; set; }
-
- private string NavLinkClass => Collapsed
- ? "nav-link group flex items-center justify-center rounded-md p-2 text-sm font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
- : "nav-link group flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent";
-
- private async Task HandleToggle()
- {
- if (OnToggleSidebar.HasDelegate)
- await OnToggleSidebar.InvokeAsync();
- }
-
- private async Task HandleNav()
- {
- if (OnNavigated.HasDelegate)
- await OnNavigated.InvokeAsync();
- }
-}
-
diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor
deleted file mode 100644
index 9001e0b..0000000
--- a/Components/Pages/Home.razor
+++ /dev/null
@@ -1,7 +0,0 @@
-@page "/"
-
-Home
-
-Hello, world!
-
-Welcome to your new app.
diff --git a/Components/Pages/NotFound.razor b/Components/Pages/NotFound.razor
deleted file mode 100644
index 917ada1..0000000
--- a/Components/Pages/NotFound.razor
+++ /dev/null
@@ -1,5 +0,0 @@
-@page "/not-found"
-@layout MainLayout
-
-Not Found
-Sorry, the content you are looking for does not exist.
\ No newline at end of file
diff --git a/Components/Routes.razor b/Components/Routes.razor
deleted file mode 100644
index de342b1..0000000
--- a/Components/Routes.razor
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/Enciphered.Blazor.UIComponents.Demo/Components/App.razor b/Enciphered.Blazor.UIComponents.Demo/Components/App.razor
new file mode 100644
index 0000000..3e1e747
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Demo/Components/App.razor
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Enciphered.Blazor.UIComponents.Demo/Components/Layout/MainLayout.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Layout/MainLayout.razor
new file mode 100644
index 0000000..0e92629
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Demo/Components/Layout/MainLayout.razor
@@ -0,0 +1,87 @@
+@inherits LayoutComponentBase
+@using Enciphered.Blazor.UIComponents
+
+
+
+
+
+
+

+

+
+
Enciphered UI
+
+
+
+
+
+
+
+
+
+
+
+ Home
+
+
+
+
+
+ Counter
+
+
+
+
+
+ Weather
+
+
+
+
+
+ Forms
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @Body
+
+
+
diff --git a/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Counter.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Counter.razor
new file mode 100644
index 0000000..1a4f8e7
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Counter.razor
@@ -0,0 +1,19 @@
+@page "/counter"
+@rendermode InteractiveServer
+
+Counter
+
+Counter
+
+Current count: @currentCount
+
+
+
+@code {
+ private int currentCount = 0;
+
+ private void IncrementCount()
+ {
+ currentCount++;
+ }
+}
diff --git a/Components/Pages/Error.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Error.razor
similarity index 100%
rename from Components/Pages/Error.razor
rename to Enciphered.Blazor.UIComponents.Demo/Components/Pages/Error.razor
diff --git a/Enciphered.Blazor.UIComponents.Demo/Components/Pages/FormsDemo.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/FormsDemo.razor
new file mode 100644
index 0000000..9aaa1bd
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/FormsDemo.razor
@@ -0,0 +1,126 @@
+@page "/forms"
+@rendermode InteractiveServer
+@using System.ComponentModel.DataAnnotations
+
+Forms
+
+
+
+
Forms Demo
+
All input components with DataAnnotations validation.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (_submitted)
+ {
+
+
✓ Form submitted successfully
+
Name: @_submittedName
+
+ }
+
+
+@code {
+ private FormModel Model { get; set; } = new();
+ private EditContext _editContext = null!;
+ private bool _submitted;
+ private string _submittedName = "";
+
+ protected override void OnInitialized()
+ {
+ _editContext = new EditContext(Model);
+ }
+
+ private string? GetError(string fieldName)
+ {
+ var field = _editContext.Field(fieldName);
+ var messages = _editContext.GetValidationMessages(field);
+ return messages.FirstOrDefault();
+ }
+
+ private void HandleSubmit()
+ {
+ _submitted = false;
+
+ if (!_editContext.Validate())
+ return;
+
+ _submittedName = Model.Name!;
+ _submitted = true;
+ }
+
+ private void HandleReset()
+ {
+ Model = new();
+ _submitted = false;
+ _editContext = new EditContext(Model);
+ }
+
+ public class FormModel
+ {
+ [Required(ErrorMessage = "Name is required.")]
+ [StringLength(100, MinimumLength = 2, ErrorMessage = "Name must be 2–100 characters.")]
+ public string? Name { get; set; }
+
+ [Required(ErrorMessage = "Email is required.")]
+ [EmailAddress(ErrorMessage = "Invalid email address.")]
+ public string? Email { get; set; }
+
+ [Required(ErrorMessage = "Password is required.")]
+ [StringLength(64, MinimumLength = 8, ErrorMessage = "Password must be 8–64 characters.")]
+ public string? Password { get; set; }
+
+ [Required(ErrorMessage = "Age is required.")]
+ [Range(1, 150, ErrorMessage = "Age must be between 1 and 150.")]
+ public double? Age { get; set; }
+
+ [Required(ErrorMessage = "Birth date is required.")]
+ public DateOnly? BirthDate { get; set; }
+
+ [Required(ErrorMessage = "Preferred time is required.")]
+ public TimeOnly? PreferredTime { get; set; }
+
+ [Required(ErrorMessage = "Appointment is required.")]
+ public DateTime? Appointment { get; set; }
+ }
+}
diff --git a/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Home.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Home.razor
new file mode 100644
index 0000000..5bfc55f
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Home.razor
@@ -0,0 +1,8 @@
+@page "/"
+
+Home
+
+
+
Welcome
+
This is the Enciphered Blazor UI Components demo app.
+
diff --git a/Components/Pages/Weather.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Weather.razor
similarity index 96%
rename from Components/Pages/Weather.razor
rename to Enciphered.Blazor.UIComponents.Demo/Components/Pages/Weather.razor
index f437e5e..381bbd2 100644
--- a/Components/Pages/Weather.razor
+++ b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Weather.razor
@@ -18,7 +18,7 @@ else
| Date |
Temp. (C) |
- Temp. (F) |
+ Temp. (F) |
Summary |
diff --git a/Enciphered.Blazor.UIComponents.Demo/Components/Routes.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Routes.razor
new file mode 100644
index 0000000..1b80547
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Demo/Components/Routes.razor
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Components/_Imports.razor b/Enciphered.Blazor.UIComponents.Demo/Components/_Imports.razor
similarity index 78%
rename from Components/_Imports.razor
rename to Enciphered.Blazor.UIComponents.Demo/Components/_Imports.razor
index 7ed3f68..230977f 100644
--- a/Components/_Imports.razor
+++ b/Enciphered.Blazor.UIComponents.Demo/Components/_Imports.razor
@@ -6,6 +6,6 @@
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
+@using Enciphered.Blazor.UIComponents.Demo
+@using Enciphered.Blazor.UIComponents.Demo.Components
@using Enciphered.Blazor.UIComponents
-@using Enciphered.Blazor.UIComponents.Components
-@using Enciphered.Blazor.UIComponents.Components.Layout
diff --git a/Enciphered.Blazor.UIComponents.Demo/Enciphered.Blazor.UIComponents.Demo.csproj b/Enciphered.Blazor.UIComponents.Demo/Enciphered.Blazor.UIComponents.Demo.csproj
new file mode 100644
index 0000000..4336ecc
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Demo/Enciphered.Blazor.UIComponents.Demo.csproj
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/Program.cs b/Enciphered.Blazor.UIComponents.Demo/Program.cs
similarity index 75%
rename from Program.cs
rename to Enciphered.Blazor.UIComponents.Demo/Program.cs
index 5d166e6..b65e9c0 100644
--- a/Program.cs
+++ b/Enciphered.Blazor.UIComponents.Demo/Program.cs
@@ -1,4 +1,4 @@
-using Enciphered.Blazor.UIComponents.Components;
+using Enciphered.Blazor.UIComponents.Demo.Components;
var builder = WebApplication.CreateBuilder(args);
@@ -15,13 +15,15 @@ if (!app.Environment.IsDevelopment())
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
-app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
+
app.UseHttpsRedirection();
+
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents()
- .AddInteractiveServerRenderMode();
+ .AddInteractiveServerRenderMode()
+ .AddAdditionalAssemblies(typeof(Enciphered.Blazor.UIComponents.SidebarProvider).Assembly);
-app.Run();
+app.Run();
\ No newline at end of file
diff --git a/Properties/launchSettings.json b/Enciphered.Blazor.UIComponents.Demo/Properties/launchSettings.json
similarity index 81%
rename from Properties/launchSettings.json
rename to Enciphered.Blazor.UIComponents.Demo/Properties/launchSettings.json
index 14690c5..63305cf 100644
--- a/Properties/launchSettings.json
+++ b/Enciphered.Blazor.UIComponents.Demo/Properties/launchSettings.json
@@ -5,7 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
- "applicationUrl": "http://localhost:5182",
+ "applicationUrl": "http://localhost:5146",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -14,7 +14,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
- "applicationUrl": "https://localhost:7009;http://localhost:5182",
+ "applicationUrl": "https://localhost:7065;http://localhost:5146",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
diff --git a/Enciphered.Blazor.UIComponents.Demo/Styles/app.css b/Enciphered.Blazor.UIComponents.Demo/Styles/app.css
new file mode 100644
index 0000000..d3c2799
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Demo/Styles/app.css
@@ -0,0 +1,160 @@
+@import "tailwindcss";
+
+@custom-variant dark (&:where(.dark, .dark *));
+
+/*
+ * shadcn/ui design tokens.
+ * These must be defined in every project that uses Tailwind classes with
+ * the Enciphered.Blazor.UIComponents design system. The library ships a
+ * pre-built CSS (via _content/.../css/app.css) that covers all component
+ * classes, but if your own Razor files use token-based utilities like
+ * bg-background, text-foreground, etc., you need these tokens in your
+ * own Tailwind source file.
+ */
+
+/* ---------- light tokens (neutral) ---------- */
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.985 0 0);
+ --border-color: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --radius: 0.625rem;
+ --sidebar-background: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+/* ---------- dark tokens (neutral) ---------- */
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.145 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.145 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.985 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.985 0 0);
+ --border-color: oklch(0.269 0 0);
+ --input: oklch(0.269 0 0);
+ --ring: oklch(0.439 0 0);
+ --sidebar-background: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.985 0 0);
+ --sidebar-primary-foreground: oklch(0.205 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(0.269 0 0);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+/* ---------- Map CSS vars → Tailwind theme ---------- */
+@theme {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border-color);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-sidebar: var(--sidebar-background);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --radius: var(--radius);
+ --sidebar-width: 16rem;
+ --sidebar-width-icon: 3rem;
+}
+
+/* ---------- Date / Time input native chrome overrides ---------- */
+input[type="date"]::-webkit-calendar-picker-indicator,
+input[type="time"]::-webkit-calendar-picker-indicator,
+input[type="datetime-local"]::-webkit-calendar-picker-indicator {
+ display: none;
+ -webkit-appearance: none;
+}
+
+input[type="date"]::-webkit-date-and-time-value,
+input[type="time"]::-webkit-date-and-time-value,
+input[type="datetime-local"]::-webkit-date-and-time-value {
+ text-align: left;
+}
+
+input[type="date"],
+input[type="time"],
+input[type="datetime-local"] {
+ color-scheme: light;
+}
+
+.dark input[type="date"],
+.dark input[type="time"],
+.dark input[type="datetime-local"] {
+ color-scheme: dark;
+}
+
+/* ---------- Custom scrollbar for picker columns ---------- */
+.scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: var(--muted) transparent;
+}
+
+.scrollbar-thin::-webkit-scrollbar {
+ width: 6px;
+}
+
+.scrollbar-thin::-webkit-scrollbar-track {
+ background: transparent;
+ border-radius: 3px;
+}
+
+.scrollbar-thin::-webkit-scrollbar-thumb {
+ background-color: var(--muted);
+ border-radius: 3px;
+}
+
+.scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background-color: var(--muted-foreground);
+}
diff --git a/appsettings.Development.json b/Enciphered.Blazor.UIComponents.Demo/appsettings.Development.json
similarity index 100%
rename from appsettings.Development.json
rename to Enciphered.Blazor.UIComponents.Demo/appsettings.Development.json
diff --git a/appsettings.json b/Enciphered.Blazor.UIComponents.Demo/appsettings.json
similarity index 100%
rename from appsettings.json
rename to Enciphered.Blazor.UIComponents.Demo/appsettings.json
diff --git a/Enciphered.Blazor.UIComponents.Demo/wwwroot/css/app.css b/Enciphered.Blazor.UIComponents.Demo/wwwroot/css/app.css
new file mode 100644
index 0000000..4467541
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Demo/wwwroot/css/app.css
@@ -0,0 +1,2 @@
+/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
+@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--container-lg:32rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--radius-md:.375rem;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius:var(--radius);--color-background:var(--background);--color-foreground:var(--foreground);--color-card:var(--card);--color-card-foreground:var(--card-foreground);--color-muted-foreground:var(--muted-foreground);--color-border:var(--border-color);--color-input:var(--input);--color-sidebar-foreground:var(--sidebar-foreground)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.end{inset-inline-end:var(--spacing)}.mt-1{margin-top:calc(var(--spacing) * 1)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.hidden{display:none}.table{display:table}.aspect-square{aspect-ratio:1}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.h-14{height:calc(var(--spacing) * 14)}.min-h-svh{min-height:100svh}.max-w-lg{max-width:var(--container-lg)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.items-center{align-items:center}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-border{border-color:var(--color-border)}.border-input{border-color:var(--color-input)}.bg-background{background-color:var(--color-background)}.bg-card{background-color:var(--color-card)}.p-4{padding:calc(var(--spacing) * 4)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.pt-2{padding-top:calc(var(--spacing) * 2)}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-card-foreground{color:var(--color-card-foreground)}.text-foreground{color:var(--color-foreground)}.text-muted-foreground{color:var(--color-muted-foreground)}.text-sidebar-foreground\/50{color:var(--color-sidebar-foreground)}@supports (color:color-mix(in lab, red, red)){.text-sidebar-foreground\/50{color:color-mix(in oklab, var(--color-sidebar-foreground) 50%, transparent)}}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.group-data-\[state\=collapsed\]\:group-data-\[mobile\=false\]\:hidden:is(:where(.group)[data-state=collapsed] *):is(:where(.group)[data-mobile=false] *){display:none}@media (min-width:48rem){.md\:hidden{display:none}.md\:p-6{padding:calc(var(--spacing) * 6)}}.dark\:block:where(.dark,.dark *){display:block}.dark\:hidden:where(.dark,.dark *){display:none}}:root{--background:oklch(100% 0 0);--foreground:oklch(14.5% 0 0);--card:oklch(100% 0 0);--card-foreground:oklch(14.5% 0 0);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.5% 0 0);--primary:oklch(20.5% 0 0);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(20.5% 0 0);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.6% 0 0);--accent:oklch(97% 0 0);--accent-foreground:oklch(20.5% 0 0);--destructive:oklch(57.7% .245 27.325);--destructive-foreground:oklch(98.5% 0 0);--border-color:oklch(92.2% 0 0);--input:oklch(92.2% 0 0);--ring:oklch(70.8% 0 0);--radius:.625rem;--sidebar-background:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.5% 0 0);--sidebar-primary:oklch(20.5% 0 0);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(97% 0 0);--sidebar-accent-foreground:oklch(20.5% 0 0);--sidebar-border:oklch(92.2% 0 0);--sidebar-ring:oklch(70.8% 0 0)}.dark{--background:oklch(14.5% 0 0);--foreground:oklch(98.5% 0 0);--card:oklch(14.5% 0 0);--card-foreground:oklch(98.5% 0 0);--popover:oklch(14.5% 0 0);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(98.5% 0 0);--primary-foreground:oklch(20.5% 0 0);--secondary:oklch(26.9% 0 0);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(26.9% 0 0);--muted-foreground:oklch(70.8% 0 0);--accent:oklch(26.9% 0 0);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(57.7% .245 27.325);--destructive-foreground:oklch(98.5% 0 0);--border-color:oklch(26.9% 0 0);--input:oklch(26.9% 0 0);--ring:oklch(43.9% 0 0);--sidebar-background:oklch(20.5% 0 0);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(98.5% 0 0);--sidebar-primary-foreground:oklch(20.5% 0 0);--sidebar-accent:oklch(26.9% 0 0);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(26.9% 0 0);--sidebar-ring:oklch(55.6% 0 0)}input[type=date]::-webkit-calendar-picker-indicator{-webkit-appearance:none;display:none}input[type=time]::-webkit-calendar-picker-indicator{-webkit-appearance:none;display:none}input[type=datetime-local]::-webkit-calendar-picker-indicator{-webkit-appearance:none;display:none}input[type=date]::-webkit-date-and-time-value{text-align:left}input[type=time]::-webkit-date-and-time-value{text-align:left}input[type=datetime-local]::-webkit-date-and-time-value{text-align:left}input[type=date],input[type=time],input[type=datetime-local]{color-scheme:light}.dark input[type=date],.dark input[type=time],.dark input[type=datetime-local]{color-scheme:dark}.scrollbar-thin{scrollbar-width:thin;scrollbar-color:var(--muted) transparent}.scrollbar-thin::-webkit-scrollbar{width:6px}.scrollbar-thin::-webkit-scrollbar-track{background:0 0;border-radius:3px}.scrollbar-thin::-webkit-scrollbar-thumb{background-color:var(--muted);border-radius:3px}.scrollbar-thin::-webkit-scrollbar-thumb:hover{background-color:var(--muted-foreground)}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}
\ No newline at end of file
diff --git a/wwwroot/enci.svg b/Enciphered.Blazor.UIComponents.Demo/wwwroot/enci.svg
similarity index 100%
rename from wwwroot/enci.svg
rename to Enciphered.Blazor.UIComponents.Demo/wwwroot/enci.svg
diff --git a/wwwroot/enci_white.svg b/Enciphered.Blazor.UIComponents.Demo/wwwroot/enci_white.svg
similarity index 100%
rename from wwwroot/enci_white.svg
rename to Enciphered.Blazor.UIComponents.Demo/wwwroot/enci_white.svg
diff --git a/wwwroot/favicon.png b/Enciphered.Blazor.UIComponents.Demo/wwwroot/favicon.png
similarity index 100%
rename from wwwroot/favicon.png
rename to Enciphered.Blazor.UIComponents.Demo/wwwroot/favicon.png
diff --git a/Enciphered.Blazor.UIComponents.Tests/.runsettings b/Enciphered.Blazor.UIComponents.Tests/.runsettings
new file mode 100644
index 0000000..1bb6d43
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Tests/.runsettings
@@ -0,0 +1,9 @@
+
+
+
+ chromium
+
+ true
+
+
+
diff --git a/Enciphered.Blazor.UIComponents.Tests/DemoServerFixture.cs b/Enciphered.Blazor.UIComponents.Tests/DemoServerFixture.cs
new file mode 100644
index 0000000..99b6f99
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Tests/DemoServerFixture.cs
@@ -0,0 +1,91 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Sockets;
+
+namespace Enciphered.Blazor.UIComponents.Tests;
+
+///
+/// Launches the demo Blazor app as a separate process on a random free port.
+/// Shared across all tests in the assembly via [SetUpFixture].
+///
+public sealed class DemoServerFixture : IDisposable
+{
+ private Process? _process;
+
+ public string BaseUrl { get; private set; } = string.Empty;
+
+ public async Task StartAsync()
+ {
+ var port = GetFreePort();
+ BaseUrl = $"http://localhost:{port}";
+
+ // Resolve the demo project directory (navigate up from test bin output)
+ var testDir = AppContext.BaseDirectory; // …Tests/bin/Debug/net9.0
+ var solutionRoot = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", ".."));
+ var demoProjectDir = Path.Combine(solutionRoot, "Enciphered.Blazor.UIComponents.Demo");
+
+ if (!Directory.Exists(demoProjectDir))
+ throw new DirectoryNotFoundException($"Demo project not found at: {demoProjectDir}");
+
+ _process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "dotnet",
+ Arguments = $"run --no-build --urls {BaseUrl}",
+ WorkingDirectory = demoProjectDir,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ Environment =
+ {
+ ["ASPNETCORE_ENVIRONMENT"] = "Development",
+ ["DOTNET_NOLOGO"] = "1"
+ }
+ }
+ };
+
+ _process.Start();
+
+ // Wait for the server to be ready by polling the URL
+ using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
+ var deadline = DateTime.UtcNow.AddSeconds(30);
+
+ while (DateTime.UtcNow < deadline)
+ {
+ try
+ {
+ var response = await httpClient.GetAsync(BaseUrl);
+ if (response.IsSuccessStatusCode)
+ return;
+ }
+ catch
+ {
+ // Server not ready yet
+ }
+ await Task.Delay(500);
+ }
+
+ throw new TimeoutException($"Demo server did not start within 30 seconds at {BaseUrl}");
+ }
+
+ public void Dispose()
+ {
+ if (_process is not null && !_process.HasExited)
+ {
+ _process.Kill(entireProcessTree: true);
+ _process.WaitForExit(5000);
+ _process.Dispose();
+ }
+ }
+
+ private static int GetFreePort()
+ {
+ using var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+}
diff --git a/Enciphered.Blazor.UIComponents.Tests/Enciphered.Blazor.UIComponents.Tests.csproj b/Enciphered.Blazor.UIComponents.Tests/Enciphered.Blazor.UIComponents.Tests.csproj
new file mode 100644
index 0000000..a2e8204
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Tests/Enciphered.Blazor.UIComponents.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net9.0
+ latest
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Enciphered.Blazor.UIComponents.Tests/FormsTests.cs b/Enciphered.Blazor.UIComponents.Tests/FormsTests.cs
new file mode 100644
index 0000000..adf1226
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Tests/FormsTests.cs
@@ -0,0 +1,605 @@
+using Microsoft.Playwright;
+
+namespace Enciphered.Blazor.UIComponents.Tests;
+
+[TestFixture]
+public class FormsTests : PlaywrightTestBase
+{
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private async Task GoToFormsAsync()
+ {
+ await Page.GotoAsync($"{BaseUrl}/forms", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+ // Wait for Blazor interactive mode to be ready
+ await Page.WaitForSelectorAsync("[data-testid='btn-submit']", new PageWaitForSelectorOptions { Timeout = 10_000 });
+ }
+
+ private ILocator Input(string testId) => Page.Locator($"[data-testid='{testId}']");
+ private ILocator Trigger(string testId) => Page.Locator($"[data-testid='{testId}']");
+ private ILocator Btn(string testId) => Page.Locator($"[data-testid='{testId}']");
+
+ ///
+ /// Select a date via the calendar popover.
+ /// Opens the trigger, uses month/year pickers to navigate, then clicks the day.
+ ///
+ private async Task SelectDateAsync(string triggerId, DateOnly target)
+ {
+ // Open the popover
+ await Trigger(triggerId).ClickAsync();
+ await Page.WaitForTimeoutAsync(200);
+
+ await NavigateCalendarToDate(target);
+
+ // Click the target day (only enabled buttons in the calendar day grid)
+ // The day grid is the last grid-cols-7 div; find the button with matching day text
+ var dayGrid = Page.Locator(".grid.grid-cols-7").Last;
+ var dayButton = dayGrid.Locator($"button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = target.Day.ToString() }).First;
+ await dayButton.ClickAsync();
+ await Page.WaitForTimeoutAsync(200);
+ }
+
+ ///
+ /// Navigate the open calendar to a specific month/year using the month and year pickers.
+ ///
+ private async Task NavigateCalendarToDate(DateOnly target)
+ {
+ // Click year header to open year picker, then select the year
+ var yearButton = Page.Locator("[data-calendar-year]");
+ await yearButton.ClickAsync();
+ await Page.WaitForTimeoutAsync(100);
+
+ // The year picker is a scrollable grid; find and click the target year
+ var yearGrid = Page.Locator(".grid.grid-cols-4");
+ var targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
+
+ // If the year isn't visible, use prev/next to shift the year range (±20 per click)
+ var attempts = 0;
+ while (await targetYearBtn.CountAsync() == 0 && attempts < 10)
+ {
+ // Read the first year button text to determine which direction to go
+ var firstYearText = await yearGrid.Locator("button").First.InnerTextAsync();
+ var firstYear = int.Parse(firstYearText.Trim());
+
+ if (target.Year < firstYear)
+ await Page.Locator("button[aria-label='Previous month']").ClickAsync();
+ else
+ await Page.Locator("button[aria-label='Next month']").ClickAsync();
+ await Page.WaitForTimeoutAsync(50);
+ yearGrid = Page.Locator(".grid.grid-cols-4");
+ targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
+ attempts++;
+ }
+
+ await targetYearBtn.First.ClickAsync();
+ await Page.WaitForTimeoutAsync(100);
+
+ // Now click month header to open month picker, then select the month
+ var monthButton = Page.Locator("[data-calendar-month]");
+ await monthButton.ClickAsync();
+ await Page.WaitForTimeoutAsync(100);
+
+ var monthGrid = Page.Locator(".grid.grid-cols-3");
+ var targetMonthText = new DateOnly(2000, target.Month, 1).ToString("MMM");
+ await monthGrid.Locator($"button:has-text('{targetMonthText}')").First.ClickAsync();
+ await Page.WaitForTimeoutAsync(100);
+ }
+
+ ///
+ /// Select a time via the time picker popover.
+ /// Opens the trigger, clicks the hour, minute, and AM/PM.
+ ///
+ private async Task SelectTimeAsync(string triggerId, int hour, int minute)
+ {
+ // Open the popover
+ await Trigger(triggerId).ClickAsync();
+ await Page.WaitForTimeoutAsync(200);
+
+ await PickTimeInOpenPopover(hour, minute);
+
+ // Close popover by clicking the backdrop overlay
+ await Page.Locator(".fixed.inset-0.z-40").ClickAsync(new LocatorClickOptions { Force = true });
+ await Page.WaitForTimeoutAsync(100);
+ }
+
+ ///
+ /// Pick hour, minute, and AM/PM in an already-open time picker.
+ /// Scopes all locators to the visible popover content to avoid backdrop interception.
+ ///
+ private async Task PickTimeInOpenPopover(int hour, int minute)
+ {
+ // The popover content sits in a z-50 absolutely positioned container
+ var popoverContent = Page.Locator(".absolute.z-50");
+
+ // Convert to 12-hour format
+ var isPm = hour >= 12;
+ var hour12 = hour % 12;
+ if (hour12 == 0) hour12 = 12;
+
+ // Click the hour in the first scrollable column (within the popover)
+ var hourText = hour12.ToString("D2");
+ var hourColumn = popoverContent.Locator(".scrollbar-thin").First;
+ await hourColumn.Locator($"button:has-text('{hourText}')").First.ClickAsync();
+ await Page.WaitForTimeoutAsync(50);
+
+ // Click the minute in the second scrollable column (within the popover)
+ var minuteText = minute.ToString("D2");
+ var minuteColumn = popoverContent.Locator(".scrollbar-thin").Nth(1);
+ await minuteColumn.Locator($"button:has-text('{minuteText}')").First.ClickAsync();
+ await Page.WaitForTimeoutAsync(50);
+
+ // Click AM/PM (within the popover)
+ var periodText = isPm ? "PM" : "AM";
+ await popoverContent.Locator($"button:has-text('{periodText}')").First.ClickAsync();
+ await Page.WaitForTimeoutAsync(50);
+ }
+
+ // ════════════════════════════════════════════════════════════════════════
+ // Rendering
+ // ════════════════════════════════════════════════════════════════════════
+
+ [Test]
+ public async Task FormsPage_Loads_Successfully()
+ {
+ var response = await Page.GotoAsync($"{BaseUrl}/forms");
+ Assert.That(response!.Status, Is.EqualTo(200));
+ }
+
+ [Test]
+ public async Task FormsPage_Has_Title()
+ {
+ await GoToFormsAsync();
+ var heading = Page.Locator("[data-sidebar-inset] h1.text-3xl");
+ await Expect(heading).ToHaveTextAsync("Forms Demo");
+ }
+
+ [Test]
+ public async Task All_Inputs_Are_Rendered()
+ {
+ await GoToFormsAsync();
+
+ await Expect(Input("input-name")).ToBeVisibleAsync();
+ await Expect(Input("input-email")).ToBeVisibleAsync();
+ await Expect(Input("input-password")).ToBeVisibleAsync();
+ await Expect(Input("input-age")).ToBeVisibleAsync();
+ // Date/Time/DateTime use popover triggers instead of visible native inputs
+ await Expect(Trigger("trigger-birthdate")).ToBeVisibleAsync();
+ await Expect(Trigger("trigger-preferredtime")).ToBeVisibleAsync();
+ await Expect(Trigger("trigger-appointment-date")).ToBeVisibleAsync();
+ await Expect(Trigger("trigger-appointment-time")).ToBeVisibleAsync();
+ }
+
+ [Test]
+ public async Task All_Buttons_Are_Rendered()
+ {
+ await GoToFormsAsync();
+
+ await Expect(Btn("btn-submit")).ToBeVisibleAsync();
+ await Expect(Btn("btn-reset")).ToBeVisibleAsync();
+ await Expect(Btn("btn-disabled")).ToBeVisibleAsync();
+ }
+
+ // ════════════════════════════════════════════════════════════════════════
+ // Input types
+ // ════════════════════════════════════════════════════════════════════════
+
+ [Test]
+ public async Task TextInput_Has_Correct_Type()
+ {
+ await GoToFormsAsync();
+ await Expect(Input("input-name")).ToHaveAttributeAsync("type", "text");
+ }
+
+ [Test]
+ public async Task EmailInput_Has_Correct_Type()
+ {
+ await GoToFormsAsync();
+ await Expect(Input("input-email")).ToHaveAttributeAsync("type", "email");
+ }
+
+ [Test]
+ public async Task PasswordInput_Has_Correct_Type()
+ {
+ await GoToFormsAsync();
+ await Expect(Input("input-password")).ToHaveAttributeAsync("type", "password");
+ }
+
+ [Test]
+ public async Task NumberInput_Has_Correct_Type()
+ {
+ await GoToFormsAsync();
+ await Expect(Input("input-age")).ToHaveAttributeAsync("type", "number");
+ }
+
+ [Test]
+ public async Task DateInput_Has_Correct_Type()
+ {
+ await GoToFormsAsync();
+ await Expect(Input("input-birthdate")).ToHaveAttributeAsync("type", "date");
+ }
+
+ [Test]
+ public async Task TimeInput_Has_Correct_Type()
+ {
+ await GoToFormsAsync();
+ await Expect(Input("input-time")).ToHaveAttributeAsync("type", "time");
+ }
+
+ [Test]
+ public async Task DateTimeInput_Has_Correct_Type()
+ {
+ await GoToFormsAsync();
+ await Expect(Input("input-appointment")).ToHaveAttributeAsync("type", "datetime-local");
+ }
+
+ // ════════════════════════════════════════════════════════════════════════
+ // Placeholders
+ // ════════════════════════════════════════════════════════════════════════
+
+ [Test]
+ public async Task TextInput_Shows_Placeholder()
+ {
+ await GoToFormsAsync();
+ await Expect(Input("input-name")).ToHaveAttributeAsync("placeholder", "Jane Doe");
+ }
+
+ [Test]
+ public async Task EmailInput_Shows_Placeholder()
+ {
+ await GoToFormsAsync();
+ await Expect(Input("input-email")).ToHaveAttributeAsync("placeholder", "jane@example.com");
+ }
+
+ // ════════════════════════════════════════════════════════════════════════
+ // Labels
+ // ════════════════════════════════════════════════════════════════════════
+
+ [Test]
+ public async Task FormFields_Have_Labels()
+ {
+ await GoToFormsAsync();
+
+ var labels = Page.Locator("label");
+ var count = await labels.CountAsync();
+ Assert.That(count, Is.EqualTo(7), "Expected 7 labels (one per form field)");
+ }
+
+ [Test]
+ public async Task Label_For_Attribute_Matches_Input_Id()
+ {
+ await GoToFormsAsync();
+
+ var label = Page.Locator("label[for='name']");
+ await Expect(label).ToHaveTextAsync("Full Name");
+
+ var input = Input("input-name");
+ await Expect(input).ToHaveAttributeAsync("id", "name");
+ }
+
+ // ════════════════════════════════════════════════════════════════════════
+ // Two-way binding
+ // ════════════════════════════════════════════════════════════════════════
+
+ [Test]
+ public async Task TextInput_Binds_Value()
+ {
+ await GoToFormsAsync();
+
+ var input = Input("input-name");
+ await input.FillAsync("Alice");
+ await Expect(input).ToHaveValueAsync("Alice");
+ }
+
+ [Test]
+ public async Task NumberInput_Binds_Value()
+ {
+ await GoToFormsAsync();
+
+ var input = Input("input-age");
+ await input.FillAsync("30");
+ await Expect(input).ToHaveValueAsync("30");
+ }
+
+ [Test]
+ public async Task DateInput_Binds_Value()
+ {
+ await GoToFormsAsync();
+
+ // Use the calendar popover to select June 15, 2000
+ await SelectDateAsync("trigger-birthdate", new DateOnly(2000, 6, 15));
+
+ // The hidden input should reflect the selected date
+ await Expect(Input("input-birthdate")).ToHaveValueAsync("2000-06-15");
+ }
+
+ [Test]
+ public async Task TimeInput_Binds_Value()
+ {
+ await GoToFormsAsync();
+
+ // Use the time picker popover to select 14:30 (2:30 PM)
+ await SelectTimeAsync("trigger-preferredtime", 14, 30);
+
+ // The hidden input should reflect the selected time
+ await Expect(Input("input-time")).ToHaveValueAsync("14:30");
+ }
+
+ [Test]
+ public async Task DateTimeInput_Binds_Value()
+ {
+ await GoToFormsAsync();
+
+ // Pick the date part via the date trigger
+ await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
+
+ // Pick the time part via the time trigger
+ await SelectTimeAsync("trigger-appointment-time", 10, 0);
+
+ // The hidden input should have the combined datetime value
+ var value = await Input("input-appointment").InputValueAsync();
+ Assert.That(value, Does.StartWith("2025-12-25T10:00"));
+ }
+
+ // ════════════════════════════════════════════════════════════════════════
+ // Validation — empty submit shows errors
+ // ════════════════════════════════════════════════════════════════════════
+
+ [Test]
+ public async Task Empty_Submit_Shows_Validation_Errors()
+ {
+ await GoToFormsAsync();
+
+ await Btn("btn-submit").ClickAsync();
+
+ // Wait for at least one error message to appear
+ await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
+
+ var errors = Page.Locator("p.text-destructive");
+ var count = await errors.CountAsync();
+ Assert.That(count, Is.GreaterThanOrEqualTo(7), "Expected at least 7 validation errors (one per required field)");
+ }
+
+ [Test]
+ public async Task Validation_Error_Shows_Name_Required()
+ {
+ await GoToFormsAsync();
+
+ await Btn("btn-submit").ClickAsync();
+ await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
+
+ var nameError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Name is required" });
+ await Expect(nameError).ToBeVisibleAsync();
+ }
+
+ [Test]
+ public async Task Validation_Error_Shows_Email_Required()
+ {
+ await GoToFormsAsync();
+
+ await Btn("btn-submit").ClickAsync();
+ await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
+
+ var emailError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Email is required" });
+ await Expect(emailError).ToBeVisibleAsync();
+ }
+
+ // ════════════════════════════════════════════════════════════════════════
+ // Validation — specific error messages
+ // ════════════════════════════════════════════════════════════════════════
+
+ [Test]
+ public async Task Short_Name_Shows_Length_Error()
+ {
+ await GoToFormsAsync();
+
+ var input = Input("input-name");
+ await input.FillAsync("A");
+ await Btn("btn-submit").ClickAsync();
+ await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
+
+ var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "2–100 characters" });
+ await Expect(error).ToBeVisibleAsync();
+ }
+
+ [Test]
+ public async Task Invalid_Email_Shows_Error()
+ {
+ await GoToFormsAsync();
+
+ var input = Input("input-email");
+ await input.FillAsync("not-an-email");
+ await Btn("btn-submit").ClickAsync();
+ await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
+
+ var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Invalid email" });
+ await Expect(error).ToBeVisibleAsync();
+ }
+
+ [Test]
+ public async Task Short_Password_Shows_Error()
+ {
+ await GoToFormsAsync();
+
+ var input = Input("input-password");
+ await input.FillAsync("123");
+ await Btn("btn-submit").ClickAsync();
+ await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
+
+ var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "8–64 characters" });
+ await Expect(error).ToBeVisibleAsync();
+ }
+
+ // ════════════════════════════════════════════════════════════════════════
+ // Valid submission
+ // ════════════════════════════════════════════════════════════════════════
+
+ [Test]
+ public async Task Valid_Form_Shows_Success_Message()
+ {
+ await GoToFormsAsync();
+
+ await Input("input-name").FillAsync("Jane Doe");
+ await Input("input-email").FillAsync("jane@example.com");
+ await Input("input-password").FillAsync("securepassword123");
+ await Input("input-age").FillAsync("30");
+
+ // Use popover pickers for date/time fields
+ await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15));
+ await SelectTimeAsync("trigger-preferredtime", 9, 30);
+
+ // DateTime: pick date and time via separate triggers
+ await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
+ await SelectTimeAsync("trigger-appointment-time", 10, 0);
+
+ await Btn("btn-submit").ClickAsync();
+
+ var success = Page.Locator("[data-testid='success-message']");
+ await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 });
+ await Expect(success).ToContainTextAsync("Form submitted successfully");
+ await Expect(success).ToContainTextAsync("Jane Doe");
+ }
+
+ [Test]
+ public async Task No_Success_Message_Before_Submit()
+ {
+ await GoToFormsAsync();
+
+ var success = Page.Locator("[data-testid='success-message']");
+ await Expect(success).ToBeHiddenAsync();
+ }
+
+ // ════════════════════════════════════════════════════════════════════════
+ // Button variants
+ // ════════════════════════════════════════════════════════════════════════
+
+ [Test]
+ public async Task Submit_Button_Has_Default_Variant_Classes()
+ {
+ await GoToFormsAsync();
+
+ var btn = Btn("btn-submit");
+ var cls = await btn.GetAttributeAsync("class");
+ Assert.That(cls, Does.Contain("bg-primary"), "Submit should use default variant");
+ }
+
+ [Test]
+ public async Task Reset_Button_Has_Outline_Variant_Classes()
+ {
+ await GoToFormsAsync();
+
+ var btn = Btn("btn-reset");
+ var cls = await btn.GetAttributeAsync("class");
+ Assert.That(cls, Does.Contain("border"), "Reset should use outline variant");
+ Assert.That(cls, Does.Contain("bg-background"), "Reset should use outline variant");
+ }
+
+ [Test]
+ public async Task Disabled_Button_Is_Actually_Disabled()
+ {
+ await GoToFormsAsync();
+
+ var btn = Btn("btn-disabled");
+ await Expect(btn).ToBeDisabledAsync();
+ }
+
+ [Test]
+ public async Task Disabled_Button_Has_Destructive_Variant()
+ {
+ await GoToFormsAsync();
+
+ var btn = Btn("btn-disabled");
+ var cls = await btn.GetAttributeAsync("class");
+ Assert.That(cls, Does.Contain("bg-destructive"), "Disabled button should have destructive variant");
+ }
+
+ // ════════════════════════════════════════════════════════════════════════
+ // Reset
+ // ════════════════════════════════════════════════════════════════════════
+
+ [Test]
+ public async Task Reset_Button_Clears_Form()
+ {
+ await GoToFormsAsync();
+
+ // Fill some fields
+ await Input("input-name").FillAsync("Alice");
+ await Input("input-email").FillAsync("alice@test.com");
+
+ // Reset
+ await Btn("btn-reset").ClickAsync();
+
+ // Fields should be empty
+ await Expect(Input("input-name")).ToHaveValueAsync("");
+ await Expect(Input("input-email")).ToHaveValueAsync("");
+ }
+
+ [Test]
+ public async Task Reset_Button_Clears_Success_Message()
+ {
+ await GoToFormsAsync();
+
+ // Submit valid form
+ await Input("input-name").FillAsync("Jane Doe");
+ await Input("input-email").FillAsync("jane@example.com");
+ await Input("input-password").FillAsync("securepassword123");
+ await Input("input-age").FillAsync("30");
+
+ await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15));
+ await SelectTimeAsync("trigger-preferredtime", 9, 30);
+
+ // DateTime picker — date and time via separate triggers
+ await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
+ await SelectTimeAsync("trigger-appointment-time", 10, 0);
+
+ await Btn("btn-submit").ClickAsync();
+
+ var success = Page.Locator("[data-testid='success-message']");
+ await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 });
+
+ // Reset
+ await Btn("btn-reset").ClickAsync();
+
+ await Expect(success).ToBeHiddenAsync();
+ }
+
+ // ════════════════════════════════════════════════════════════════════════
+ // Input styling (base CSS classes present)
+ // ════════════════════════════════════════════════════════════════════════
+
+ [Test]
+ public async Task Inputs_Have_Base_Styling_Classes()
+ {
+ await GoToFormsAsync();
+
+ var input = Input("input-name");
+ var cls = await input.GetAttributeAsync("class");
+ Assert.That(cls, Does.Contain("rounded-md"), "Input should have rounded-md class");
+ Assert.That(cls, Does.Contain("border"), "Input should have border class");
+ }
+
+ // ════════════════════════════════════════════════════════════════════════
+ // Navigation to forms page
+ // ════════════════════════════════════════════════════════════════════════
+
+ [Test]
+ public async Task Sidebar_Has_Forms_Link()
+ {
+ await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions { Timeout = 10_000 });
+
+ var formsLink = Page.Locator("a[href='/forms']");
+ await Expect(formsLink).ToBeVisibleAsync();
+ }
+
+ [Test]
+ public async Task Navigate_To_Forms_Via_Sidebar()
+ {
+ await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions { Timeout = 10_000 });
+
+ await Page.Locator("a[href='/forms']").ClickAsync();
+ await Page.WaitForURLAsync($"{BaseUrl}/forms");
+
+ var heading = Page.Locator("[data-sidebar-inset] h1.text-3xl");
+ await Expect(heading).ToHaveTextAsync("Forms Demo");
+ }
+}
diff --git a/Enciphered.Blazor.UIComponents.Tests/GlobalSetup.cs b/Enciphered.Blazor.UIComponents.Tests/GlobalSetup.cs
new file mode 100644
index 0000000..19f9927
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Tests/GlobalSetup.cs
@@ -0,0 +1,24 @@
+namespace Enciphered.Blazor.UIComponents.Tests;
+
+///
+/// Assembly-level setup: boots the demo server once before any test runs.
+///
+[SetUpFixture]
+public class GlobalSetup
+{
+ public static DemoServerFixture Server { get; private set; } = null!;
+
+ [OneTimeSetUp]
+ public async Task OneTimeSetUp()
+ {
+ Server = new DemoServerFixture();
+ await Server.StartAsync();
+ TestContext.Out.WriteLine($"Demo server started at {Server.BaseUrl}");
+ }
+
+ [OneTimeTearDown]
+ public void OneTimeTearDown()
+ {
+ Server?.Dispose();
+ }
+}
diff --git a/Enciphered.Blazor.UIComponents.Tests/PlaywrightTestBase.cs b/Enciphered.Blazor.UIComponents.Tests/PlaywrightTestBase.cs
new file mode 100644
index 0000000..02e21c7
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Tests/PlaywrightTestBase.cs
@@ -0,0 +1,47 @@
+using Microsoft.Playwright;
+
+namespace Enciphered.Blazor.UIComponents.Tests;
+
+///
+/// Base class for Playwright tests. Creates a fresh browser context per test.
+///
+public abstract class PlaywrightTestBase
+{
+ protected IBrowser Browser { get; private set; } = null!;
+ protected IBrowserContext Context { get; private set; } = null!;
+ protected IPage Page { get; private set; } = null!;
+ protected string BaseUrl => GlobalSetup.Server.BaseUrl;
+
+ protected static ILocatorAssertions Expect(ILocator locator) => Assertions.Expect(locator);
+ protected static IPageAssertions Expect(IPage page) => Assertions.Expect(page);
+
+ private static IPlaywright? _playwright;
+ private static IBrowser? _sharedBrowser;
+
+ [OneTimeSetUp]
+ public async Task PlaywrightOneTimeSetUp()
+ {
+ _playwright ??= await Playwright.CreateAsync();
+ _sharedBrowser ??= await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
+ {
+ Headless = true
+ });
+ Browser = _sharedBrowser;
+ }
+
+ [SetUp]
+ public async Task PlaywrightSetUp()
+ {
+ Context = await Browser.NewContextAsync(new BrowserNewContextOptions
+ {
+ IgnoreHTTPSErrors = true
+ });
+ Page = await Context.NewPageAsync();
+ }
+
+ [TearDown]
+ public async Task PlaywrightTearDown()
+ {
+ await Context.CloseAsync();
+ }
+}
diff --git a/Enciphered.Blazor.UIComponents.Tests/SidebarTests.cs b/Enciphered.Blazor.UIComponents.Tests/SidebarTests.cs
new file mode 100644
index 0000000..87d613f
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Tests/SidebarTests.cs
@@ -0,0 +1,590 @@
+using Microsoft.Playwright;
+
+namespace Enciphered.Blazor.UIComponents.Tests;
+
+[TestFixture]
+public class SidebarTests : PlaywrightTestBase
+{
+ ///
+ /// Helper: navigate to home and wait for sidebar JS to initialize.
+ ///
+ private async Task GoHomeAsync()
+ {
+ await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+
+ // Wait for the sidebar JS to apply state (data-state attribute appears on wrapper)
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
+ {
+ Timeout = 10_000
+ });
+ }
+
+ // ────────────────────────────────────────────
+ // Initial render
+ // ────────────────────────────────────────────
+
+ [Test]
+ public async Task Page_Loads_Successfully()
+ {
+ var response = await Page.GotoAsync(BaseUrl);
+ Assert.That(response, Is.Not.Null);
+ Assert.That(response!.Status, Is.EqualTo(200));
+ }
+
+ [Test]
+ public async Task Sidebar_Wrapper_Has_DataState_After_Init()
+ {
+ await GoHomeAsync();
+
+ var wrapper = Page.Locator("[data-sidebar-wrapper]");
+ var state = await wrapper.GetAttributeAsync("data-state");
+ Assert.That(state, Is.Not.Null.And.Not.Empty, "Wrapper should have data-state after JS init");
+ }
+
+ [Test]
+ public async Task Sidebar_Starts_Expanded_On_Desktop()
+ {
+ // Ensure desktop viewport
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ var wrapper = Page.Locator("[data-sidebar-wrapper]");
+ var state = await wrapper.GetAttributeAsync("data-state");
+ Assert.That(state, Is.EqualTo("expanded"), "Sidebar should start expanded on desktop");
+ }
+
+ [Test]
+ public async Task Sidebar_Element_Exists()
+ {
+ await GoHomeAsync();
+
+ var sidebar = Page.Locator("[data-sidebar]");
+ await Expect(sidebar).ToBeVisibleAsync();
+ }
+
+ [Test]
+ public async Task Sidebar_Trigger_Exists_On_Header()
+ {
+ await GoHomeAsync();
+
+ // The sidebar header itself is the trigger on desktop
+ var trigger = Page.Locator("[data-sidebar-header][data-sidebar-trigger]");
+ await Expect(trigger).ToBeVisibleAsync();
+ }
+
+ [Test]
+ public async Task Sidebar_Has_Navigation_Links()
+ {
+ await GoHomeAsync();
+
+ var sidebar = Page.Locator("[data-sidebar]");
+ var links = sidebar.Locator("a[href]");
+ var count = await links.CountAsync();
+ Assert.That(count, Is.GreaterThanOrEqualTo(3), "Should have at least Home, Counter, Weather links");
+ }
+
+ [Test]
+ public async Task Sidebar_Has_Footer()
+ {
+ await GoHomeAsync();
+
+ var footer = Page.Locator("[data-sidebar-footer]");
+ await Expect(footer).ToBeVisibleAsync();
+ }
+
+ // ────────────────────────────────────────────
+ // Desktop toggle (collapse / expand)
+ // ────────────────────────────────────────────
+
+ [Test]
+ public async Task Desktop_Click_Trigger_Collapses_Sidebar()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ // Verify starts expanded
+ var wrapper = Page.Locator("[data-sidebar-wrapper]");
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
+
+ // Click the sidebar header (which is the trigger on desktop)
+ await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
+
+ // Wait for state change
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']", new PageWaitForSelectorOptions
+ {
+ Timeout = 5_000
+ });
+
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
+ }
+
+ [Test]
+ public async Task Desktop_Click_Trigger_Twice_Re_Expands_Sidebar()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ var trigger = Page.Locator("[data-sidebar-header][data-sidebar-trigger]");
+
+ // Collapse
+ await trigger.ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
+
+ // Expand
+ await trigger.ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
+
+ var state = await Page.Locator("[data-sidebar-wrapper]").GetAttributeAsync("data-state");
+ Assert.That(state, Is.EqualTo("expanded"));
+ }
+
+ [Test]
+ public async Task Desktop_Collapsed_Sidebar_Has_Reduced_Width()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ var sidebar = Page.Locator("[data-sidebar]");
+
+ // Get expanded width
+ var expandedWidth = await sidebar.EvaluateAsync("el => el.getBoundingClientRect().width");
+ Assert.That(expandedWidth, Is.GreaterThan(100), "Expanded sidebar should be wider than 100px");
+
+ // Collapse
+ await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
+
+ // Wait for CSS transition
+ await Page.WaitForTimeoutAsync(300);
+
+ // Get collapsed width
+ var collapsedWidth = await sidebar.EvaluateAsync("el => el.getBoundingClientRect().width");
+ Assert.That(collapsedWidth, Is.LessThan(expandedWidth), "Collapsed sidebar should be narrower");
+ Assert.That(collapsedWidth, Is.LessThanOrEqualTo(60), "Collapsed sidebar should be icon-only width (~3rem = 48px)");
+ }
+
+ [Test]
+ public async Task Desktop_Collapsed_Hides_Menu_Labels()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ // Target only the menu item label spans (inside sidebar-content, not sidebar-header)
+ var labelSpans = Page.Locator("[data-sidebar-content] a span.truncate");
+ var countBefore = await labelSpans.CountAsync();
+ Assert.That(countBefore, Is.GreaterThan(0), "Should have label spans");
+
+ // Collapse
+ await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
+ await Page.WaitForTimeoutAsync(300);
+
+ // Check that label text is hidden (CSS-driven via group-data-[state=collapsed])
+ for (int i = 0; i < countBefore; i++)
+ {
+ var display = await labelSpans.Nth(i).EvaluateAsync("el => getComputedStyle(el).display");
+ Assert.That(display, Is.EqualTo("none"), $"Label span {i} should have display:none when sidebar is collapsed (got '{display}')");
+ }
+ }
+
+ [Test]
+ public async Task Desktop_Collapsed_Hides_Group_Label()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ var groupLabel = Page.Locator("[data-sidebar-group-label]");
+ await Expect(groupLabel).ToBeVisibleAsync();
+
+ // Collapse
+ await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
+ await Page.WaitForTimeoutAsync(300);
+
+ await Expect(groupLabel).Not.ToBeVisibleAsync();
+ }
+
+ // ────────────────────────────────────────────
+ // Spacer width tracks sidebar state
+ // ────────────────────────────────────────────
+
+ [Test]
+ public async Task Desktop_Spacer_Width_Changes_On_Collapse()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ var spacer = Page.Locator("[data-sidebar-spacer]");
+ var expandedWidth = await spacer.EvaluateAsync("el => el.getBoundingClientRect().width");
+ Assert.That(expandedWidth, Is.GreaterThan(100), "Spacer should be wide when expanded");
+
+ // Collapse
+ await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
+ await Page.WaitForTimeoutAsync(300);
+
+ var collapsedWidth = await spacer.EvaluateAsync("el => el.getBoundingClientRect().width");
+ Assert.That(collapsedWidth, Is.LessThan(expandedWidth), "Spacer should shrink when collapsed");
+ }
+
+ // ────────────────────────────────────────────
+ // Mobile behavior
+ // ────────────────────────────────────────────
+
+ [Test]
+ public async Task Mobile_Sidebar_Starts_Closed()
+ {
+ await Page.SetViewportSizeAsync(375, 812);
+ await GoHomeAsync();
+
+ var wrapper = Page.Locator("[data-sidebar-wrapper]");
+ var state = await wrapper.GetAttributeAsync("data-state");
+ Assert.That(state, Is.EqualTo("collapsed"), "Sidebar should start collapsed on mobile");
+
+ var mobile = await wrapper.GetAttributeAsync("data-mobile");
+ Assert.That(mobile, Is.EqualTo("true"), "data-mobile should be 'true' on small viewport");
+ }
+
+ [Test]
+ public async Task Mobile_Click_Trigger_Opens_Sidebar_And_Shows_Overlay()
+ {
+ await Page.SetViewportSizeAsync(375, 812);
+ await GoHomeAsync();
+
+ // Click the mobile trigger button in the inset (visible only on mobile)
+ await Page.Locator("[data-sidebar-inset] [data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
+
+ // Overlay should be visible
+ var overlay = Page.Locator("[data-sidebar-overlay]");
+ var display = await overlay.EvaluateAsync("el => getComputedStyle(el).display");
+ Assert.That(display, Is.Not.EqualTo("none"), "Overlay should be visible when mobile sidebar is open");
+ }
+
+ [Test]
+ public async Task Mobile_Click_Overlay_Closes_Sidebar()
+ {
+ await Page.SetViewportSizeAsync(375, 812);
+ await GoHomeAsync();
+
+ // Open via the inset trigger
+ await Page.Locator("[data-sidebar-inset] [data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
+
+ // Click overlay
+ await Page.Locator("[data-sidebar-overlay]").ClickAsync(new LocatorClickOptions { Force = true });
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
+
+ var state = await Page.Locator("[data-sidebar-wrapper]").GetAttributeAsync("data-state");
+ Assert.That(state, Is.EqualTo("collapsed"), "Sidebar should close when overlay is clicked");
+ }
+
+ [Test]
+ public async Task Mobile_Overlay_Hidden_When_Sidebar_Closed()
+ {
+ await Page.SetViewportSizeAsync(375, 812);
+ await GoHomeAsync();
+
+ var overlay = Page.Locator("[data-sidebar-overlay]");
+ var display = await overlay.EvaluateAsync("el => el.style.display || getComputedStyle(el).display");
+ Assert.That(display, Is.EqualTo("none"), "Overlay should be hidden when mobile sidebar is closed");
+ }
+
+ // ────────────────────────────────────────────
+ // Navigation links work
+ // ────────────────────────────────────────────
+
+ [Test]
+ public async Task Clicking_Counter_Link_Navigates_To_Counter()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ var counterLink = Page.Locator("[data-sidebar] a[href='/counter']");
+ await counterLink.ClickAsync();
+
+ await Page.WaitForURLAsync($"{BaseUrl}/counter");
+ Assert.That(Page.Url, Does.Contain("/counter"));
+ }
+
+ [Test]
+ public async Task Clicking_Weather_Link_Navigates_To_Weather()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ var weatherLink = Page.Locator("[data-sidebar] a[href='/weather']");
+ await weatherLink.ClickAsync();
+
+ await Page.WaitForURLAsync($"{BaseUrl}/weather");
+ Assert.That(Page.Url, Does.Contain("/weather"));
+ }
+
+ [Test]
+ public async Task Clicking_Header_Toggles_Sidebar()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ // Starts expanded
+ var wrapper = Page.Locator("[data-sidebar-wrapper]");
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
+
+ // Click the sidebar header (logo area) to collapse
+ await Page.Locator("[data-sidebar-header]").ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
+
+ // Click again to expand
+ await Page.Locator("[data-sidebar-header]").ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
+ }
+
+ // ────────────────────────────────────────────
+ // Navigation should NOT change sidebar state
+ // ────────────────────────────────────────────
+
+ [Test]
+ public async Task Desktop_Collapsed_Sidebar_Stays_Collapsed_After_Navigation()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ // Verify starts expanded
+ var wrapper = Page.Locator("[data-sidebar-wrapper]");
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
+
+ // Collapse the sidebar
+ await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
+
+ // Click a navigation link (Counter)
+ var counterLink = Page.Locator("[data-sidebar] a[href='/counter']");
+ await counterLink.ClickAsync();
+
+ // Wait for navigation to complete
+ await Page.WaitForURLAsync($"{BaseUrl}/counter");
+
+ // Wait for the sidebar JS to re-apply state after enhanced navigation
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
+ {
+ Timeout = 5_000
+ });
+
+ // Give any transitions/scripts time to settle
+ await Page.WaitForTimeoutAsync(500);
+
+ // Sidebar should STILL be collapsed
+ var stateAfterNav = await wrapper.GetAttributeAsync("data-state");
+ Assert.That(stateAfterNav, Is.EqualTo("collapsed"),
+ "Sidebar should remain collapsed after clicking a navigation link");
+ }
+
+ [Test]
+ public async Task Desktop_Collapsed_Sidebar_Stays_Collapsed_After_Multiple_Navigations()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ // Collapse the sidebar
+ await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
+
+ var wrapper = Page.Locator("[data-sidebar-wrapper]");
+
+ // Navigate to Counter
+ await Page.Locator("[data-sidebar] a[href='/counter']").ClickAsync();
+ await Page.WaitForURLAsync($"{BaseUrl}/counter");
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
+ await Page.WaitForTimeoutAsync(500);
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
+ "Sidebar should remain collapsed after navigating to Counter");
+
+ // Navigate to Weather
+ await Page.Locator("[data-sidebar] a[href='/weather']").ClickAsync();
+ await Page.WaitForURLAsync($"{BaseUrl}/weather");
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
+ await Page.WaitForTimeoutAsync(500);
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
+ "Sidebar should remain collapsed after navigating to Weather");
+
+ // Navigate back Home
+ await Page.Locator("[data-sidebar] a[href='/']").ClickAsync();
+ await Page.WaitForURLAsync(url => url == BaseUrl || url == BaseUrl + "/");
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
+ await Page.WaitForTimeoutAsync(500);
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
+ "Sidebar should remain collapsed after navigating back to Home");
+ }
+
+ [Test]
+ public async Task Desktop_Sidebar_Toggle_Works_After_Same_Page_Navigation()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ var wrapper = Page.Locator("[data-sidebar-wrapper]");
+
+ // Starts expanded
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
+
+ // Click the Home link while already on home (same-page navigation)
+ await Page.Locator("[data-sidebar] a[href='/']").ClickAsync();
+
+ // Wait for any enhanced navigation / DOM mutation to settle
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
+ {
+ Timeout = 5_000
+ });
+ await Page.WaitForTimeoutAsync(500);
+
+ // Now click the sidebar trigger to collapse
+ await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForTimeoutAsync(300);
+
+ var stateAfterFirstToggle = await wrapper.GetAttributeAsync("data-state");
+ Assert.That(stateAfterFirstToggle, Is.EqualTo("collapsed"),
+ "Sidebar should be collapsed after one trigger click following same-page nav");
+
+ // Click the sidebar trigger again to expand
+ await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForTimeoutAsync(300);
+
+ var stateAfterSecondToggle = await wrapper.GetAttributeAsync("data-state");
+ Assert.That(stateAfterSecondToggle, Is.EqualTo("expanded"),
+ "Sidebar should be expanded after second trigger click following same-page nav");
+ }
+
+ [Test]
+ public async Task Desktop_Collapsed_Sidebar_Stays_Collapsed_After_Same_Page_Navigation()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ var wrapper = Page.Locator("[data-sidebar-wrapper]");
+
+ // Collapse the sidebar first
+ await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
+
+ // Click the Home link while already on home (same-page navigation)
+ await Page.Locator("[data-sidebar] a[href='/']").ClickAsync();
+
+ // Wait for any enhanced navigation / DOM mutation to settle
+ await Page.WaitForTimeoutAsync(500);
+
+ // Sidebar should STILL be collapsed
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
+ "Sidebar should remain collapsed after same-page navigation");
+
+ // Toggle should still work correctly: collapse -> expand
+ await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForTimeoutAsync(300);
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"),
+ "Trigger should expand sidebar after same-page nav while collapsed");
+
+ // And back to collapsed
+ await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForTimeoutAsync(300);
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
+ "Trigger should collapse sidebar again after same-page nav");
+ }
+
+ // ────────────────────────────────────────────
+ // Cookie persistence
+ // ────────────────────────────────────────────
+
+ [Test]
+ public async Task Desktop_State_Persists_Via_Cookie()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ // Collapse
+ await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
+
+ // Check cookie
+ var cookies = await Context.CookiesAsync();
+ var sidebarCookie = cookies.FirstOrDefault(c => c.Name == "sidebar:state");
+ Assert.That(sidebarCookie, Is.Not.Null, "sidebar:state cookie should exist");
+ Assert.That(sidebarCookie!.Value, Is.EqualTo("closed"), "Cookie should be 'closed' after collapse");
+
+ // Reload page — sidebar should remain collapsed
+ await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
+
+ var state = await Page.Locator("[data-sidebar-wrapper]").GetAttributeAsync("data-state");
+ Assert.That(state, Is.EqualTo("collapsed"), "Sidebar state should persist after reload");
+ }
+
+ // ────────────────────────────────────────────
+ // Viewport resize transitions
+ // ────────────────────────────────────────────
+
+ [Test]
+ public async Task Resize_From_Desktop_To_Mobile_Collapses_Sidebar()
+ {
+ await Page.SetViewportSizeAsync(1280, 800);
+ await GoHomeAsync();
+
+ var wrapper = Page.Locator("[data-sidebar-wrapper]");
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
+
+ // Shrink to mobile
+ await Page.SetViewportSizeAsync(375, 812);
+
+ // Wait for resize handler to fire
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-mobile='true']", new PageWaitForSelectorOptions
+ {
+ Timeout = 5_000
+ });
+
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
+ Assert.That(await wrapper.GetAttributeAsync("data-mobile"), Is.EqualTo("true"));
+ }
+
+ [Test]
+ public async Task Resize_From_Mobile_To_Desktop_Expands_Sidebar()
+ {
+ await Page.SetViewportSizeAsync(375, 812);
+ await GoHomeAsync();
+
+ var wrapper = Page.Locator("[data-sidebar-wrapper]");
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
+
+ // Grow to desktop
+ await Page.SetViewportSizeAsync(1280, 800);
+
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-mobile='false']", new PageWaitForSelectorOptions
+ {
+ Timeout = 5_000
+ });
+
+ Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
+ Assert.That(await wrapper.GetAttributeAsync("data-mobile"), Is.EqualTo("false"));
+ }
+
+ // ────────────────────────────────────────────
+ // Sidebar inset (main content area)
+ // ────────────────────────────────────────────
+
+ [Test]
+ public async Task SidebarInset_Exists_And_Contains_Page_Content()
+ {
+ await GoHomeAsync();
+
+ var inset = Page.Locator("[data-sidebar-inset]");
+ await Expect(inset).ToBeVisibleAsync();
+
+ // On mobile, the inset should contain the trigger button
+ await Page.SetViewportSizeAsync(375, 812);
+ await GoHomeAsync();
+
+ var mobileTrigger = inset.Locator("[data-sidebar-trigger]");
+ await Expect(mobileTrigger).ToBeVisibleAsync();
+ }
+}
diff --git a/Enciphered.Blazor.UIComponents.Tests/ThemeToggleTests.cs b/Enciphered.Blazor.UIComponents.Tests/ThemeToggleTests.cs
new file mode 100644
index 0000000..3bde662
--- /dev/null
+++ b/Enciphered.Blazor.UIComponents.Tests/ThemeToggleTests.cs
@@ -0,0 +1,324 @@
+using Microsoft.Playwright;
+
+namespace Enciphered.Blazor.UIComponents.Tests;
+
+[TestFixture]
+public class ThemeToggleTests : PlaywrightTestBase
+{
+ ///
+ /// Navigate to home, wait for sidebar JS + darkmode JS to finish initializing.
+ ///
+ private async Task GoHomeAsync()
+ {
+ await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+
+ // Wait for sidebar JS to apply state
+ await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
+ {
+ Timeout = 10_000
+ });
+
+ // Wait for the ThemeToggle button to appear
+ await Page.WaitForSelectorAsync("[data-theme-toggle]", new PageWaitForSelectorOptions
+ {
+ Timeout = 10_000
+ });
+ }
+
+ ///
+ /// Click the toggle and wait for the dark class to be added to <html>.
+ ///
+ private async Task ToggleToDarkAsync()
+ {
+ await Page.Locator("[data-theme-toggle]").ClickAsync();
+ await Page.WaitForFunctionAsync(
+ "() => document.documentElement.classList.contains('dark')",
+ null,
+ new PageWaitForFunctionOptions { Timeout = 5_000 });
+ }
+
+ ///
+ /// Click the toggle and wait for the dark class to be removed from <html>.
+ ///
+ private async Task ToggleToLightAsync()
+ {
+ await Page.Locator("[data-theme-toggle]").ClickAsync();
+ await Page.WaitForFunctionAsync(
+ "() => !document.documentElement.classList.contains('dark')",
+ null,
+ new PageWaitForFunctionOptions { Timeout = 5_000 });
+ }
+
+ // ────────────────────────────────────────────
+ // Initial render
+ // ────────────────────────────────────────────
+
+ [Test]
+ public async Task ThemeToggle_Button_Is_Visible()
+ {
+ await GoHomeAsync();
+
+ var toggle = Page.Locator("[data-theme-toggle]");
+ await Expect(toggle).ToBeVisibleAsync();
+ }
+
+ [Test]
+ public async Task ThemeToggle_Starts_In_Light_Mode_By_Default()
+ {
+ await GoHomeAsync();
+
+ var hasDark = await Page.EvaluateAsync(
+ "() => document.documentElement.classList.contains('dark')");
+ Assert.That(hasDark, Is.False, "Page should start in light mode when no preference is stored");
+ }
+
+ [Test]
+ public async Task ThemeToggle_Shows_Moon_Icon_In_Light_Mode()
+ {
+ await GoHomeAsync();
+
+ var moon = Page.Locator("[data-theme-toggle] [data-theme-icon-moon]");
+ var moonDisplay = await moon.EvaluateAsync("el => getComputedStyle(el).display");
+ Assert.That(moonDisplay, Is.Not.EqualTo("none"), "Moon icon should be visible in light mode");
+
+ var sun = Page.Locator("[data-theme-toggle] [data-theme-icon-sun]");
+ var sunDisplay = await sun.EvaluateAsync("el => el.style.display");
+ Assert.That(sunDisplay, Is.EqualTo("none"), "Sun icon should be hidden in light mode");
+ }
+
+ // ────────────────────────────────────────────
+ // Toggle to dark mode
+ // ────────────────────────────────────────────
+
+ [Test]
+ public async Task Click_Toggle_Adds_Dark_Class_To_Html()
+ {
+ await GoHomeAsync();
+ await ToggleToDarkAsync();
+
+ var hasDark = await Page.EvaluateAsync(
+ "() => document.documentElement.classList.contains('dark')");
+ Assert.That(hasDark, Is.True, "Clicking toggle should add 'dark' class to ");
+ }
+
+ [Test]
+ public async Task Click_Toggle_Shows_Sun_Icon_In_Dark_Mode()
+ {
+ await GoHomeAsync();
+ await ToggleToDarkAsync();
+
+ var sun = Page.Locator("[data-theme-toggle] [data-theme-icon-sun]");
+ var sunDisplay = await sun.EvaluateAsync("el => el.style.display");
+ Assert.That(sunDisplay, Is.Not.EqualTo("none"), "Sun icon should be visible in dark mode");
+
+ var moon = Page.Locator("[data-theme-toggle] [data-theme-icon-moon]");
+ var moonDisplay = await moon.EvaluateAsync("el => el.style.display");
+ Assert.That(moonDisplay, Is.EqualTo("none"), "Moon icon should be hidden in dark mode");
+ }
+
+ [Test]
+ public async Task Click_Toggle_Stores_Dark_In_LocalStorage()
+ {
+ await GoHomeAsync();
+ await ToggleToDarkAsync();
+
+ var stored = await Page.EvaluateAsync("() => localStorage.getItem('theme')");
+ Assert.That(stored, Is.EqualTo("dark"), "localStorage 'theme' should be 'dark' after toggle");
+ }
+
+ // ────────────────────────────────────────────
+ // Toggle back to light mode
+ // ────────────────────────────────────────────
+
+ [Test]
+ public async Task Double_Click_Toggle_Returns_To_Light_Mode()
+ {
+ await GoHomeAsync();
+
+ // Toggle to dark
+ await ToggleToDarkAsync();
+
+ // Toggle back to light
+ await ToggleToLightAsync();
+
+ var hasDark = await Page.EvaluateAsync(
+ "() => document.documentElement.classList.contains('dark')");
+ Assert.That(hasDark, Is.False, "Double-clicking toggle should return to light mode");
+
+ var stored = await Page.EvaluateAsync("() => localStorage.getItem('theme')");
+ Assert.That(stored, Is.EqualTo("light"), "localStorage should be 'light' after toggling back");
+ }
+
+ [Test]
+ public async Task Double_Click_Toggle_Shows_Moon_Icon_Again()
+ {
+ await GoHomeAsync();
+
+ // Dark
+ await ToggleToDarkAsync();
+
+ // Light again
+ await ToggleToLightAsync();
+
+ var moon = Page.Locator("[data-theme-toggle] [data-theme-icon-moon]");
+ var moonDisplay = await moon.EvaluateAsync("el => getComputedStyle(el).display");
+ Assert.That(moonDisplay, Is.Not.EqualTo("none"), "Moon icon should be visible again after toggling back");
+
+ var sun = Page.Locator("[data-theme-toggle] [data-theme-icon-sun]");
+ var sunDisplay = await sun.EvaluateAsync("el => el.style.display");
+ Assert.That(sunDisplay, Is.EqualTo("none"), "Sun icon should be hidden again after toggling back");
+ }
+
+ // ────────────────────────────────────────────
+ // Persistence across page reloads
+ // ────────────────────────────────────────────
+
+ [Test]
+ public async Task Dark_Mode_Persists_After_Reload()
+ {
+ await GoHomeAsync();
+ await ToggleToDarkAsync();
+
+ // Reload the page
+ await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
+
+ // The inline