diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3350350..3e9c1ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,20 @@ jobs: dotnet new blazor -o SmokeApp --no-restore cd SmokeApp shellui init --tailwind standalone --yes + + # Assert init produced a working host: App.razor patched with render mode, + # theme bootstrap, and shellui.js script tag. + grep -q 'HeadOutlet @rendermode="InteractiveServer"' Components/App.razor || (echo "init did not patch HeadOutlet @rendermode"; exit 1) + grep -q 'Routes @rendermode="InteractiveServer"' Components/App.razor || (echo "init did not patch Routes @rendermode"; exit 1) + grep -q 'ShellUI theme bootstrap' Components/App.razor || (echo "init did not inject theme bootstrap"; exit 1) + grep -q '' Components/App.razor || (echo "init did not inject shellui.js script tag"; exit 1) + grep -q 'shellui-sidebar.js' Components/App.razor && (echo "init incorrectly injected shellui-sidebar.js script tag (sidebar JS is dynamically imported)"; exit 1) || true + + # Assert input.css has the full theme, not just @import "tailwindcss"; + grep -q '@theme inline' wwwroot/input.css || (echo "init did not write full theme to input.css"; exit 1) + grep -q ':root' wwwroot/input.css || (echo "init did not write :root variables to input.css"; exit 1) + grep -q '\.dark' wwwroot/input.css || (echo "init did not write .dark variables to input.css"; exit 1) + shellui add chart pie-chart dashboard-02 --force || true # Chart components reference ApexCharts.* types but the CLI does not yet # auto-add the runtime NuGet dep. Drop this line once `shellui add chart` diff --git a/ShellUI.Tests/InitBootstrapTests.cs b/ShellUI.Tests/InitBootstrapTests.cs new file mode 100644 index 0000000..b78de84 --- /dev/null +++ b/ShellUI.Tests/InitBootstrapTests.cs @@ -0,0 +1,134 @@ +using ShellUI.CLI.Services; +using Xunit; + +namespace ShellUI.Tests; + +public class InitBootstrapTests +{ + // Mirrors the App.razor that `dotnet new blazor` (net9) produces, including the + // @Assets[] asset-fingerprinting wrapper around the Blazor script. The wrapper + // is what tripped the smoke test on the first CI run of this branch. + private const string FreshAppRazor = @" + + + + + + + + + + + + + + + + + +"; + + [Fact] + public void RewriteAppRazor_AddsRenderModeToHeadOutletAndRoutes() + { + var result = InitService.RewriteAppRazor(FreshAppRazor); + + Assert.Contains(@"", result); + Assert.Contains(@"", result); + } + + [Fact] + public void RewriteAppRazor_InjectsThemeBootstrapInHead() + { + var result = InitService.RewriteAppRazor(FreshAppRazor); + + Assert.Contains("ShellUI theme bootstrap", result); + Assert.Contains("classList.add('dark')", result); + // Theme script must be inside , before . + var themeIdx = result.IndexOf("ShellUI theme bootstrap"); + var headCloseIdx = result.IndexOf(""); + Assert.True(themeIdx > 0 && themeIdx < headCloseIdx); + } + + [Fact] + public void RewriteAppRazor_InjectsShelluiJsBeforeBlazorScript() + { + var result = InitService.RewriteAppRazor(FreshAppRazor); + + var shelluiIdx = result.IndexOf(@""); + var blazorIdx = result.IndexOf("blazor.web.js"); + + Assert.True(shelluiIdx > 0, "shellui.js script tag was not injected"); + Assert.True(shelluiIdx < blazorIdx, "shellui.js must precede blazor.web.js so window.ShellUI.* is defined before Blazor calls into it"); + } + + [Fact] + public void RewriteAppRazor_HandlesBareBlazorScriptTag() + { + // Older templates ship the bare form without @Assets[]. The patcher must handle both. + const string bare = @""; + + var result = InitService.RewriteAppRazor(bare); + + Assert.Contains(@"", result); + Assert.True(result.IndexOf(@"shellui.js") < result.IndexOf(@"blazor.web.js")); + } + + [Fact] + public void RewriteAppRazor_IsIdempotent() + { + var once = InitService.RewriteAppRazor(FreshAppRazor); + var twice = InitService.RewriteAppRazor(once); + + Assert.Equal(once, twice); + } + + [Fact] + public void RewriteAppRazor_PreservesExistingRenderMode() + { + // If the user (or another tool) already set a different render mode (e.g. Auto), + // we must not overwrite it. + const string custom = + @"" + "\n" + + @""; + + var result = InitService.RewriteAppRazor(custom); + + Assert.Contains(@"", result); + Assert.Contains(@"", result); + Assert.DoesNotContain(@"InteractiveServer", result); + } + + [Fact] + public void RewriteWasmIndexHtml_InjectsThemeAndShelluiJs() + { + const string indexHtml = @" + + + App + + +
+ + +"; + + var result = InitService.RewriteWasmIndexHtml(indexHtml); + + Assert.Contains("ShellUI theme bootstrap", result); + var shelluiIdx = result.IndexOf(@""); + var blazorIdx = result.IndexOf(@""; + + var once = InitService.RewriteWasmIndexHtml(indexHtml); + var twice = InitService.RewriteWasmIndexHtml(once); + + Assert.Equal(once, twice); + } +} diff --git a/ShellUI.Tests/ShellUI.Tests.csproj b/ShellUI.Tests/ShellUI.Tests.csproj index aa45fab..932a68f 100644 --- a/ShellUI.Tests/ShellUI.Tests.csproj +++ b/ShellUI.Tests/ShellUI.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/src/ShellUI.CLI/Services/InitService.cs b/src/ShellUI.CLI/Services/InitService.cs index 4b356dc..8cd1906 100644 --- a/src/ShellUI.CLI/Services/InitService.cs +++ b/src/ShellUI.CLI/Services/InitService.cs @@ -1,6 +1,7 @@ using ShellUI.Core.Models; using ShellUI.Templates; using System.Text.Json; +using System.Text.RegularExpressions; using Spectre.Console; namespace ShellUI.CLI.Services; @@ -147,6 +148,10 @@ await AnsiConsole.Status() await SetupTailwindStandaloneAsync(); } + // Step 6.5: Patch App.razor / index.html — render mode + theme bootstrap + shellui.js + ctx.Status("Wiring up theme and render mode..."); + await BootstrapHostAsync(projectInfo); + // Step 7: Create MSBuild targets file ctx.Status("Setting up MSBuild integration..."); var buildPath = Path.Combine(Directory.GetCurrentDirectory(), "Build"); @@ -181,7 +186,6 @@ await AnsiConsole.Status() AnsiConsole.MarkupLine("\n[blue]Next steps:[/]"); AnsiConsole.MarkupLine(" [dim]1. Add components:[/] dotnet shellui add button"); AnsiConsole.MarkupLine(" [dim]2. Browse all:[/] dotnet shellui list"); - AnsiConsole.MarkupLine(" [dim]3. CopyButton/FileUpload/Command:[/] Add [yellow][/] before Blazor script in App.razor or index.html"); } private static async Task SetupTailwindNpmAsync() @@ -435,6 +439,107 @@ private static async Task UpdateProjectFileAsync(string projectFilePath, string } } + private static async Task BootstrapHostAsync(ProjectInfo projectInfo) + { + var cwd = Directory.GetCurrentDirectory(); + + // Blazor Web App / Server / SSR: patch Components/App.razor + var appRazor = Path.Combine(cwd, "Components", "App.razor"); + if (File.Exists(appRazor)) + { + var original = await File.ReadAllTextAsync(appRazor); + var patched = RewriteAppRazor(original); + if (patched != original) + { + await File.WriteAllTextAsync(appRazor, patched); + AnsiConsole.MarkupLine("[green]Patched:[/] Components/App.razor"); + } + return; + } + + // Blazor WASM (standalone): patch wwwroot/index.html instead — no Routes/HeadOutlet + // render-mode pattern there; just inject theme bootstrap + shellui.js script tag. + var indexHtml = Path.Combine(cwd, "wwwroot", "index.html"); + if (File.Exists(indexHtml)) + { + var original = await File.ReadAllTextAsync(indexHtml); + var patched = RewriteWasmIndexHtml(original); + if (patched != original) + { + await File.WriteAllTextAsync(indexHtml, patched); + AnsiConsole.MarkupLine("[green]Patched:[/] wwwroot/index.html"); + } + return; + } + + AnsiConsole.MarkupLine("[yellow]No Components/App.razor or wwwroot/index.html found — skipped host bootstrap.[/]"); + } + + // Idempotent: a second `shellui init` won't double-inject. Tags that already + // carry @rendermode or any other attribute aren't matched, so user customizations + // are preserved (they'll need to set @rendermode manually). + internal static string RewriteAppRazor(string content) + { + content = Regex.Replace(content, @"", @""); + content = Regex.Replace(content, @"", @""); + + // 3. Theme bootstrap in — sets `dark` class before paint to avoid FOUC. + if (!content.Contains("ShellUI theme bootstrap")) + { + const string themeScript = +@" +"; + content = Regex.Replace(content, @"", themeScript + "", RegexOptions.IgnoreCase); + } + + // 4. immediately before blazor.web.js — provides + // window.ShellUI.* (addClassToDocument, focusElement, copyToClipboard, …) for + // ThemeToggle, CopyButton, InputOTP, FileUpload, Command. The pattern matches + // both the modern `@Assets["_framework/blazor.web.js"]` and the bare form. + if (!content.Contains("shellui.js")) + { + content = Regex.Replace( + content, + @"(]*_framework/blazor\.web\.js[^>]*>)", + "\n $1"); + } + + return content; + } + + internal static string RewriteWasmIndexHtml(string content) + { + if (!content.Contains("ShellUI theme bootstrap")) + { + const string themeScript = +@" +"; + content = Regex.Replace(content, @"", themeScript + "", RegexOptions.IgnoreCase); + } + + if (!content.Contains("shellui.js")) + { + content = Regex.Replace( + content, + @"(]*_framework/blazor\.webassembly\.js[^>]*>)", + "\n $1"); + } + + return content; + } + private static void RemoveBootstrapFiles() { try diff --git a/src/ShellUI.CLI/ShellUI.CLI.csproj b/src/ShellUI.CLI/ShellUI.CLI.csproj index 7dab43e..58aa584 100644 --- a/src/ShellUI.CLI/ShellUI.CLI.csproj +++ b/src/ShellUI.CLI/ShellUI.CLI.csproj @@ -32,6 +32,7 @@ + diff --git a/src/ShellUI.Templates/CssTemplates.cs b/src/ShellUI.Templates/CssTemplates.cs index 949f959..e8143d2 100644 --- a/src/ShellUI.Templates/CssTemplates.cs +++ b/src/ShellUI.Templates/CssTemplates.cs @@ -2,8 +2,8 @@ namespace ShellUI.Templates; public static class CssTemplates { - public static string InputCss => @"@import ""tailwindcss""; -"; + public static string InputCss => FullThemeCss; + public static string InputCssNpm => FullThemeCss; public static string AppCss => @"/* * ShellUI - Blazor Component Library @@ -26,6 +26,8 @@ public static class CssTemplates } "; + // Tailwind v4 reads the theme via `@theme inline` inside input.css — no need + // to map CSS vars in the config file. Customize the `content` globs only. public static string TailwindConfigJsNpm => @"/** @type {import('tailwindcss').Config} */ module.exports = { content: [ @@ -33,54 +35,205 @@ public static class CssTemplates './Pages/**/*.{razor,html,cshtml}', ], darkMode: 'class', - theme: { - extend: { - colors: { - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))', - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))', - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))', - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))', - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))', - }, - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))', - }, - }, - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)', - }, - }, - }, plugins: [], } "; - public static string InputCssNpm => @"@import ""tailwindcss""; + // Default theme: light + dark CSS variables, @theme inline mapping for Tailwind v4, + // and base animation keyframes used by the Loading component. Tweakcn-compatible — + // users can paste a new :root / .dark block over this to retheme. + private const string FullThemeCss = @"@import ""tailwindcss""; +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(0.9900 0 0); + --foreground: oklch(0 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0 0 0); + --popover: oklch(0.9900 0 0); + --popover-foreground: oklch(0 0 0); + --primary: oklch(0 0 0); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.9400 0 0); + --secondary-foreground: oklch(0 0 0); + --muted: oklch(0.9700 0 0); + --muted-foreground: oklch(0.4400 0 0); + --accent: oklch(0.9400 0 0); + --accent-foreground: oklch(0 0 0); + --destructive: oklch(0.6300 0.1900 23.0300); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.9200 0 0); + --input: oklch(0.9400 0 0); + --ring: oklch(0 0 0); + --chart-1: oklch(0.8100 0.1700 75.3500); + --chart-2: oklch(0.5500 0.2200 264.5300); + --chart-3: oklch(0.7200 0 0); + --chart-4: oklch(0.9200 0 0); + --chart-5: oklch(0.5600 0 0); + --sidebar: oklch(0.9900 0 0); + --sidebar-foreground: oklch(0 0 0); + --sidebar-primary: oklch(0 0 0); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.9400 0 0); + --sidebar-accent-foreground: oklch(0 0 0); + --sidebar-border: oklch(0.9400 0 0); + --sidebar-ring: oklch(0 0 0); + --font-sans: Geist, sans-serif; + --font-serif: Georgia, serif; + --font-mono: Geist Mono, monospace; + --radius: 0.5rem; + --shadow-x: 0px; + --shadow-y: 1px; + --shadow-blur: 2px; + --shadow-spread: 0px; + --shadow-opacity: 0.18; + --shadow-color: hsl(0 0% 0%); + --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); + --shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); + --shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); + --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); + --tracking-normal: 0em; + --spacing: 0.25rem; +} + +.dark { + --background: oklch(0 0 0); + --foreground: oklch(1 0 0); + --card: oklch(0.1400 0 0); + --card-foreground: oklch(1 0 0); + --popover: oklch(0.1800 0 0); + --popover-foreground: oklch(1 0 0); + --primary: oklch(1 0 0); + --primary-foreground: oklch(0 0 0); + --secondary: oklch(0.2500 0 0); + --secondary-foreground: oklch(1 0 0); + --muted: oklch(0.2300 0 0); + --muted-foreground: oklch(0.7200 0 0); + --accent: oklch(0.3200 0 0); + --accent-foreground: oklch(1 0 0); + --destructive: oklch(0.6900 0.2000 23.9100); + --destructive-foreground: oklch(0 0 0); + --border: oklch(0.2600 0 0); + --input: oklch(0.3200 0 0); + --ring: oklch(0.7200 0 0); + --chart-1: oklch(0.8100 0.1700 75.3500); + --chart-2: oklch(0.5800 0.2100 260.8400); + --chart-3: oklch(0.5600 0 0); + --chart-4: oklch(0.4400 0 0); + --chart-5: oklch(0.9200 0 0); + --sidebar: oklch(0.1800 0 0); + --sidebar-foreground: oklch(1 0 0); + --sidebar-primary: oklch(1 0 0); + --sidebar-primary-foreground: oklch(0 0 0); + --sidebar-accent: oklch(0.3200 0 0); + --sidebar-accent-foreground: oklch(1 0 0); + --sidebar-border: oklch(0.3200 0 0); + --sidebar-ring: oklch(0.7200 0 0); + --font-sans: Geist, sans-serif; + --font-serif: Georgia, serif; + --font-mono: Geist Mono, monospace; + --radius: 0.5rem; + --shadow-color: hsl(0 0% 0%); + --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); + --shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); + --shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); + --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); +} + +@theme inline { + --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-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --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); + + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); +} + +@layer base { + * { + border-color: var(--border); + } + body { + background-color: var(--background); + color: var(--foreground); + } +} + +html, body, :host { + font-family: var(--font-sans); +} + +/* Animation keyframes used by the Loading component variants */ +@keyframes bars { + 0%, 100% { transform: scaleY(0.4); opacity: 0.7; } + 50% { transform: scaleY(1); opacity: 1; } +} + +@keyframes bars-vertical { + 0%, 100% { transform: scaleY(0.4); opacity: 0.7; } + 50% { transform: scaleY(1); opacity: 1; } +} + +@keyframes bars-pulse { + 0%, 100% { transform: scaleY(0.3); opacity: 0.5; } + 50% { transform: scaleY(1); opacity: 1; } +} + +@keyframes orbit { + 0% { transform: translate(-50%, 0) rotate(0deg) translateX(calc((var(--size, 1rem) - 0.5rem) / 2)) rotate(0deg); } + 100% { transform: translate(-50%, 0) rotate(360deg) translateX(calc((var(--size, 1rem) - 0.5rem) / 2)) rotate(-360deg); } +} "; }