From ae3244e84a70a9dfabbbd709fe4b98f88bdfe96c Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 17 Jun 2026 17:47:19 +0200 Subject: [PATCH 1/5] chore(ci): refine smoke-test comments and clarify Blazor-ApexCharts dependency Updated comments in the CI workflow to enhance clarity regarding the purpose of the smoke tests for CLI scaffolding. Additionally, adjusted the handling of the Blazor-ApexCharts package to ensure it is explicitly added for smoke tests, isolating specific bug classes related to template escapes. --- .github/workflows/ci.yml | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa73aed..3350350 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,10 +36,8 @@ jobs: - name: Run tests run: dotnet test ShellUI.sln --no-restore --no-build --configuration Release --verbosity normal - # Guards against the template-escape bug class from shellui-fixes-for-lib.md - # (Fixes 2, 9, 10). TemplateCompileTests verifies generated content parses; - # this end-to-end build catches anything the syntactic check misses - # (e.g. missing using directives). + # End-to-end build of a scaffolded project — catches anything the in-process + # TemplateCompileTests miss (missing usings, dependency resolution). - name: Smoke-test CLI scaffolding shell: bash run: | @@ -53,13 +51,10 @@ jobs: dotnet new blazor -o SmokeApp --no-restore cd SmokeApp shellui init --tailwind standalone --yes - # Hit the three components that regressed last time: shellui add chart pie-chart dashboard-02 --force || true - # Chart components reference ApexCharts.* types — `shellui add chart` - # does NOT auto-install Blazor-ApexCharts today (tracked as Fix 12.3: - # `nugetDependencies` field on component manifests). Add it explicitly - # here so the smoke test isolates THIS PR's bug class (template escapes) - # from that one. When Fix 12.3 lands, remove this line. + # Chart components reference ApexCharts.* types but the CLI does not yet + # auto-add the runtime NuGet dep. Drop this line once `shellui add chart` + # declares Blazor-ApexCharts as a nuget dependency. dotnet add package Blazor-ApexCharts --version 6.0.2 dotnet build -c Debug From 7a20844d1152b8ec09366d3ccab682406ae04a1e Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 17 Jun 2026 17:48:25 +0200 Subject: [PATCH 2/5] feat(tests): add TemplateSyncTests to validate consistency between live Razor components and CLI templates Introduced a new test class, TemplateSyncTests, to ensure that the @code blocks in live Razor components match the corresponding CLI templates. This includes normalization of content to ignore comments and whitespace differences, helping to catch discrepancies that could lead to runtime errors. Updated existing comments in TemplateCompileTests for clarity on parsing behavior. --- ShellUI.Tests/TemplateCompileTests.cs | 7 +- ShellUI.Tests/TemplateSyncTests.cs | 156 ++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 ShellUI.Tests/TemplateSyncTests.cs diff --git a/ShellUI.Tests/TemplateCompileTests.cs b/ShellUI.Tests/TemplateCompileTests.cs index ecb3898..80338f3 100644 --- a/ShellUI.Tests/TemplateCompileTests.cs +++ b/ShellUI.Tests/TemplateCompileTests.cs @@ -8,10 +8,9 @@ namespace ShellUI.Tests; /// Verifies that the *generated* content of each template parses as valid C#. -/// For pure-.cs templates (variants), parse the whole content. -/// For .razor templates, extract the @code { ... } block and parse its body. -/// This catches the exact class of bug from shellui-fixes-for-lib.md (Fixes 2, 9, 10): -/// unescaped quotes inside C# verbatim strings that ship as compile errors to consumers. +/// Pure-.cs templates (variants) parse the whole content; .razor templates parse +/// the body of the @code block. Catches unescaped quotes inside C# verbatim +/// strings — the kind of error that ships as a compile failure to consumers. public class TemplateCompileTests { [Theory] diff --git a/ShellUI.Tests/TemplateSyncTests.cs b/ShellUI.Tests/TemplateSyncTests.cs new file mode 100644 index 0000000..3bb9978 --- /dev/null +++ b/ShellUI.Tests/TemplateSyncTests.cs @@ -0,0 +1,156 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using ShellUI.Templates; +using Xunit; + +namespace ShellUI.Tests; + +/// Asserts the @code block of each live src/ShellUI.Components/Components/*.razor +/// matches the corresponding CLI template's emitted Content. Compares after stripping +/// comments, blank lines, and whitespace differences so the legitimate divergence +/// (namespace, formatting) doesn't fire, but real divergence (parameter list, JS +/// interop calls, lifecycle methods) does. +public class TemplateSyncTests +{ + // Component name → reason. Empty by default — fix the drift instead of adding entries. + private static readonly Dictionary AllowedDrift = new() + { + }; + + [Theory] + [InlineData("sidebar-trigger", "SidebarTrigger.razor")] + [InlineData("theme-toggle", "ThemeToggle.razor")] + [InlineData("input-otp", "InputOTP.razor")] + public void TemplateCodeBlock_MatchesLiveLibrary(string templateName, string razorFileName) + { + if (AllowedDrift.ContainsKey(templateName)) return; + + var liveContent = File.ReadAllText(GetLiveRazorPath(razorFileName)); + var templateContent = ComponentRegistry.GetComponentContent(templateName) + ?? throw new InvalidOperationException($"Template '{templateName}' not found in registry"); + + var liveCode = ExtractCodeBlock(liveContent) + ?? throw new InvalidOperationException($"Live {razorFileName} has no @code block"); + var templateCode = ExtractCodeBlock(templateContent) + ?? throw new InvalidOperationException($"Template {templateName} has no @code block"); + + var normalizedLive = Normalize(liveCode); + var normalizedTemplate = Normalize(templateCode); + + Assert.True(normalizedLive == normalizedTemplate, + $"Drift detected between live {razorFileName} and template {templateName}.\n" + + $"This usually means someone updated one but not the other. Sync them, or add " + + $"\"{templateName}\" to AllowedDrift in TemplateSyncTests with a reason.\n\n" + + DiffSummary(normalizedLive, normalizedTemplate)); + } + + // [CallerFilePath] captures the absolute path of this source file at compile time, + // so the test resolves the live components directory regardless of cwd on CI. + private static string GetLiveRazorPath(string razorFileName, [CallerFilePath] string thisFile = "") + { + var testDir = Path.GetDirectoryName(thisFile) ?? throw new InvalidOperationException("CallerFilePath is empty"); + var repoRoot = Path.GetFullPath(Path.Combine(testDir, "..")); + return Path.Combine(repoRoot, "src", "ShellUI.Components", "Components", razorFileName); + } + + private static string Normalize(string code) + { + var withoutBlock = Regex.Replace(code, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline); + var lines = withoutBlock.Split('\n') + .Select(l => Regex.Replace(l, @"//.*$", string.Empty)) + .Select(l => Regex.Replace(l.Trim(), @"\s+", " ")) + .Where(l => !string.IsNullOrWhiteSpace(l)); + return string.Join("\n", lines); + } + + private static string DiffSummary(string live, string template) + { + var liveLines = live.Split('\n'); + var tmplLines = template.Split('\n'); + var max = Math.Max(liveLines.Length, tmplLines.Length); + var diffs = new System.Text.StringBuilder(); + var shown = 0; + for (var i = 0; i < max && shown < 5; i++) + { + var l = i < liveLines.Length ? liveLines[i] : ""; + var t = i < tmplLines.Length ? tmplLines[i] : ""; + if (l != t) + { + diffs.AppendLine($" line {i + 1}"); + diffs.AppendLine($" live: {l}"); + diffs.AppendLine($" template: {t}"); + shown++; + } + } + return diffs.Length == 0 ? "(no per-line diff — file lengths differ)" : diffs.ToString(); + } + + /// Extracts the body of the first `@code { ... }` block, balancing braces while + /// respecting strings, verbatim strings, char literals, line comments, and block comments. + /// Returns null if no `@code` block is found or braces are unbalanced. + private static string? ExtractCodeBlock(string razor) + { + var match = Regex.Match(razor, @"@code\s*\{"); + if (!match.Success) return null; + + var start = match.Index + match.Length; + var depth = 1; + var inString = false; + var inVerbatimString = false; + var inCharLiteral = false; + var inLineComment = false; + var inBlockComment = false; + + for (var i = start; i < razor.Length; i++) + { + var c = razor[i]; + var next = i + 1 < razor.Length ? razor[i + 1] : '\0'; + + if (inLineComment) + { + if (c == '\n') inLineComment = false; + continue; + } + if (inBlockComment) + { + if (c == '*' && next == '/') { inBlockComment = false; i++; } + continue; + } + if (inVerbatimString) + { + if (c == '"' && next == '"') { i++; continue; } + if (c == '"') inVerbatimString = false; + continue; + } + if (inString) + { + if (c == '\\' && next != '\0') { i++; continue; } + if (c == '"') inString = false; + continue; + } + if (inCharLiteral) + { + if (c == '\\' && next != '\0') { i++; continue; } + if (c == '\'') inCharLiteral = false; + continue; + } + + if (c == '/' && next == '/') { inLineComment = true; i++; continue; } + if (c == '/' && next == '*') { inBlockComment = true; i++; continue; } + if (c == '@' && next == '"') { inVerbatimString = true; i++; continue; } + if (c == '"') { inString = true; continue; } + if (c == '\'') { inCharLiteral = true; continue; } + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) return razor.Substring(start, i - start); + } + } + return null; + } +} From 8750ef497cb29cb253f4b5c288f6810181424c02 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 17 Jun 2026 17:49:48 +0200 Subject: [PATCH 3/5] refactor(theme-toggle): optimize theme management and improve initialization flow Updated the ThemeToggle component to streamline theme initialization by replacing the async OnInitializedAsync method with a synchronous OnInitialized method. Introduced OnAfterRenderAsync to handle localStorage access after the first render, ensuring proper theme retrieval and state updates. Simplified theme class management by utilizing ShellUI methods for adding/removing classes, enhancing performance and readability. Updated related ThemeService to maintain consistency in theme handling across components. --- .../Components/UI/ThemeToggle.razor | 30 +++++----- .../Components/ThemeToggle.razor | 30 +++++----- .../Services/ThemeService.cs | 14 ++--- .../Templates/ThemeToggleTemplate.cs | 59 ++++++++----------- 4 files changed, 60 insertions(+), 73 deletions(-) diff --git a/NET9/BlazorInteractiveServer/Components/UI/ThemeToggle.razor b/NET9/BlazorInteractiveServer/Components/UI/ThemeToggle.razor index f67dc66..05206ae 100644 --- a/NET9/BlazorInteractiveServer/Components/UI/ThemeToggle.razor +++ b/NET9/BlazorInteractiveServer/Components/UI/ThemeToggle.razor @@ -34,14 +34,21 @@ [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } - protected override async Task OnInitializedAsync() + protected override void OnInitialized() { _instances.Add(this); - + } + + // localStorage and document mutation must run after first render — JSRuntime + // is unavailable during prerender on Blazor Server. + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; try { var theme = await JSRuntime.InvokeAsync("localStorage.getItem", "theme"); _isDark = string.IsNullOrEmpty(theme) ? true : theme == "dark"; + StateHasChanged(); } catch { @@ -53,21 +60,14 @@ { _isDark = !_isDark; var theme = _isDark ? "dark" : "light"; - + try { await JSRuntime.InvokeVoidAsync("localStorage.setItem", "theme", theme); - - if (_isDark) - { - await JSRuntime.InvokeVoidAsync("eval", "document.documentElement.classList.add('dark')"); - } - else - { - await JSRuntime.InvokeVoidAsync("eval", "document.documentElement.classList.remove('dark')"); - } - - // Update all ThemeToggle instances + await JSRuntime.InvokeVoidAsync( + _isDark ? "ShellUI.addClassToDocument" : "ShellUI.removeClassFromDocument", + "dark"); + foreach (var instance in _instances) { if (instance != this) @@ -76,7 +76,7 @@ instance.StateHasChanged(); } } - + StateHasChanged(); } catch diff --git a/src/ShellUI.Components/Components/ThemeToggle.razor b/src/ShellUI.Components/Components/ThemeToggle.razor index fcca493..f285373 100644 --- a/src/ShellUI.Components/Components/ThemeToggle.razor +++ b/src/ShellUI.Components/Components/ThemeToggle.razor @@ -33,14 +33,21 @@ [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } - protected override async Task OnInitializedAsync() + protected override void OnInitialized() { _instances.Add(this); - + } + + // localStorage and document mutation must run after first render — JSRuntime + // is unavailable during prerender on Blazor Server. + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; try { var theme = await JSRuntime.InvokeAsync("localStorage.getItem", "theme"); _isDark = string.IsNullOrEmpty(theme) ? true : theme == "dark"; + StateHasChanged(); } catch { @@ -52,21 +59,14 @@ { _isDark = !_isDark; var theme = _isDark ? "dark" : "light"; - + try { await JSRuntime.InvokeVoidAsync("localStorage.setItem", "theme", theme); - - if (_isDark) - { - await JSRuntime.InvokeVoidAsync("eval", "document.documentElement.classList.add('dark')"); - } - else - { - await JSRuntime.InvokeVoidAsync("eval", "document.documentElement.classList.remove('dark')"); - } - - // Update all ThemeToggle instances + await JSRuntime.InvokeVoidAsync( + _isDark ? "ShellUI.addClassToDocument" : "ShellUI.removeClassFromDocument", + "dark"); + foreach (var instance in _instances) { if (instance != this) @@ -75,7 +75,7 @@ instance.StateHasChanged(); } } - + StateHasChanged(); } catch diff --git a/src/ShellUI.Components/Services/ThemeService.cs b/src/ShellUI.Components/Services/ThemeService.cs index 9eb610c..b9eebab 100644 --- a/src/ShellUI.Components/Services/ThemeService.cs +++ b/src/ShellUI.Components/Services/ThemeService.cs @@ -37,19 +37,13 @@ public async Task GetThemeAsync() public async Task SetThemeAsync(string theme) { _currentTheme = theme; - + try { await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "theme", theme); - - if (theme == "dark") - { - await _jsRuntime.InvokeVoidAsync("eval", "document.documentElement.classList.add('dark')"); - } - else - { - await _jsRuntime.InvokeVoidAsync("eval", "document.documentElement.classList.remove('dark')"); - } + await _jsRuntime.InvokeVoidAsync( + theme == "dark" ? "ShellUI.addClassToDocument" : "ShellUI.removeClassFromDocument", + "dark"); } catch { diff --git a/src/ShellUI.Templates/Templates/ThemeToggleTemplate.cs b/src/ShellUI.Templates/Templates/ThemeToggleTemplate.cs index 1b5cdd7..73b2a7b 100644 --- a/src/ShellUI.Templates/Templates/ThemeToggleTemplate.cs +++ b/src/ShellUI.Templates/Templates/ThemeToggleTemplate.cs @@ -12,7 +12,9 @@ public static class ThemeToggleTemplate Category = ComponentCategory.Utility, FilePath = "ThemeToggle.razor", - Dependencies = new List() + // shellui-js provides ShellUI.addClassToDocument / removeClassFromDocument + // used by the toggle action. Without it the toggle silently no-ops. + Dependencies = new List { "shellui-js" } }; public static string Content => @"@namespace YourProjectNamespace.Components.UI @@ -22,7 +24,7 @@ @implements IAsyncDisposable diff --git a/src/ShellUI.Components/Components/SidebarTrigger.razor b/src/ShellUI.Components/Components/SidebarTrigger.razor index e6606b9..046f5d2 100644 --- a/src/ShellUI.Components/Components/SidebarTrigger.razor +++ b/src/ShellUI.Components/Components/SidebarTrigger.razor @@ -7,7 +7,11 @@ Class)" @onclick="HandleClick" @attributes="AdditionalAttributes"> - + + + + + Toggle Sidebar diff --git a/src/ShellUI.Templates/Templates/SidebarTriggerTemplate.cs b/src/ShellUI.Templates/Templates/SidebarTriggerTemplate.cs index 0b3316e..7cded5c 100644 --- a/src/ShellUI.Templates/Templates/SidebarTriggerTemplate.cs +++ b/src/ShellUI.Templates/Templates/SidebarTriggerTemplate.cs @@ -23,7 +23,11 @@ public static class SidebarTriggerTemplate Class)"" @onclick=""HandleClick"" @attributes=""AdditionalAttributes""> - + + + + + Toggle Sidebar From 6188e6462e4d51e8d2f496dc9dd3f0c67e0f2e59 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 17 Jun 2026 17:51:34 +0200 Subject: [PATCH 5/5] refactor(input-otp): replace JS eval with ShellUI.focusElement for improved focus handling Updated the InputOTP component in both Blazor and ShellUI templates to utilize ShellUI.focusElement instead of eval for focusing on OTP input fields. This change enhances performance and reliability by leveraging a dedicated JavaScript function. Additionally, updated the InputOTPTemplate to include a dependency on shellui-js and refined the input class handling for better styling consistency. --- .../Components/UI/InputOTP.razor | 2 +- .../Components/InputOTP.razor | 2 +- .../Templates/InputOTPTemplate.cs | 76 ++++++++----------- 3 files changed, 33 insertions(+), 47 deletions(-) diff --git a/NET9/BlazorInteractiveServer/Components/UI/InputOTP.razor b/NET9/BlazorInteractiveServer/Components/UI/InputOTP.razor index fb934da..185b7c1 100644 --- a/NET9/BlazorInteractiveServer/Components/UI/InputOTP.razor +++ b/NET9/BlazorInteractiveServer/Components/UI/InputOTP.razor @@ -121,7 +121,7 @@ { try { - await JS.InvokeVoidAsync("eval", $"document.getElementById('otp-input-{_id}-{index}')?.focus()"); + await JS.InvokeVoidAsync("ShellUI.focusElement", $"otp-input-{_id}-{index}"); } catch { diff --git a/src/ShellUI.Components/Components/InputOTP.razor b/src/ShellUI.Components/Components/InputOTP.razor index 1b85db4..c304de1 100644 --- a/src/ShellUI.Components/Components/InputOTP.razor +++ b/src/ShellUI.Components/Components/InputOTP.razor @@ -120,7 +120,7 @@ { try { - await JS.InvokeVoidAsync("eval", $"document.getElementById('otp-input-{_id}-{index}')?.focus()"); + await JS.InvokeVoidAsync("ShellUI.focusElement", $"otp-input-{_id}-{index}"); } catch { diff --git a/src/ShellUI.Templates/Templates/InputOTPTemplate.cs b/src/ShellUI.Templates/Templates/InputOTPTemplate.cs index c5f5dab..a955ee5 100644 --- a/src/ShellUI.Templates/Templates/InputOTPTemplate.cs +++ b/src/ShellUI.Templates/Templates/InputOTPTemplate.cs @@ -11,7 +11,9 @@ public class InputOTPTemplate Description = "One-time password input component", Category = ComponentCategory.Form, FilePath = "InputOTP.razor", - + // shellui-js provides ShellUI.focusElement for moving focus between OTP digit inputs. + // Shell.Cn is installed by `shellui init` via the shell template, so it is not listed here. + Dependencies = new List { "shellui-js" }, Tags = new List { "form", "input", "otp", "password", "verification" } }; @@ -19,18 +21,16 @@ public class InputOTPTemplate @using Microsoft.JSInterop @inject IJSRuntime JS -
+
@for (int i = 0; i < Length; i++) { var index = i; - var isGroupStart = GroupBy > 0 && i % GroupBy == 0; - var isGroupEnd = GroupBy > 0 && (i + 1) % GroupBy == 0; - - @if (isGroupStart && i > 0) + + @if (GroupBy > 0 && i > 0 && i % GroupBy == 0) { - - + - } - + _focusedIndex = index)"" disabled=""@Disabled"" - class=""@(""w-10 h-12 text-center text-lg font-semibold border-2 rounded-md transition-colors "" + (Disabled ? ""opacity-50 cursor-not-allowed bg-muted"" : ""bg-background"") + "" "" + (_focusedIndex == index ? ""border-ring ring-2 ring-ring ring-offset-2"" : ""border-input"") + "" focus:outline-none focus:border-ring focus:ring-2 focus:ring-ring focus:ring-offset-2 "" + (isGroupEnd && i < Length - 1 ? ""mr-0"" : ""mr-1""))"" /> + class=""@Shell.Cn(""w-10 h-12 text-center text-lg font-semibold border-2 rounded-md transition-colors focus:outline-none focus:border-ring focus:ring-2 focus:ring-ring focus:ring-offset-2"", Disabled ? ""opacity-50 cursor-not-allowed bg-muted"" : ""bg-background"", _focusedIndex == index ? ""border-ring ring-2 ring-ring ring-offset-2"" : ""border-input"")"" /> }
@code { - [Parameter] - public string Value { get; set; } = """"; - - [Parameter] - public EventCallback ValueChanged { get; set; } - - [Parameter] - public EventCallback OnComplete { get; set; } - - [Parameter] - public int Length { get; set; } = 6; - - [Parameter] - public int GroupBy { get; set; } = 3; - - [Parameter] - public bool Disabled { get; set; } - - [Parameter] - public string ClassName { get; set; } = """"; - + [Parameter] public string Value { get; set; } = """"; + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public EventCallback OnComplete { get; set; } + [Parameter] public int Length { get; set; } = 6; + [Parameter] public int GroupBy { get; set; } = 3; + [Parameter] public bool Disabled { get; set; } + [Parameter] public string? Class { get; set; } [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } - + private int _focusedIndex = -1; private ElementReference[] _inputRefs = null!; private string _id = Guid.NewGuid().ToString(""N"")[..8]; - + protected override void OnInitialized() { _inputRefs = new ElementReference[Length]; } - + private string GetDigit(int index) => index < Value.Length ? Value[index].ToString() : """"; - + private async Task HandleInput(int index, ChangeEventArgs e) { var input = e.Value?.ToString() ?? """"; - if (string.IsNullOrEmpty(input) || !char.IsDigit(input[0])) + if (string.IsNullOrEmpty(input) || !char.IsDigit(input[0])) { return; } - + var newValue = Value.PadRight(Length, ' '); var chars = newValue.ToCharArray(); chars[index] = input[0]; Value = new string(chars).TrimEnd(); - + await ValueChanged.InvokeAsync(Value); - + if (index < Length - 1) { await FocusInput(index + 1); } - + if (Value.Replace("" "", """").Length == Length) { await OnComplete.InvokeAsync(Value); } } - + private async Task HandleKeyDown(int index, KeyboardEventArgs e) { if (e.Key == ""Backspace"") @@ -140,27 +126,27 @@ private async Task HandleKeyDown(int index, KeyboardEventArgs e) await FocusInput(index + 1); } } - + private async Task HandlePaste(ClipboardEventArgs e) { + // Paste handling will be done via JS in a real implementation await Task.CompletedTask; } - + private async Task FocusInput(int index) { if (index >= 0 && index < Length) { try { - await JS.InvokeVoidAsync(""eval"", $""document.getElementById('otp-input-{_id}-{index}')?.focus()""); + await JS.InvokeVoidAsync(""ShellUI.focusElement"", $""otp-input-{_id}-{index}""); } catch { + // Ignore JS interop errors } } } } "; } - -