diff --git a/01-branding-manage-initial.png b/01-branding-manage-initial.png new file mode 100644 index 00000000..9f505955 Binary files /dev/null and b/01-branding-manage-initial.png differ diff --git a/02-home-branding-applied-fullload.png b/02-home-branding-applied-fullload.png new file mode 100644 index 00000000..af07da37 Binary files /dev/null and b/02-home-branding-applied-fullload.png differ diff --git a/03-save-applies-color-immediately.png b/03-save-applies-color-immediately.png new file mode 100644 index 00000000..bee5d82f Binary files /dev/null and b/03-save-applies-color-immediately.png differ diff --git a/Dockerfile b/Dockerfile index 1606c2c1..67e8d09c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,6 +70,8 @@ COPY modules/Notifications/src/SimpleModule.Notifications.Contracts/*.csproj mod COPY modules/Notifications/src/SimpleModule.Notifications/*.csproj modules/Notifications/src/SimpleModule.Notifications/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting/ +COPY modules/Branding/src/SimpleModule.Branding.Contracts/*.csproj modules/Branding/src/SimpleModule.Branding.Contracts/ +COPY modules/Branding/src/SimpleModule.Branding/*.csproj modules/Branding/src/SimpleModule.Branding/ RUN dotnet restore template/SimpleModule.Host/SimpleModule.Host.csproj @@ -96,6 +98,7 @@ COPY modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/package.json modules COPY modules/Email/src/SimpleModule.Email/package.json modules/Email/src/SimpleModule.Email/ COPY modules/Notifications/src/SimpleModule.Notifications/package.json modules/Notifications/src/SimpleModule.Notifications/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting/package.json modules/RateLimiting/src/SimpleModule.RateLimiting/ +COPY modules/Branding/src/SimpleModule.Branding/package.json modules/Branding/src/SimpleModule.Branding/ COPY packages/SimpleModule.Client/package.json packages/SimpleModule.Client/ COPY packages/SimpleModule.Theme.Default/package.json packages/SimpleModule.Theme.Default/ COPY packages/SimpleModule.TsConfig/package.json packages/SimpleModule.TsConfig/ diff --git a/Dockerfile.worker b/Dockerfile.worker index db65de73..875649fd 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -78,6 +78,8 @@ COPY modules/Notifications/src/SimpleModule.Notifications.Contracts/*.csproj mod COPY modules/Notifications/src/SimpleModule.Notifications/*.csproj modules/Notifications/src/SimpleModule.Notifications/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting/ +COPY modules/Branding/src/SimpleModule.Branding.Contracts/*.csproj modules/Branding/src/SimpleModule.Branding.Contracts/ +COPY modules/Branding/src/SimpleModule.Branding/*.csproj modules/Branding/src/SimpleModule.Branding/ RUN dotnet restore template/SimpleModule.Worker/SimpleModule.Worker.csproj diff --git a/SimpleModule.slnx b/SimpleModule.slnx index b68ecbd4..8cffaf34 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -22,6 +22,11 @@ + + + + + diff --git a/docs/branding/01-branding-applied.png b/docs/branding/01-branding-applied.png new file mode 100644 index 00000000..0759bf40 Binary files /dev/null and b/docs/branding/01-branding-applied.png differ diff --git a/docs/branding/02-admin-manage.png b/docs/branding/02-admin-manage.png new file mode 100644 index 00000000..f04a54c3 Binary files /dev/null and b/docs/branding/02-admin-manage.png differ diff --git a/docs/branding/README.md b/docs/branding/README.md new file mode 100644 index 00000000..1be5818a --- /dev/null +++ b/docs/branding/README.md @@ -0,0 +1,21 @@ +# Branding module — screenshots + +The Branding module lets an administrator customize the appearance of a +SimpleModule application from a single global configuration at +`/branding/manage`. Colors, custom CSS, and the favicon are injected into the +document `` server-side (no flash), while the app name, logo, top bar, and +footer reach the client as Inertia shared props. + +## Branding applied across the app + +Custom top bar (message + link + dismiss), brand mark and app name, primary +color applied to navigation, headings, and buttons, plus the custom footer. + +![Branding applied](./01-branding-applied.png) + +## Admin configuration page + +Identity (app name, logo, favicon), colors, top bar, footer, and advanced +custom CSS — with a live preview panel. + +![Admin manage page](./02-admin-manage.png) diff --git a/docs/superpowers/plans/2026-06-25-branding-module.md b/docs/superpowers/plans/2026-06-25-branding-module.md new file mode 100644 index 00000000..10899e9d --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-branding-module.md @@ -0,0 +1,1978 @@ +# Branding Module Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a Branding module that lets an admin customize a SimpleModule app's appearance — app name, logo, favicon, primary color (light+dark), custom CSS, a configurable top bar, and a configurable footer — applied across the whole app with no color flash. + +**Architecture:** Branding stores values as `Application`-scoped Settings (no new DbContext). A `BrandingService` resolves them into a `BrandingDto`. Values reach the UI two ways: (1) an Inertia shared prop `branding` (set by a module middleware) drives React-rendered chrome (name, logo, top bar, footer); (2) a small generic framework extension — `IInertiaHeadContributor` + a `` placeholder — injects `--color-primary` overrides, custom CSS, and a favicon `` into the document `` server-side (no flash). Logo/favicon are uploaded via the existing FileStorage module and served by Branding's own anonymous asset endpoint. + +**Tech Stack:** .NET 10, ASP.NET minimal APIs, Roslyn source-gen module discovery, EF-less (settings-backed), React 19 + Inertia.js, Vite library mode, `@simplemodule/ui`, xUnit.v3 + FluentAssertions. + +## Global Constraints + +- Source generator/module discovery is compile-time: every new endpoint/DTO must build cleanly. `TreatWarningsAsErrors` is ON (`AnalysisMode=All`) — no warnings. +- **No Claude/AI attribution** in commits or source; do not append session URLs to commits. +- Module main project: `Microsoft.NET.Sdk.StaticWebAssets`, `net10.0`, ``. Contracts project: `Microsoft.NET.Sdk`, `net10.0`, references `SimpleModule.Core` only. +- Every `IViewEndpoint` with `Inertia.Render("Branding/X", …)` MUST have a matching `Pages/index.ts` entry (`npm run validate-pages`). +- C#: file-scoped namespaces, `var`, interfaces `IFoo`, private fields `_camelCase`. Biome for TS: single quotes, semicolons, 2-space, trailing commas, 100-col. +- Default primary hex: light `#059669`, dark `#34d399`. +- Settings keys (verbatim): `branding.app_name`, `branding.logo_file_id`, `branding.favicon_file_id`, `branding.color_primary`, `branding.color_primary_dark`, `branding.custom_css`, `branding.topbar`, `branding.footer`. All `SettingScope.Application`, `Group = "Branding"`. +- Asset routes: serve `GET /api/branding/assets/{kind}` (anonymous), upload `POST /api/branding/assets/{kind}` (admin). Admin API: `GET`/`PUT /api/branding`. View: `GET /branding`. Permission: `Branding.Manage`. + +--- + +## Task 1: Contracts project (DTOs, keys, defaults, interface) + +**Files:** +- Create: `modules/Branding/src/SimpleModule.Branding.Contracts/SimpleModule.Branding.Contracts.csproj` +- Create: `modules/Branding/src/SimpleModule.Branding.Contracts/BrandingSettingKeys.cs` +- Create: `modules/Branding/src/SimpleModule.Branding.Contracts/BrandingDefaults.cs` +- Create: `modules/Branding/src/SimpleModule.Branding.Contracts/BrandingLink.cs` +- Create: `modules/Branding/src/SimpleModule.Branding.Contracts/TopBarConfig.cs` +- Create: `modules/Branding/src/SimpleModule.Branding.Contracts/FooterConfig.cs` +- Create: `modules/Branding/src/SimpleModule.Branding.Contracts/BrandingDto.cs` +- Create: `modules/Branding/src/SimpleModule.Branding.Contracts/BrandingEditModel.cs` +- Create: `modules/Branding/src/SimpleModule.Branding.Contracts/IBrandingContracts.cs` +- Modify: `SimpleModule.slnx` (add Contracts project) + +**Interfaces:** +- Produces: `IBrandingContracts` (`GetBrandingAsync()→BrandingDto`, `GetCustomCssAsync()→string`, `GetEditableAsync()→BrandingEditModel`, `UpdateAsync(BrandingEditModel)→Task`); DTOs `BrandingDto`, `BrandingEditModel`, `TopBarConfig`, `FooterConfig`, `BrandingLink`; static `BrandingSettingKeys`, `BrandingDefaults`. + +- [ ] **Step 1: Create the Contracts csproj** + +```xml + + + + net10.0 + Library + + + + + +``` + +- [ ] **Step 2: Add keys + defaults** + +```csharp +// BrandingSettingKeys.cs +namespace SimpleModule.Branding.Contracts; + +public static class BrandingSettingKeys +{ + public const string AppName = "branding.app_name"; + public const string LogoFileId = "branding.logo_file_id"; + public const string FaviconFileId = "branding.favicon_file_id"; + public const string ColorPrimary = "branding.color_primary"; + public const string ColorPrimaryDark = "branding.color_primary_dark"; + public const string CustomCss = "branding.custom_css"; + public const string TopBar = "branding.topbar"; + public const string Footer = "branding.footer"; +} +``` + +```csharp +// BrandingDefaults.cs +namespace SimpleModule.Branding.Contracts; + +public static class BrandingDefaults +{ + public const string AppName = "SimpleModule"; + public const string ColorPrimary = "#059669"; + public const string ColorPrimaryDark = "#34d399"; +} +``` + +- [ ] **Step 3: Add DTOs** + +```csharp +// BrandingLink.cs +using SimpleModule.Core; + +namespace SimpleModule.Branding.Contracts; + +[Dto] +public class BrandingLink +{ + public string Label { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; +} +``` + +```csharp +// TopBarConfig.cs +using System.Collections.Generic; +using SimpleModule.Core; + +namespace SimpleModule.Branding.Contracts; + +[Dto] +public class TopBarConfig +{ + public bool Enabled { get; set; } + public string Message { get; set; } = string.Empty; + public string BackgroundColor { get; set; } = "#059669"; + public string TextColor { get; set; } = "#ffffff"; + public List Links { get; set; } = []; + public bool Dismissible { get; set; } = true; +} +``` + +```csharp +// FooterConfig.cs +using System.Collections.Generic; +using SimpleModule.Core; + +namespace SimpleModule.Branding.Contracts; + +[Dto] +public class FooterConfig +{ + public bool Enabled { get; set; } + public string Text { get; set; } = string.Empty; + public List Links { get; set; } = []; + public bool ShowCopyright { get; set; } = true; +} +``` + +```csharp +// BrandingDto.cs (shared-prop + head-contributor face) +using SimpleModule.Core; + +namespace SimpleModule.Branding.Contracts; + +[Dto] +public class BrandingDto +{ + public string AppName { get; set; } = BrandingDefaults.AppName; + public string? LogoUrl { get; set; } + public string? FaviconUrl { get; set; } + public string ColorPrimary { get; set; } = BrandingDefaults.ColorPrimary; + public string ColorPrimaryDark { get; set; } = BrandingDefaults.ColorPrimaryDark; + public TopBarConfig TopBar { get; set; } = new(); + public FooterConfig Footer { get; set; } = new(); +} +``` + +```csharp +// BrandingEditModel.cs (admin form face — includes ids + custom css) +using SimpleModule.Core; + +namespace SimpleModule.Branding.Contracts; + +[Dto] +public class BrandingEditModel +{ + public string AppName { get; set; } = BrandingDefaults.AppName; + public string? LogoFileId { get; set; } + public string? LogoUrl { get; set; } + public string? FaviconFileId { get; set; } + public string? FaviconUrl { get; set; } + public string ColorPrimary { get; set; } = BrandingDefaults.ColorPrimary; + public string ColorPrimaryDark { get; set; } = BrandingDefaults.ColorPrimaryDark; + public string CustomCss { get; set; } = string.Empty; + public TopBarConfig TopBar { get; set; } = new(); + public FooterConfig Footer { get; set; } = new(); +} +``` + +- [ ] **Step 4: Add the contract interface** + +```csharp +// IBrandingContracts.cs +using System.Threading.Tasks; + +namespace SimpleModule.Branding.Contracts; + +public interface IBrandingContracts +{ + Task GetBrandingAsync(); + Task GetCustomCssAsync(); + Task GetEditableAsync(); + Task UpdateAsync(BrandingEditModel model); +} +``` + +- [ ] **Step 5: Register the project in `SimpleModule.slnx`** + +Add a `` entry alongside the other module contracts projects (match the existing XML grouping/indentation in `SimpleModule.slnx`). + +- [ ] **Step 6: Build to verify it compiles** + +Run: `dotnet build modules/Branding/src/SimpleModule.Branding.Contracts/SimpleModule.Branding.Contracts.csproj` +Expected: Build succeeded, 0 warnings. + +- [ ] **Step 7: Commit** + +```bash +git add modules/Branding/src/SimpleModule.Branding.Contracts SimpleModule.slnx +git commit -m "feat(branding): add contracts project (DTOs, keys, interface)" +``` + +--- + +## Task 2: Framework — `IInertiaHeadContributor` + renderer head injection + +**Files:** +- Create: `framework/SimpleModule.Core/Inertia/IInertiaHeadContributor.cs` +- Modify: `framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs` +- Modify: `template/SimpleModule.Host/wwwroot/index.html` (add placeholder) +- Test: `framework/SimpleModule.Hosting.Tests/Inertia/HeadContributorTests.cs` (create; if no such test project exists, add the test to the closest existing hosting/integration test project — search `tests/` and `framework/**/*.Tests`) + +**Interfaces:** +- Produces: `IInertiaHeadContributor { ValueTask GetHeadHtmlAsync(HttpContext context); }`. Renderer replaces `` per request with the concatenation of all registered contributors' non-empty output; empty string when none. + +- [ ] **Step 1: Write the failing integration test** + +Use the shared web factory. Register a fake contributor, request a full HTML page, assert the marker appears; on the default app assert the literal placeholder is gone. + +```csharp +// HeadContributorTests.cs +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core.Inertia; +using SimpleModule.Tests.Shared; +using Xunit; + +public class HeadContributorTests +{ + private sealed class MarkerContributor : IInertiaHeadContributor + { + public ValueTask GetHeadHtmlAsync(HttpContext context) => + ValueTask.FromResult(""); + } + + [Fact] + public async Task RenderedPage_IncludesContributorOutput_AndNoRawPlaceholder() + { + await using var factory = new SimpleModuleWebApplicationFactory(); + using var client = factory + .WithWebHostBuilder(b => + b.ConfigureServices(s => + s.AddScoped() + ) + ) + .CreateClient(); + + var html = await client.GetStringAsync("/"); + + html.Should().Contain("/*HEAD_MARKER*/"); + html.Should().NotContain(""); + } +} +``` + +> If `SimpleModuleWebApplicationFactory` requires a different construction (check an existing integration test like `modules/Admin/tests/.../Integration/AdminRolesEndpointTests.cs`), mirror that. The factory may be injected via `[Collection(TestCollections.Integration)]`; if a fresh instance can't take service overrides, instead add the fake contributor through the factory's existing override hook used by other tests, or assert the empty-placeholder behavior only and verify the contributor path in Task 6's branding integration test. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test --filter "FullyQualifiedName~HeadContributorTests"` +Expected: FAIL — `` not present yet (the marker isn't injected; the page also lacks the placeholder). + +- [ ] **Step 3: Add the interface** + +```csharp +// framework/SimpleModule.Core/Inertia/IInertiaHeadContributor.cs +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace SimpleModule.Core.Inertia; + +/// +/// Implement and register (scoped) to contribute raw HTML into the document <head> +/// of every full server-rendered Inertia page. Output replaces the +/// <!--HEAD_CONTRIBUTIONS--> placeholder (just before </head>) per request. +/// Contributors are responsible for their own escaping. +/// +public interface IInertiaHeadContributor +{ + ValueTask GetHeadHtmlAsync(HttpContext context); +} +``` + +- [ ] **Step 4: Add the placeholder to `index.html`** + +In `template/SimpleModule.Host/wwwroot/index.html`, insert the placeholder on its own line immediately before `` (after the `` block): + +```html + + +``` + +- [ ] **Step 5: Inject contributions in the renderer** + +In `HtmlFileInertiaPageRenderer.cs`: + +1. Add a constant next to the others: +```csharp +private const string HeadContributionsPlaceholder = ""; +``` +2. Change `RenderPageAsync` to `async Task`, build the head HTML, and replace the placeholder in `before` before writing. Replace the current method body with: + +```csharp +public async Task RenderPageAsync(HttpContext httpContext, string pageJson) +{ + var nonce = httpContext.RequestServices.GetRequiredService().Value; + var useViteDev = + _isDevelopment && httpContext.Items.ContainsKey(DevToolsConstants.ViteDevServerKey); + + var before = useViteDev ? _beforePlaceholderViteDev : _beforePlaceholder; + var after = useViteDev ? _afterPlaceholderViteDev : _afterPlaceholder; + var devScript = + _isDevelopment && !useViteDev + ? "" + : ""; + + var headHtml = await BuildHeadContributionsAsync(httpContext); + before = before.Replace(HeadContributionsPlaceholder, headHtml, StringComparison.Ordinal); + + httpContext.Response.ContentType = "text/html; charset=utf-8"; + await httpContext.Response.WriteAsync( + string.Concat( + before.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal), + $"", + devScript, + after.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal) + ) + ); +} + +private static async Task BuildHeadContributionsAsync(HttpContext httpContext) +{ + var contributors = httpContext.RequestServices.GetServices(); + StringBuilder? sb = null; + foreach (var contributor in contributors) + { + var html = await contributor.GetHeadHtmlAsync(httpContext); + if (string.IsNullOrEmpty(html)) + continue; + (sb ??= new StringBuilder()).Append(html); + } + return sb?.ToString() ?? string.Empty; +} +``` +3. Add `using SimpleModule.Core.Inertia;` if not already present (the file already uses `SimpleModule.Core.Inertia`). `System.Text`, `Microsoft.Extensions.DependencyInjection` are already imported. + +> Note: the constructor must NOT replace `HeadContributionsPlaceholder` (only `DEPLOY_VERSION` and `MODULE_CSS_LINKS` are replaced at startup). Leaving it untouched is correct — it survives into `_beforePlaceholder` and the ViteDev variant. + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `dotnet test --filter "FullyQualifiedName~HeadContributorTests"` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add framework/SimpleModule.Core/Inertia/IInertiaHeadContributor.cs \ + framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs \ + template/SimpleModule.Host/wwwroot/index.html \ + framework/SimpleModule.Hosting.Tests +git commit -m "feat(core): per-request IInertiaHeadContributor head injection" +``` + +--- + +## Task 3: Branding main project — module, service, permissions, settings, menu + +**Files:** +- Create: `modules/Branding/src/SimpleModule.Branding/SimpleModule.Branding.csproj` +- Create: `modules/Branding/src/SimpleModule.Branding/BrandingPermissions.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/BrandingService.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/BrandingModule.cs` +- Modify: `SimpleModule.slnx`; `template/SimpleModule.Host/SimpleModule.Host.csproj` +- Create test project: `modules/Branding/tests/SimpleModule.Branding.Tests/SimpleModule.Branding.Tests.csproj` +- Create: `modules/Branding/tests/SimpleModule.Branding.Tests/FakeSettings.cs` +- Test: `modules/Branding/tests/SimpleModule.Branding.Tests/BrandingServiceTests.cs` + +**Interfaces:** +- Consumes: `IBrandingContracts`, DTOs, keys, defaults (Task 1); `ISettingsContracts` (`GetSettingAsync`, `SetManyAsync`); `IInertiaHeadContributor` (Task 2 — referenced in Task 6). +- Produces: `BrandingService : IBrandingContracts`; `BrandingPermissions.Manage = "Branding.Manage"`; `BrandingModule` registering the service + settings + permission + admin menu item. + +- [ ] **Step 1: Create the main csproj** + +```xml + + + + net10.0 + Branding module for SimpleModule. Customize app name, logo, favicon, colors, custom CSS, top bar, and footer. + + + + + + + + + +``` + +- [ ] **Step 2: Add permissions** + +```csharp +// BrandingPermissions.cs +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Branding; + +public sealed class BrandingPermissions : IModulePermissions +{ + public const string Manage = "Branding.Manage"; +} +``` + +- [ ] **Step 3: Write the failing service unit test (+ fake settings)** + +```csharp +// FakeSettings.cs — dictionary-backed ISettingsContracts for unit tests +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using SimpleModule.Core.Settings; +using SimpleModule.Settings.Contracts; + +namespace SimpleModule.Branding.Tests; + +public sealed class FakeSettings : ISettingsContracts +{ + private readonly Dictionary _app = []; + + public Task GetSettingAsync(string key, SettingScope scope, string? userId = null) => + Task.FromResult(_app.TryGetValue(key, out var v) ? v.Deserialize() : default); + + public Task GetSettingAsync(string key, SettingScope scope, string? userId = null) => + Task.FromResult(_app.TryGetValue(key, out var v) ? v.GetString() : null); + + public Task SetSettingAsync(string key, JsonElement value, SettingScope scope, string? userId = null) + { + _app[key] = value; + return Task.CompletedTask; + } + + public Task SetManyAsync(IReadOnlyList updates) + { + foreach (var u in updates) + _app[u.Key] = u.Value; + return Task.CompletedTask; + } + + // Unused members — throw so accidental reliance is caught. + public Task ResolveUserSettingAsync(string key, string userId) => throw new System.NotSupportedException(); + public Task ResolveUserSettingElementAsync(string key, string userId) => throw new System.NotSupportedException(); + public Task DeleteSettingAsync(string key, SettingScope scope, string? userId = null) => throw new System.NotSupportedException(); + public Task ResetToDefaultAsync(string key, SettingScope scope, string? userId = null) => throw new System.NotSupportedException(); + public Task> GetSettingValuesAsync(SettingsFilter? filter = null) => throw new System.NotSupportedException(); + public Task GetSettingValueAsync(string key, SettingScope scope, string? userId = null) => throw new System.NotSupportedException(); +} +``` + +> Copy the exact `ISettingsContracts` member list from `modules/Settings/src/SimpleModule.Settings.Contracts/ISettingsContracts.cs` at implementation time and match every signature — the list above mirrors the researched interface but must compile against the real one. + +```csharp +// BrandingServiceTests.cs +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core.Settings; +using Xunit; + +public class BrandingServiceTests +{ + [Fact] + public async Task GetBrandingAsync_ReturnsDefaults_WhenNothingStored() + { + var svc = new BrandingService(new FakeSettings()); + + var dto = await svc.GetBrandingAsync(); + + dto.AppName.Should().Be(BrandingDefaults.AppName); + dto.ColorPrimary.Should().Be(BrandingDefaults.ColorPrimary); + dto.LogoUrl.Should().BeNull(); + dto.TopBar.Enabled.Should().BeFalse(); + dto.Footer.Enabled.Should().BeFalse(); + } + + [Fact] + public async Task Update_Then_Get_RoundTrips() + { + var settings = new FakeSettings(); + var svc = new BrandingService(settings); + + await svc.UpdateAsync(new BrandingEditModel + { + AppName = "Acme", + ColorPrimary = "#112233", + CustomCss = ".x{color:red}", + TopBar = new TopBarConfig { Enabled = true, Message = "Hi" }, + Footer = new FooterConfig { Enabled = true, Text = "© Acme" }, + }); + + var dto = await svc.GetBrandingAsync(); + dto.AppName.Should().Be("Acme"); + dto.ColorPrimary.Should().Be("#112233"); + dto.TopBar.Message.Should().Be("Hi"); + dto.Footer.Text.Should().Be("© Acme"); + (await svc.GetCustomCssAsync()).Should().Be(".x{color:red}"); + } + + [Fact] + public async Task GetBrandingAsync_BuildsLogoUrl_WhenFileIdSet() + { + var settings = new FakeSettings(); + await settings.SetSettingAsync(BrandingSettingKeys.LogoFileId, + JsonSerializer.SerializeToElement("abc-123"), SettingScope.Application); + var svc = new BrandingService(settings); + + var dto = await svc.GetBrandingAsync(); + + dto.LogoUrl.Should().Be("/api/branding/assets/logo?v=abc-123"); + } +} +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `dotnet test --filter "FullyQualifiedName~BrandingServiceTests"` +Expected: FAIL — `BrandingService` does not exist yet (won't compile until Step 5/6 wire the test project). + +- [ ] **Step 5: Create the test project csproj + FakeSettings** + +```xml + + + + net10.0 + false + Exe + + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 6: Implement `BrandingService`** + +```csharp +// BrandingService.cs +using System.Text.Json; +using System.Threading.Tasks; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core.Settings; +using SimpleModule.Settings.Contracts; + +namespace SimpleModule.Branding; + +public sealed class BrandingService(ISettingsContracts settings) : IBrandingContracts +{ + public async Task GetBrandingAsync() + { + var logoId = await settings.GetSettingAsync(BrandingSettingKeys.LogoFileId, SettingScope.Application); + var faviconId = await settings.GetSettingAsync(BrandingSettingKeys.FaviconFileId, SettingScope.Application); + + return new BrandingDto + { + AppName = await Str(BrandingSettingKeys.AppName, BrandingDefaults.AppName), + ColorPrimary = await Str(BrandingSettingKeys.ColorPrimary, BrandingDefaults.ColorPrimary), + ColorPrimaryDark = await Str(BrandingSettingKeys.ColorPrimaryDark, BrandingDefaults.ColorPrimaryDark), + LogoUrl = AssetUrl("logo", logoId), + FaviconUrl = AssetUrl("favicon", faviconId), + TopBar = await Json(BrandingSettingKeys.TopBar) ?? new TopBarConfig(), + Footer = await Json(BrandingSettingKeys.Footer) ?? new FooterConfig(), + }; + } + + public async Task GetCustomCssAsync() => + await Str(BrandingSettingKeys.CustomCss, string.Empty); + + public async Task GetEditableAsync() + { + var logoId = await settings.GetSettingAsync(BrandingSettingKeys.LogoFileId, SettingScope.Application); + var faviconId = await settings.GetSettingAsync(BrandingSettingKeys.FaviconFileId, SettingScope.Application); + + return new BrandingEditModel + { + AppName = await Str(BrandingSettingKeys.AppName, BrandingDefaults.AppName), + ColorPrimary = await Str(BrandingSettingKeys.ColorPrimary, BrandingDefaults.ColorPrimary), + ColorPrimaryDark = await Str(BrandingSettingKeys.ColorPrimaryDark, BrandingDefaults.ColorPrimaryDark), + CustomCss = await Str(BrandingSettingKeys.CustomCss, string.Empty), + LogoFileId = logoId, + LogoUrl = AssetUrl("logo", logoId), + FaviconFileId = faviconId, + FaviconUrl = AssetUrl("favicon", faviconId), + TopBar = await Json(BrandingSettingKeys.TopBar) ?? new TopBarConfig(), + Footer = await Json(BrandingSettingKeys.Footer) ?? new FooterConfig(), + }; + } + + public async Task UpdateAsync(BrandingEditModel model) + { + var updates = new System.Collections.Generic.List + { + Str(BrandingSettingKeys.AppName, model.AppName ?? BrandingDefaults.AppName), + Str(BrandingSettingKeys.ColorPrimary, model.ColorPrimary ?? BrandingDefaults.ColorPrimary), + Str(BrandingSettingKeys.ColorPrimaryDark, model.ColorPrimaryDark ?? BrandingDefaults.ColorPrimaryDark), + Str(BrandingSettingKeys.CustomCss, model.CustomCss ?? string.Empty), + JsonUpdate(BrandingSettingKeys.TopBar, model.TopBar ?? new TopBarConfig()), + JsonUpdate(BrandingSettingKeys.Footer, model.Footer ?? new FooterConfig()), + }; + if (model.LogoFileId is not null) + updates.Add(Str(BrandingSettingKeys.LogoFileId, model.LogoFileId)); + if (model.FaviconFileId is not null) + updates.Add(Str(BrandingSettingKeys.FaviconFileId, model.FaviconFileId)); + + await settings.SetManyAsync(updates); + } + + private async Task Str(string key, string fallback) + { + var v = await settings.GetSettingAsync(key, SettingScope.Application); + return string.IsNullOrWhiteSpace(v) ? fallback : v; + } + + private Task Json(string key) => + settings.GetSettingAsync(key, SettingScope.Application); + + private static string? AssetUrl(string kind, string? fileId) => + string.IsNullOrWhiteSpace(fileId) ? null : $"/api/branding/assets/{kind}?v={fileId}"; + + private static BulkSettingUpdate Str(string key, string value) => new() + { + Key = key, + Scope = SettingScope.Application, + Value = JsonSerializer.SerializeToElement(value), + }; + + private static BulkSettingUpdate JsonUpdate(string key, T value) => new() + { + Key = key, + Scope = SettingScope.Application, + Value = JsonSerializer.SerializeToElement(value), + }; +} +``` + +- [ ] **Step 7: Implement `BrandingModule` (settings/permissions/menu; service registration)** + +```csharp +// BrandingModule.cs +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Menu; +using SimpleModule.Core.Settings; + +namespace SimpleModule.Branding; + +[Module("Branding", ViewPrefix = "/branding")] +public class BrandingModule : IModule +{ + private const string Icon = + """"""; + + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + // IInertiaHeadContributor + middleware are added in Task 6. + } + + public void ConfigurePermissions(PermissionRegistryBuilder builder) => + builder.AddPermissions(); + + public void ConfigureMenu(IMenuBuilder menus) => + menus.Add(new MenuItem + { + Label = "Branding", + Url = "/branding", + Icon = Icon, + Order = 86, + Section = MenuSection.AppSidebar, + Roles = ["Admin"], + RequiredPermission = BrandingPermissions.Manage, + }); + + public void ConfigureSettings(ISettingsBuilder settings) + { + settings + .Add(Def(BrandingSettingKeys.AppName, "Application name", SettingType.Text, JsonSerializer.Serialize(BrandingDefaults.AppName), order: 0)) + .Add(Def(BrandingSettingKeys.ColorPrimary, "Primary color (light)", SettingType.Color, JsonSerializer.Serialize(BrandingDefaults.ColorPrimary), order: 1)) + .Add(Def(BrandingSettingKeys.ColorPrimaryDark, "Primary color (dark)", SettingType.Color, JsonSerializer.Serialize(BrandingDefaults.ColorPrimaryDark), order: 2)) + .Add(Def(BrandingSettingKeys.CustomCss, "Custom CSS", SettingType.MultilineText, "\"\"", order: 3)) + .Add(Def(BrandingSettingKeys.LogoFileId, "Logo file id", SettingType.Text, "\"\"", order: 4)) + .Add(Def(BrandingSettingKeys.FaviconFileId, "Favicon file id", SettingType.Text, "\"\"", order: 5)) + .Add(Def(BrandingSettingKeys.TopBar, "Top bar", SettingType.Json, JsonSerializer.Serialize(new TopBarConfig()), order: 6)) + .Add(Def(BrandingSettingKeys.Footer, "Footer", SettingType.Json, JsonSerializer.Serialize(new FooterConfig()), order: 7)); + } + + private static SettingDefinition Def(string key, string name, SettingType type, string defaultValue, int order) => + new() + { + Key = key, + DisplayName = name, + Group = "Branding", + Scope = SettingScope.Application, + Type = type, + DefaultValue = defaultValue, + Order = order, + }; +} +``` + +- [ ] **Step 8: Register projects in `SimpleModule.slnx` and Host csproj** + +Add to `SimpleModule.slnx`: the main project and the test project. Add to `template/SimpleModule.Host/SimpleModule.Host.csproj` a `` alongside the other module references. + +- [ ] **Step 9: Run tests to verify they pass + build host** + +Run: `dotnet test --filter "FullyQualifiedName~BrandingServiceTests"` → PASS. +Run: `dotnet build template/SimpleModule.Host/SimpleModule.Host.csproj` → Build succeeded (module discovered by source generator). + +- [ ] **Step 10: Commit** + +```bash +git add modules/Branding SimpleModule.slnx template/SimpleModule.Host/SimpleModule.Host.csproj +git commit -m "feat(branding): module, settings, permission, menu, BrandingService" +``` + +--- + +## Task 4: Admin API + asset endpoints + view endpoint + +**Files:** +- Create: `modules/Branding/src/SimpleModule.Branding/Endpoints/Branding/GetBrandingEndpoint.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/Endpoints/Branding/UpdateBrandingEndpoint.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/Endpoints/Branding/UploadAssetEndpoint.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/Endpoints/Branding/AssetEndpoint.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/Pages/ManageEndpoint.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/Pages/index.ts` +- Test: `modules/Branding/tests/SimpleModule.Branding.Tests/Integration/BrandingEndpointsTests.cs` + +**Interfaces:** +- Consumes: `IBrandingContracts`, `ISettingsContracts`, `IFileStorageContracts`, `BrandingPermissions`, settings keys. +- Produces: routes `GET/PUT /api/branding`, `POST /api/branding/assets/{kind}`, `GET /api/branding/assets/{kind}`, `GET /branding`. + +- [ ] **Step 1: Write the failing integration tests** + +```csharp +// Integration/BrandingEndpointsTests.cs +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using SimpleModule.Branding.Contracts; +using SimpleModule.Tests.Shared; +using Xunit; + +[Collection(TestCollections.Integration)] +public class BrandingEndpointsTests +{ + private readonly SimpleModuleWebApplicationFactory _factory; + public BrandingEndpointsTests(SimpleModuleWebApplicationFactory factory) => _factory = factory; + + private HttpClient AdminClient() + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var claims = $"{ClaimTypes.Role}=Admin;{ClaimTypes.NameIdentifier}=admin-branding-test"; + client.DefaultRequestHeaders.Add("X-Test-Claims", claims); + client.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); + return client; + } + + [Fact] + public async Task Get_Requires_Permission() + { + var anon = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var res = await anon.GetAsync("/api/branding"); + res.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden, HttpStatusCode.Redirect); + } + + [Fact] + public async Task Put_Then_Get_RoundTrips() + { + var client = AdminClient(); + var model = new BrandingEditModel { AppName = "Acme Co", ColorPrimary = "#123456" }; + + var put = await client.PutAsJsonAsync("/api/branding", model); + put.StatusCode.Should().Be(HttpStatusCode.OK); + + var got = await client.GetFromJsonAsync("/api/branding"); + got!.AppName.Should().Be("Acme Co"); + got.ColorPrimary.Should().Be("#123456"); + } + + [Fact] + public async Task Asset_Serve_Returns404_WhenUnset() + { + var anon = _factory.CreateClient(); + var res = await anon.GetAsync("/api/branding/assets/logo"); + res.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ManageView_Requires_Permission_And_RendersForAdmin() + { + var admin = AdminClient(); + var res = await admin.GetAsync("/branding"); + res.StatusCode.Should().Be(HttpStatusCode.OK); + } +} +``` + +> The exact unauthorized status depends on the app's auth fallback (cookie redirect vs 401/403). `BeOneOf(...)` keeps the test robust; tighten after observing actual behavior. If the integration collection requires a token-seeded user with the `Branding.Manage` permission rather than the `Admin` role, follow the seeding pattern used by `modules/Settings` or `modules/FileStorage` integration tests. Admin role bypasses permission checks (per `MenuItem`/permission semantics), so `Role=Admin` should satisfy `RequirePermission` — verify against an existing admin-gated endpoint test. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test --filter "FullyQualifiedName~BrandingEndpointsTests"` +Expected: FAIL — endpoints/routes don't exist (404). + +- [ ] **Step 3: Implement the admin GET/PUT endpoints** + +```csharp +// Endpoints/Branding/GetBrandingEndpoint.cs +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Branding.Endpoints.Branding; + +public class GetBrandingEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/api/branding", async (IBrandingContracts branding) => + TypedResults.Ok(await branding.GetEditableAsync())) + .RequirePermission(BrandingPermissions.Manage); +} +``` + +```csharp +// Endpoints/Branding/UpdateBrandingEndpoint.cs +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Branding.Endpoints.Branding; + +public class UpdateBrandingEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPut("/api/branding", async (BrandingEditModel model, IBrandingContracts branding) => + { + await branding.UpdateAsync(model); + return TypedResults.Ok(); + }) + .RequirePermission(BrandingPermissions.Manage) + .DisableAntiforgery(); +} +``` + +- [ ] **Step 4: Implement the upload endpoint** + +```csharp +// Endpoints/Branding/UploadAssetEndpoint.cs +using System; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Security; // GetUserId() — verify namespace in FileStorage UploadEndpoint +using SimpleModule.Core.Settings; +using SimpleModule.FileStorage.Contracts; +using SimpleModule.Settings.Contracts; + +namespace SimpleModule.Branding.Endpoints.Branding; + +public class UploadAssetEndpoint : IEndpoint +{ + private const long MaxBytes = 2 * 1024 * 1024; + + public void Map(IEndpointRouteBuilder app) => + app.MapPost("/api/branding/assets/{kind}", async Task ( + string kind, + IFormFile? file, + HttpContext context, + IFileStorageContracts files, + ISettingsContracts settings) => + { + if (kind is not ("logo" or "favicon")) + return TypedResults.BadRequest("Invalid asset kind."); + if (file is null || file.Length == 0) + return TypedResults.BadRequest("A file is required."); + if (!file.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + return TypedResults.BadRequest("Only image files are allowed."); + if (file.Length > MaxBytes) + return TypedResults.BadRequest("File too large (max 2 MB)."); + + var userId = context.User.GetUserId(); + await using var stream = file.OpenReadStream(); + var stored = await files.UploadFileAsync(stream, file.FileName, file.ContentType, "branding", userId); + + var key = kind == "logo" ? BrandingSettingKeys.LogoFileId : BrandingSettingKeys.FaviconFileId; + var fileId = stored.Id.ToString(); + await settings.SetSettingAsync(key, JsonSerializer.SerializeToElement(fileId), SettingScope.Application); + + return TypedResults.Ok(new { fileId, url = $"/api/branding/assets/{kind}?v={fileId}" }); + }) + .RequirePermission(BrandingPermissions.Manage) + .DisableAntiforgery(); +} +``` + +> Verify `context.User.GetUserId()`'s namespace by reading `modules/FileStorage/.../Endpoints/Files/UploadEndpoint.cs` (it uses the same call) and copy its `using`. `stored.Id.ToString()` must yield a value `Guid.TryParse` can round-trip in the serve endpoint — confirm `FileStorageId.ToString()` returns the bare GUID (read `FileStorageId`); if it returns a wrapped form, store `stored.Id.Value.ToString()` instead and adjust `AssetUrl`/serve parsing to match. + +- [ ] **Step 5: Implement the anonymous serve endpoint** + +```csharp +// Endpoints/Branding/AssetEndpoint.cs +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Settings; +using SimpleModule.FileStorage.Contracts; +using SimpleModule.Settings.Contracts; + +namespace SimpleModule.Branding.Endpoints.Branding; + +public class AssetEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/api/branding/assets/{kind}", async Task ( + string kind, + HttpContext context, + ISettingsContracts settings, + IFileStorageContracts files) => + { + if (kind is not ("logo" or "favicon")) + return Results.NotFound(); + + var key = kind == "logo" ? BrandingSettingKeys.LogoFileId : BrandingSettingKeys.FaviconFileId; + var idStr = await settings.GetSettingAsync(key, SettingScope.Application); + if (string.IsNullOrWhiteSpace(idStr) || !Guid.TryParse(idStr, out var guid)) + return Results.NotFound(); + + var file = await files.GetFileByIdAsync(FileStorageId.From(guid)); + if (file is null) + return Results.NotFound(); + + var stream = await files.DownloadFileAsync(file); + if (stream is null) + return Results.NotFound(); + + context.Response.Headers.CacheControl = "public, max-age=3600"; + return Results.File(stream, file.ContentType); + }) + .AllowAnonymous(); +} +``` + +> `FileStorageId.From(Guid)` — confirm the exact factory on `FileStorageId` (strongly-typed id; the repo uses value converters for it). If the factory differs (e.g. `FileStorageId.FromGuid`, or it wraps a `long`), adjust both this parse and the stored representation in Task 4 Step 4 so they round-trip. + +- [ ] **Step 6: Implement the view endpoint + Pages/index.ts** + +```csharp +// Pages/ManageEndpoint.cs +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.Branding.Pages; + +public class ManageEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/branding", async (IBrandingContracts branding) => + Inertia.Render("Branding/Manage", new { branding = await branding.GetEditableAsync() })) + .RequirePermission(BrandingPermissions.Manage); +} +``` + +```ts +// Pages/index.ts +export const pages: Record = { + 'Branding/Manage': () => import('./Manage'), +}; +``` + +> `Manage.tsx` is created in Task 6; `validate-pages` is only run after that. To keep the build green now, you may create a minimal placeholder `Pages/Manage.tsx` (`export default function Manage(){return null}`) and flesh it out in Task 6, or defer creating `Pages/index.ts` until Task 6. Prefer the placeholder so the C# endpoint and registry stay in sync. + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `dotnet test --filter "FullyQualifiedName~BrandingEndpointsTests"` +Expected: PASS (all four). + +- [ ] **Step 8: Commit** + +```bash +git add modules/Branding/src/SimpleModule.Branding/Endpoints modules/Branding/src/SimpleModule.Branding/Pages modules/Branding/tests +git commit -m "feat(branding): admin API, asset upload/serve, manage view endpoint" +``` + +--- + +## Task 5: Shared-prop middleware + head contributor + +**Files:** +- Create: `modules/Branding/src/SimpleModule.Branding/BrandingSharedDataMiddleware.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/BrandingHeadContributor.cs` +- Modify: `modules/Branding/src/SimpleModule.Branding/BrandingModule.cs` (register contributor in `ConfigureServices`; add `ConfigureMiddleware`) +- Test: `modules/Branding/tests/SimpleModule.Branding.Tests/Integration/BrandingRenderingTests.cs` + +**Interfaces:** +- Consumes: `IBrandingContracts`, `InertiaSharedData`, `IInertiaHeadContributor` (Task 2). +- Produces: `branding` shared Inertia prop on every page; `` `"); + } + + if (!string.IsNullOrWhiteSpace(b.FaviconUrl)) + sb.Append(""); + + return sb.Length == 0 ? null : sb.ToString(); + } + + private static bool ColorEquals(string a, string b) => + string.Equals(a, b, System.StringComparison.OrdinalIgnoreCase); + + private static string SanitizeColor(string color) => + HexColor().IsMatch(color) ? color : BrandingDefaults.ColorPrimary; + + private static string StripClosingStyle(string css) => + ClosingStyle().Replace(css, string.Empty); + + private static string EncodeAttr(string value) => + value.Replace("\"", """, System.StringComparison.Ordinal); + + [GeneratedRegex("^#[0-9a-fA-F]{3,8}$")] + private static partial Regex HexColor(); + + [GeneratedRegex("", RegexOptions.IgnoreCase)] + private static partial Regex ClosingStyle(); +} +``` + +- [ ] **Step 5: Wire registration in `BrandingModule`** + +In `BrandingModule.ConfigureServices`, add the contributor registration; add `ConfigureMiddleware`: + +```csharp +public void ConfigureServices(IServiceCollection services, IConfiguration configuration) +{ + services.AddScoped(); + services.AddScoped(); +} + +public void ConfigureMiddleware(Microsoft.AspNetCore.Builder.IApplicationBuilder app) +{ + app.UseMiddleware(); +} +``` + +(Add `using Microsoft.AspNetCore.Builder;` and `using SimpleModule.Core.Inertia;` to the top instead of inlining the fully-qualified names if you prefer — match file style.) + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `dotnet test --filter "FullyQualifiedName~BrandingRenderingTests"` +Expected: PASS. Also re-run `HeadContributorTests` to confirm no regression. + +> If `FullPage_Injects_PrimaryColor_WhenChanged` fails because module `ConfigureMiddleware` runs too late to set shared data / contributors aren't resolved on the `/` request, confirm the branding middleware is in the pipeline before endpoint execution (compare with `SimpleModule.Localization`'s middleware). The head contributor path is independent of the middleware (resolved directly by the renderer), so color injection should work even if the shared-prop timing needs adjustment. + +- [ ] **Step 7: Commit** + +```bash +git add modules/Branding/src/SimpleModule.Branding modules/Branding/tests +git commit -m "feat(branding): shared-prop middleware + head contributor (colors/css/favicon)" +``` + +--- + +## Task 6: Frontend — shared types, chrome (brand mark, top bar, footer), layout edits + +**Files:** +- Modify: `packages/SimpleModule.UI/components/layouts/types.ts` +- Create: `packages/SimpleModule.UI/components/layouts/brand-mark.tsx` +- Create: `packages/SimpleModule.UI/components/layouts/top-bar.tsx` +- Create: `packages/SimpleModule.UI/components/layouts/footer.tsx` +- Modify: `packages/SimpleModule.UI/components/layouts/app-layout.tsx` +- Modify: `packages/SimpleModule.UI/components/layouts/public-layout.tsx` +- Modify: `packages/SimpleModule.UI/components/layouts/index.ts` (export new pieces if needed) + +**Interfaces:** +- Consumes: `branding` shared prop (Task 5). +- Produces: `BrandingProps`/`TopBarConfig`/`FooterConfig`/`BrandingLink` TS types on `SharedProps.branding`; ``, ``, `