diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1cb22889..0cc97e14 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -24,7 +24,11 @@ "mcp__plugin_playwright_playwright__browser_tabs", "mcp__ide__getDiagnostics", "mcp__plugin_playwright_playwright__browser_close", - "mcp__plugin_playwright_playwright__browser_evaluate" + "mcp__plugin_playwright_playwright__browser_evaluate", + "mcp__plugin_playwright_playwright__browser_take_screenshot", + "mcp__plugin_playwright_playwright__browser_press_key", + "mcp__plugin_playwright_playwright__browser_run_code", + "mcp__plugin_playwright_playwright__browser_network_requests" ] } } diff --git a/.claude/skills/new-module/SKILL.md b/.claude/skills/new-module/SKILL.md index 5e5bc539..5282eccb 100644 --- a/.claude/skills/new-module/SKILL.md +++ b/.claude/skills/new-module/SKILL.md @@ -58,7 +58,7 @@ public class Module : IModule } ``` -4. Add `` to `src/SimpleModule.Api/SimpleModule.Api.csproj` +4. Add `` to `src/SimpleModule.Host/SimpleModule.Host.csproj` 5. Add the project to `SimpleModule.sln` using `dotnet sln add` diff --git a/.editorconfig b/.editorconfig index 583c7040..727a724f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -159,7 +159,7 @@ generated_code = true generated_code = true # ---- Test project relaxations ---- -[{tests,src/modules/*/tests}/**/*.cs] +[{tests,modules/*/tests}/**/*.cs] # CA1707 — underscore in test method names (standard convention) dotnet_diagnostic.CA1707.severity = none diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7be9017..bd35a5e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,4 +85,4 @@ jobs: dotnet-version: '10.0.x' - name: Publish AOT - run: dotnet publish src/SimpleModule.Api/SimpleModule.Api.csproj -c Release + run: dotnet publish template/SimpleModule.Host/SimpleModule.Host.csproj -c Release diff --git a/.gitignore b/.gitignore index 7a7c25f9..fbeb7189 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -## Ignore Visual Studio temporary files, build results, and +## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore @@ -207,6 +207,9 @@ PublishScripts/ **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ +# except our top-level packages/ directory (npm libraries) +!/packages/ +!/packages/** # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files @@ -413,5 +416,10 @@ tools/tailwindcss* .playwright-mcp/ # Module JS build output (generated by Vite) -src/modules/**/wwwroot/*.lib.module.js -src/modules/**/wwwroot/*.lib.module.js.map +modules/**/wwwroot/* + +# Tailwind CSS build output +template/SimpleModule.Host/wwwroot/* + +# Tailwind CSS module scan directory +template/SimpleModule.Host/Styles/_scan/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..dfa386d0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + } +} diff --git a/CLAUDE.md b/CLAUDE.md index cfd4b6e5..cdd8cce8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,39 +1,129 @@ -# SimpleModule +# CLAUDE.md -Modular monolith framework for .NET with compile-time module discovery via Roslyn source generators. Fully AOT-compatible — no reflection. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Is + +Modular monolith framework for .NET with compile-time module discovery via Roslyn source generators. Fully AOT-compatible — no reflection. Frontend uses React 19 + Inertia.js served via Blazor SSR. + +## Build & Run + +```bash +dotnet build +dotnet run --project template/SimpleModule.Host # runs on https://localhost:5001 +``` + +## Frontend (npm workspaces) + +```bash +npm install # install all workspace dependencies +npm run check # biome lint + format check +npm run check:fix # auto-fix lint + formatting +npm run lint # lint only +npm run format # format only (with write) +``` + +Workspaces: `modules/*/src/*`, `packages/*`, and `template/SimpleModule.Host/ClientApp`. + +## Testing + +```bash +dotnet test # all tests +dotnet test --filter "FullyQualifiedName~ClassName" # single test class +dotnet test --filter "FullyQualifiedName~MethodName" # single test method +``` + +Test stack: xUnit.v3, FluentAssertions, Bogus, Microsoft.AspNetCore.Mvc.Testing. SQLite in-memory for unit tests, PostgreSQL for integration tests in CI. ## Architecture -- **SimpleModule.Core** — `IModule` interface + `[Module]` attribute. References `Microsoft.AspNetCore.App` framework. -- **SimpleModule.Generator** — Incremental source generator (`netstandard2.0`) that discovers `[Module]`-decorated classes across referenced assemblies and generates `AddModules()` / `MapModuleEndpoints()` extension methods with direct `new` calls (no DI for module resolution). -- **SimpleModule.Api** — Host app (net10.0, PublishAot). References Core, Generator (as Analyzer), and all module projects. -- **src/modules/** — Each module is a class library (net10.0) referencing Core + `Microsoft.AspNetCore.App` framework. +### .NET Backend + +- **SimpleModule.Core** — `IModule` interface, `[Module]` attribute, `IEndpoint` interface, `[Dto]` attribute, menu system (`IMenuRegistry`), event bus (`IEventBus`), Inertia integration. +- **SimpleModule.Generator** — Roslyn `IIncrementalGenerator` (netstandard2.0). Scans referenced assemblies for `[Module]` classes, `IEndpoint` implementors, and `[Dto]` types. Generates: `AddModules()`, `MapModuleEndpoints()`, `CollectModuleMenuItems()`, AOT JSON serializers, TypeScript interface definitions, Razor component assembly discovery. +- **SimpleModule.Host** — Host app (net10.0, PublishAot). Calls generated extension methods in `Program.cs`. Custom Inertia middleware bridges Blazor SSR → React. + +### Frontend (React + Inertia.js) + +- **ClientApp** (`template/SimpleModule.Host/ClientApp/app.tsx`) — Inertia bootstrap. Resolves pages by splitting route name (e.g., `Products/Browse` → imports `/_content/Products/Products.pages.js`). +- **Module pages** — Each module builds its React pages via Vite in library mode → `{ModuleName}.pages.js` in module's `wwwroot/`. Entry point: `Pages/index.ts` exporting a `pages` record mapping route names to components. +- **Type generation** — `[Dto]` types → source generator embeds TS interfaces → `tools/extract-ts-types.mjs` writes `.ts` files to `ClientApp/types/`. + +### Request Flow + +1. ASP.NET route handler calls `Inertia.Render("Products/Browse", props)` +2. Inertia middleware renders Blazor SSR shell with JSON props +3. React ClientApp dynamically imports module's `pages.js` bundle +4. Component hydrates with server-provided props ## Key Constraints -- **No reflection** — everything must be AOT-compliant. The source generator emits static `new ModuleName()` calls. -- **Source generator must target netstandard2.0** with `LangVersion: latest` and `EnforceExtendedAnalyzerRules: true` (must use `IIncrementalGenerator`, not `ISourceGenerator`). -- **Module class libraries must NOT have `PublishAot`** — only the API project should. They need ``. +- **No reflection** — source generator emits static `new ModuleName()` calls for AOT. +- **Source generator must target netstandard2.0** with `IIncrementalGenerator` (not `ISourceGenerator`). +- **Module class libraries must NOT have `PublishAot`** — only the Host project. Modules need ``. +- **Module Vite builds use library mode** — externalize React, React-DOM, @inertiajs/react. Inline dynamic imports. +- **TreatWarningsAsErrors is enabled** globally via `Directory.Build.props` with `AnalysisLevel=latest-all` and `AnalysisMode=All`. Suppressed rules are listed in `.editorconfig`. -## Build & Run +## C# Conventions (enforced by .editorconfig) + +- **Naming**: Interfaces `IFoo`, public members `PascalCase`, private fields `_camelCase`, locals/params `camelCase`, constants `PascalCase`. +- **Style**: File-scoped namespaces (error), usings outside namespace (error), prefer `var`. +- **Tests**: Underscore method names allowed (`Method_Scenario_Expected`). CA2234 (Uri overload) and xUnit1051 (CancellationToken) suppressed in test projects. + +## Module Communication + +- **Contracts pattern** — each module has a `.Contracts` project with a public interface (e.g., `IProductContracts`) and `[Dto]` types. Other modules depend on contracts, never implementations. +- **Event bus** — `IEventBus.PublishAsync()` broadcasts to all `IEventHandler` implementations. Handler failures are isolated (collected in `AggregateException`). + +## Test Infrastructure + +- **`SimpleModule.Tests.Shared`** provides `SimpleModuleWebApplicationFactory` — in-memory SQLite, test auth scheme with `CreateAuthenticatedClient(params Claim[] claims)`, claims passed via `X-Test-Claims` header. +- **`FakeDataGenerators`** (Bogus) — pre-built fakers for all module DTOs and request types. +- CI runs tests against both SQLite and PostgreSQL. + +## Frontend Packages (`packages/`) + +- **@simplemodule/client** — Vite plugin for vendoring, page resolution utility. +- **@simplemodule/ui** — Radix UI component wrappers with Tailwind. Import components from `@simplemodule/ui/components`, utils from `@simplemodule/ui/lib/utils`. +- **@simplemodule/theme-default** — Tailwind CSS base theme. + +## CLI (`sm` command) ```bash -dotnet build -dotnet run --project src/SimpleModule.Api +sm new project # scaffold new SimpleModule solution +sm new module # create module with contracts, endpoints, tests, events +sm new feature # add feature to existing module +sm doctor [--fix] # validate project structure, auto-fix issues ``` +## Database + +- Multi-provider: SQLite (table prefixes), PostgreSQL/SQL Server (schemas per module). +- Each module registers `ModuleDbContextInfo` for schema isolation. +- Uses `EnsureCreated()` — for production migrations, use EF Core migrations per module. + ## Adding a New Module -1. Create folder `src/modules//` -2. Create `src/modules//src/.Contracts/` with: - - `.Contracts.csproj` (references Core only, uses `Microsoft.NET.Sdk`) +Use `sm new module ` (CLI) or manually: + +1. Create `modules//` +2. Create `modules//src/.Contracts/` with: + - `.Contracts.csproj` (references Core only, `Microsoft.NET.Sdk`) - `IContracts.cs` — public interface for cross-module use - Shared DTO types marked with `[Dto]` -3. Create `src/modules//src//` with: - - `.csproj` (references Core + `.Contracts`; uses `Microsoft.NET.Sdk` with ``) - - `Module.cs` — implements `IModule` with `[Module("Name")]` - - `Features//` folders containing endpoint and handler classes - - Register the contract interface against implementation in `ConfigureServices` -4. Create `src/modules//tests/.Tests/` with test project -5. Add `ProjectReference` to `src/SimpleModule.Api/SimpleModule.Api.csproj` pointing to `/src/.csproj` +3. Create `modules//src//` with: + - `.csproj` (references Core + Contracts; `Microsoft.NET.Sdk` with ``) + - `Module.cs` — implements `IModule` with `[Module("Name", RoutePrefix = "...")]` + - `Endpoints//` — endpoint classes implementing `IEndpoint` (auto-discovered) + - `Pages/index.ts` — exports `pages` record mapping route names to React components + - `vite.config.ts` — library mode build targeting `Pages/index.ts` + - `package.json` — declare React/Inertia as peerDependencies + - Register contract interface in `ConfigureServices` + - **Escape hatch**: For non-standard routes, implement `ConfigureEndpoints` on the module class +4. Create `modules//tests/.Tests/` with xUnit test project +5. Add `ProjectReference` to `template/SimpleModule.Host/SimpleModule.Host.csproj` 6. Add all projects to `SimpleModule.slnx` + +## Linting & Formatting + +Biome is configured at repo root (`biome.json`). Covers `modules/**`, `packages/**`, `template/**` except `**/wwwroot/**`. Settings: single quotes, semicolons always, 2-space indent, trailing commas, 100-char line width. Tailwind CSS directives enabled. diff --git a/Directory.Build.props b/Directory.Build.props index 6bf2efa3..a9fc6003 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,6 @@ + $(MSBuildThisFileDirectory) enable enable true diff --git a/Dockerfile b/Dockerfile index 73f35305..9d3272ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,27 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src -# Copy solution and project files for restore COPY Directory.Build.props Directory.Packages.props ./ COPY *.slnx ./ -COPY src/SimpleModule.Core/*.csproj src/SimpleModule.Core/ -COPY src/SimpleModule.Database/*.csproj src/SimpleModule.Database/ -COPY src/SimpleModule.Generator/*.csproj src/SimpleModule.Generator/ -COPY src/SimpleModule.Api/*.csproj src/SimpleModule.Api/ -COPY src/modules/Users/Users.Contracts/*.csproj src/modules/Users/Users.Contracts/ -COPY src/modules/Users/Users/*.csproj src/modules/Users/Users/ -COPY src/modules/Products/Products.Contracts/*.csproj src/modules/Products/Products.Contracts/ -COPY src/modules/Products/Products/*.csproj src/modules/Products/Products/ -COPY src/modules/Orders/Orders.Contracts/*.csproj src/modules/Orders/Orders.Contracts/ -COPY src/modules/Orders/Orders/*.csproj src/modules/Orders/Orders/ -RUN dotnet restore src/SimpleModule.Api/SimpleModule.Api.csproj +COPY framework/SimpleModule.Core/*.csproj framework/SimpleModule.Core/ +COPY framework/SimpleModule.Database/*.csproj framework/SimpleModule.Database/ +COPY framework/SimpleModule.Generator/*.csproj framework/SimpleModule.Generator/ +COPY framework/SimpleModule.Blazor/*.csproj framework/SimpleModule.Blazor/ +COPY template/SimpleModule.Host/*.csproj template/SimpleModule.Host/ +COPY modules/Dashboard/src/Dashboard/*.csproj modules/Dashboard/src/Dashboard/ +COPY modules/Users/src/Users.Contracts/*.csproj modules/Users/src/Users.Contracts/ +COPY modules/Users/src/Users/*.csproj modules/Users/src/Users/ +COPY modules/Products/src/Products.Contracts/*.csproj modules/Products/src/Products.Contracts/ +COPY modules/Products/src/Products/*.csproj modules/Products/src/Products/ +COPY modules/Orders/src/Orders.Contracts/*.csproj modules/Orders/src/Orders.Contracts/ +COPY modules/Orders/src/Orders/*.csproj modules/Orders/src/Orders/ +RUN dotnet restore template/SimpleModule.Host/SimpleModule.Host.csproj -# Copy everything and publish COPY . . -RUN dotnet publish src/SimpleModule.Api/SimpleModule.Api.csproj -c Release -o /app/publish +RUN dotnet publish template/SimpleModule.Host/SimpleModule.Host.csproj -c Release -o /app/publish FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app COPY --from=build /app/publish . EXPOSE 8080 -ENTRYPOINT ["dotnet", "SimpleModule.Api.dll"] +ENTRYPOINT ["dotnet", "SimpleModule.Host.dll"] diff --git a/README.md b/README.md index c473cbd0..70195b7f 100644 --- a/README.md +++ b/README.md @@ -1 +1,96 @@ -# SimpleModule \ No newline at end of file +# SimpleModule + +A modular monolith framework for .NET with compile-time module discovery via Roslyn source generators. Fully AOT-compatible with no reflection. Frontend uses React 19 + Inertia.js served via Blazor SSR. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- [Node.js](https://nodejs.org/) (for frontend builds) + +## Getting Started + +```bash +dotnet build +npm install +dotnet run --project template/SimpleModule.Host # https://localhost:5001 +``` + +### Docker + +```bash +docker compose up +``` + +Runs the app on `http://localhost:8080` with PostgreSQL 16. + +## Architecture + +``` +framework/ + SimpleModule.Core # IModule, IEndpoint, [Dto], [Module], IEventBus, IMenuRegistry + SimpleModule.Generator # Roslyn source generator (netstandard2.0) — module/endpoint/DTO discovery + SimpleModule.Database # Multi-provider DB support (SQLite, PostgreSQL, SQL Server) + SimpleModule.Blazor # Blazor SSR shell for Inertia rendering +modules/ + Dashboard/ # Dashboard module + Products/ # Products module (src + contracts + tests) + Orders/ # Orders module (src + contracts + tests) + Users/ # Users module (src + contracts + tests) +packages/ + SimpleModule.Client # Vite plugin + page resolution for module frontends + SimpleModule.UI # Radix UI component library with Tailwind + SimpleModule.Theme.Default # Tailwind CSS base theme +template/ + SimpleModule.Host # Host app (net10.0) — wires modules via generated code +cli/ + SimpleModule.Cli # `sm` CLI tool for scaffolding and validation +``` + +### How It Works + +1. Modules are .NET class libraries decorated with `[Module("Name", RoutePrefix = "...")]` implementing `IModule` +2. The Roslyn source generator scans referenced assemblies at compile time and emits static registration code — no reflection needed +3. Endpoints implement `IEndpoint` and are auto-discovered and mapped +4. Each module builds its React pages via Vite in library mode into a `{ModuleName}.pages.js` bundle +5. Inertia.js bridges the .NET backend to the React frontend: endpoints call `Inertia.Render("Module/Page", props)`, the Blazor SSR shell delivers the initial HTML, and React hydrates on the client + +### Module Communication + +Modules communicate through two mechanisms: + +- **Contracts** — each module has a `.Contracts` project exposing a public interface (e.g., `IProductContracts`) and shared `[Dto]` types. Modules depend on contract interfaces, never on other module implementations. +- **Event bus** — `IEventBus.PublishAsync()` broadcasts events to all registered `IEventHandler` implementations across modules. + +### Database + +Each module gets its own isolated storage — table prefixes on SQLite, separate schemas on PostgreSQL/SQL Server. Modules register a `ModuleDbContextInfo` and the framework handles schema creation. + +## CLI + +```bash +sm new project # scaffold a new SimpleModule solution +sm new module # create a module with contracts, endpoints, tests, events +sm new feature # add a feature to an existing module +sm doctor [--fix] # validate project structure, auto-fix issues +``` + +## Testing + +```bash +dotnet test # all tests +dotnet test --filter "FullyQualifiedName~ClassName" # single test class +dotnet test --filter "FullyQualifiedName~MethodName" # single test method +``` + +Tests use xUnit.v3, FluentAssertions, Bogus, and NSubstitute. Integration tests run against in-memory SQLite by default. CI also tests against PostgreSQL 16 using the `Database__DefaultConnection` environment variable. + +## Linting & Formatting + +**C#** — Analyzers enforced via `Directory.Build.props` (`TreatWarningsAsErrors`, `AnalysisLevel=latest-all`). Naming and style rules in `.editorconfig`. + +**TypeScript/React** — [Biome](https://biomejs.dev/) configured at repo root. Single quotes, semicolons, 2-space indent, trailing commas, 100-char line width. + +```bash +npm run check # lint + format check +npm run check:fix # auto-fix +``` diff --git a/SimpleModule.slnx b/SimpleModule.slnx index d5d88b2d..7b382271 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -4,28 +4,36 @@ - - - - - - + + + + + + + + + + + - - - + + + - - - + + + - - - + + + + + + diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..155c6441 --- /dev/null +++ b/biome.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all", + "semicolons": "always" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": { + "noSvgWithoutTitle": "warn", + "noLabelWithoutControl": "warn", + "useButtonType": "warn" + }, + "suspicious": { + "noExplicitAny": "warn", + "noArrayIndexKey": "warn" + } + } + }, + "files": { + "includes": ["modules/**", "packages/**", "template/**", "!**/wwwroot/**"] + } +} diff --git a/src/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs similarity index 100% rename from src/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs rename to cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs diff --git a/src/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs similarity index 100% rename from src/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs rename to cli/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs diff --git a/src/SimpleModule.Cli/Commands/Doctor/Checks/ModulePatternCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ModulePatternCheck.cs similarity index 82% rename from src/SimpleModule.Cli/Commands/Doctor/Checks/ModulePatternCheck.cs rename to cli/SimpleModule.Cli/Commands/Doctor/Checks/ModulePatternCheck.cs index 7fa8629f..3cd474a0 100644 --- a/src/SimpleModule.Cli/Commands/Doctor/Checks/ModulePatternCheck.cs +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ModulePatternCheck.cs @@ -33,17 +33,17 @@ public IEnumerable Run(SolutionContext solution) ); } - var featuresDir = Path.Combine(moduleDir, "Features"); - yield return Directory.Exists(featuresDir) + var endpointsDir = Path.Combine(moduleDir, "Endpoints"); + yield return Directory.Exists(endpointsDir) ? new CheckResult( - $"{module}/Features/", + $"{module}/Endpoints/", CheckStatus.Pass, - "Features directory exists" + "Endpoints directory exists" ) : new CheckResult( - $"{module}/Features/", + $"{module}/Endpoints/", CheckStatus.Warning, - "Features directory missing" + "Endpoints directory missing" ); } } diff --git a/src/SimpleModule.Cli/Commands/Doctor/Checks/ProjectReferenceCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ProjectReferenceCheck.cs similarity index 94% rename from src/SimpleModule.Cli/Commands/Doctor/Checks/ProjectReferenceCheck.cs rename to cli/SimpleModule.Cli/Commands/Doctor/Checks/ProjectReferenceCheck.cs index 7c2e64e5..18623469 100644 --- a/src/SimpleModule.Cli/Commands/Doctor/Checks/ProjectReferenceCheck.cs +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ProjectReferenceCheck.cs @@ -11,7 +11,7 @@ public IEnumerable Run(SolutionContext solution) yield return new CheckResult( "API csproj", CheckStatus.Fail, - "SimpleModule.Api.csproj not found" + "SimpleModule.Host.csproj not found" ); yield break; } diff --git a/src/SimpleModule.Cli/Commands/Doctor/Checks/SlnxEntriesCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/SlnxEntriesCheck.cs similarity index 100% rename from src/SimpleModule.Cli/Commands/Doctor/Checks/SlnxEntriesCheck.cs rename to cli/SimpleModule.Cli/Commands/Doctor/Checks/SlnxEntriesCheck.cs diff --git a/src/SimpleModule.Cli/Commands/Doctor/Checks/SolutionStructureCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/SolutionStructureCheck.cs similarity index 100% rename from src/SimpleModule.Cli/Commands/Doctor/Checks/SolutionStructureCheck.cs rename to cli/SimpleModule.Cli/Commands/Doctor/Checks/SolutionStructureCheck.cs diff --git a/src/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs b/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs similarity index 100% rename from src/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs rename to cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs diff --git a/src/SimpleModule.Cli/Commands/Doctor/DoctorSettings.cs b/cli/SimpleModule.Cli/Commands/Doctor/DoctorSettings.cs similarity index 100% rename from src/SimpleModule.Cli/Commands/Doctor/DoctorSettings.cs rename to cli/SimpleModule.Cli/Commands/Doctor/DoctorSettings.cs diff --git a/src/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs similarity index 58% rename from src/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs rename to cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs index 431b2f37..d9697363 100644 --- a/src/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs @@ -1,4 +1,4 @@ -using SimpleModule.Cli.Infrastructure; +using SimpleModule.Cli.Infrastructure; using SimpleModule.Cli.Templates; using Spectre.Console; using Spectre.Console.Cli; @@ -35,15 +35,15 @@ public override int Execute(CommandContext context, NewFeatureSettings settings) var templates = new FeatureTemplates(solution); - var featureDir = Path.Combine( + var endpointsDir = Path.Combine( solution.GetModuleProjectPath(moduleName), - "Features", - featureName + "Endpoints", + moduleName ); - Directory.CreateDirectory(featureDir); + Directory.CreateDirectory(endpointsDir); - // Create endpoint - var endpointPath = Path.Combine(featureDir, $"{featureName}Endpoint.cs"); + // Create endpoint (implements IEndpoint — auto-discovered by source generator) + var endpointPath = Path.Combine(endpointsDir, $"{featureName}Endpoint.cs"); File.WriteAllText( endpointPath, templates.Endpoint(moduleName, featureName, httpMethod, route, singularName) @@ -53,7 +53,7 @@ public override int Execute(CommandContext context, NewFeatureSettings settings) // Create validator if requested if (includeValidator) { - var validatorPath = Path.Combine(featureDir, $"{featureName}RequestValidator.cs"); + var validatorPath = Path.Combine(endpointsDir, $"{featureName}RequestValidator.cs"); File.WriteAllText( validatorPath, templates.Validator(moduleName, featureName, singularName) @@ -61,29 +61,9 @@ public override int Execute(CommandContext context, NewFeatureSettings settings) AnsiConsole.MarkupLine($"[green] + {featureName}RequestValidator.cs[/]"); } - // Wire into module class - var moduleFilePath = Path.Combine( - solution.GetModuleProjectPath(moduleName), - $"{moduleName}Module.cs" + AnsiConsole.MarkupLine( + $"[green]Endpoint '{featureName}' added to '{moduleName}' (auto-discovered via IEndpoint).[/]" ); - if (ModuleClassManipulator.AddFeatureWiring(moduleFilePath, moduleName, featureName)) - { - AnsiConsole.MarkupLine( - $"[green] + Wired {featureName}Endpoint into {moduleName}Module.cs[/]" - ); - } - else - { - AnsiConsole.MarkupLine( - $"[yellow] ! Could not auto-wire into {moduleName}Module.cs. Add manually:[/]" - ); - AnsiConsole.MarkupLine( - $"[yellow] using SimpleModule.{moduleName}.Features.{featureName};[/]" - ); - AnsiConsole.MarkupLine($"[yellow] {featureName}Endpoint.Map(group);[/]"); - } - - AnsiConsole.MarkupLine($"[green]Feature '{featureName}' added to '{moduleName}'.[/]"); return 0; } } diff --git a/src/SimpleModule.Cli/Commands/New/NewFeatureSettings.cs b/cli/SimpleModule.Cli/Commands/New/NewFeatureSettings.cs similarity index 100% rename from src/SimpleModule.Cli/Commands/New/NewFeatureSettings.cs rename to cli/SimpleModule.Cli/Commands/New/NewFeatureSettings.cs diff --git a/src/SimpleModule.Cli/Commands/New/NewModuleCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs similarity index 96% rename from src/SimpleModule.Cli/Commands/New/NewModuleCommand.cs rename to cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs index c49168a8..ab9ccdfd 100644 --- a/src/SimpleModule.Cli/Commands/New/NewModuleCommand.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs @@ -37,13 +37,13 @@ public override int Execute(CommandContext context, NewModuleSettings settings) var contractsDir = solution.GetModuleContractsPath(moduleName); var moduleDir = solution.GetModuleProjectPath(moduleName); var eventsDir = Path.Combine(contractsDir, "Events"); - var featuresDir = Path.Combine(moduleDir, "Features", $"GetAll{moduleName}"); + var endpointsDir = Path.Combine(moduleDir, "Endpoints", moduleName); var testDir = solution.GetTestProjectPath(moduleName); var unitTestDir = Path.Combine(testDir, "Unit"); var integrationTestDir = Path.Combine(testDir, "Integration"); Directory.CreateDirectory(eventsDir); - Directory.CreateDirectory(featuresDir); + Directory.CreateDirectory(endpointsDir); Directory.CreateDirectory(unitTestDir); Directory.CreateDirectory(integrationTestDir); @@ -87,7 +87,7 @@ public override int Execute(CommandContext context, NewModuleSettings settings) templates.ServiceClass(moduleName, singularName) ); WriteFile( - Path.Combine(featuresDir, $"GetAll{moduleName}Endpoint.cs"), + Path.Combine(endpointsDir, "GetAllEndpoint.cs"), templates.GetAllEndpoint(moduleName, singularName) ); diff --git a/src/SimpleModule.Cli/Commands/New/NewModuleSettings.cs b/cli/SimpleModule.Cli/Commands/New/NewModuleSettings.cs similarity index 100% rename from src/SimpleModule.Cli/Commands/New/NewModuleSettings.cs rename to cli/SimpleModule.Cli/Commands/New/NewModuleSettings.cs diff --git a/src/SimpleModule.Cli/Commands/New/NewProjectCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs similarity index 100% rename from src/SimpleModule.Cli/Commands/New/NewProjectCommand.cs rename to cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs diff --git a/src/SimpleModule.Cli/Commands/New/NewProjectSettings.cs b/cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs similarity index 100% rename from src/SimpleModule.Cli/Commands/New/NewProjectSettings.cs rename to cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs diff --git a/src/SimpleModule.Cli/Infrastructure/ModuleClassManipulator.cs b/cli/SimpleModule.Cli/Infrastructure/ModuleClassManipulator.cs similarity index 100% rename from src/SimpleModule.Cli/Infrastructure/ModuleClassManipulator.cs rename to cli/SimpleModule.Cli/Infrastructure/ModuleClassManipulator.cs diff --git a/src/SimpleModule.Cli/Infrastructure/ProjectManipulator.cs b/cli/SimpleModule.Cli/Infrastructure/ProjectManipulator.cs similarity index 100% rename from src/SimpleModule.Cli/Infrastructure/ProjectManipulator.cs rename to cli/SimpleModule.Cli/Infrastructure/ProjectManipulator.cs diff --git a/src/SimpleModule.Cli/Infrastructure/SlnxManipulator.cs b/cli/SimpleModule.Cli/Infrastructure/SlnxManipulator.cs similarity index 100% rename from src/SimpleModule.Cli/Infrastructure/SlnxManipulator.cs rename to cli/SimpleModule.Cli/Infrastructure/SlnxManipulator.cs diff --git a/src/SimpleModule.Cli/Infrastructure/SolutionContext.cs b/cli/SimpleModule.Cli/Infrastructure/SolutionContext.cs similarity index 96% rename from src/SimpleModule.Cli/Infrastructure/SolutionContext.cs rename to cli/SimpleModule.Cli/Infrastructure/SolutionContext.cs index d8d20422..f05ac4cf 100644 --- a/src/SimpleModule.Cli/Infrastructure/SolutionContext.cs +++ b/cli/SimpleModule.Cli/Infrastructure/SolutionContext.cs @@ -16,8 +16,8 @@ private SolutionContext(string rootPath, string slnxPath) ApiCsprojPath = Path.Combine( rootPath, "src", - "SimpleModule.Api", - "SimpleModule.Api.csproj" + "SimpleModule.Host", + "SimpleModule.Host.csproj" ); ModulesPath = Path.Combine(rootPath, "src", "modules"); diff --git a/src/SimpleModule.Cli/Infrastructure/TemplateExtractor.cs b/cli/SimpleModule.Cli/Infrastructure/TemplateExtractor.cs similarity index 100% rename from src/SimpleModule.Cli/Infrastructure/TemplateExtractor.cs rename to cli/SimpleModule.Cli/Infrastructure/TemplateExtractor.cs diff --git a/src/SimpleModule.Cli/Program.cs b/cli/SimpleModule.Cli/Program.cs similarity index 100% rename from src/SimpleModule.Cli/Program.cs rename to cli/SimpleModule.Cli/Program.cs diff --git a/src/SimpleModule.Cli/SimpleModule.Cli.csproj b/cli/SimpleModule.Cli/SimpleModule.Cli.csproj similarity index 100% rename from src/SimpleModule.Cli/SimpleModule.Cli.csproj rename to cli/SimpleModule.Cli/SimpleModule.Cli.csproj diff --git a/src/SimpleModule.Cli/Templates/FeatureTemplates.cs b/cli/SimpleModule.Cli/Templates/FeatureTemplates.cs similarity index 89% rename from src/SimpleModule.Cli/Templates/FeatureTemplates.cs rename to cli/SimpleModule.Cli/Templates/FeatureTemplates.cs index 11f5d2df..41f80e53 100644 --- a/src/SimpleModule.Cli/Templates/FeatureTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/FeatureTemplates.cs @@ -28,9 +28,9 @@ string singularName { var refPath = Path.Combine( _solution.GetModuleProjectPath(_refModule), - "Features", - $"GetAll{_refModule}", - $"GetAll{_refModule}Endpoint.cs" + "Endpoints", + _refModule, + "GetAllEndpoint.cs" ); if (File.Exists(refPath)) @@ -95,16 +95,16 @@ string singularName singularName ); - // Replace the feature folder namespace + // Replace the endpoint namespace content = content.Replace( - $"Features.GetAll{moduleName}", - $"Features.{featureName}", + $"Endpoints.{moduleName}", + $"Endpoints.{moduleName}", StringComparison.Ordinal ); // Replace the class name content = content.Replace( - $"GetAll{moduleName}Endpoint", + "GetAllEndpoint", $"{featureName}Endpoint", StringComparison.Ordinal ); @@ -229,12 +229,22 @@ string singularName { if ( lines[i].Contains("namespace ", StringComparison.Ordinal) - && lines[i].Contains(".Features.", StringComparison.Ordinal) + && lines[i].Contains(".Endpoints.", StringComparison.Ordinal) ) { - // Extract the namespace prefix and replace the feature name - var nsPrefix = lines[i][..lines[i].IndexOf(".Features.", StringComparison.Ordinal)]; - lines[i] = $"{nsPrefix}.Features.{featureName};"; + // Extract the namespace prefix and replace the endpoint namespace + var nsPrefix = lines[i][ + ..lines[i].IndexOf(".Endpoints.", StringComparison.Ordinal) + ]; + var moduleSuffix = lines[i].Contains(';', StringComparison.Ordinal) + ? lines[i][ + ( + lines[i].IndexOf(".Endpoints.", StringComparison.Ordinal) + + ".Endpoints.".Length + )..lines[i].IndexOf(';', StringComparison.Ordinal) + ] + : moduleName; + lines[i] = $"{nsPrefix}.Endpoints.{moduleSuffix};"; } } @@ -316,15 +326,16 @@ string singularName using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; using SimpleModule.{{moduleName}}.Contracts; - namespace SimpleModule.{{moduleName}}.Features.{{featureName}}; + namespace SimpleModule.{{moduleName}}.Endpoints.{{moduleName}}; - public static class {{featureName}}Endpoint + public class {{featureName}}Endpoint : IEndpoint { - public static void Map(IEndpointRouteBuilder group) + public void Map(IEndpointRouteBuilder app) { - group.{{mapMethod}}( + app.{{mapMethod}}( "{{route}}", async (I{{singularName}}Contracts contracts) => { @@ -346,7 +357,7 @@ string singularName using SimpleModule.Core.Validation; using SimpleModule.{{moduleName}}.Contracts; - namespace SimpleModule.{{moduleName}}.Features.{{featureName}}; + namespace SimpleModule.{{moduleName}}.Endpoints.{{moduleName}}; public static class {{featureName}}RequestValidator { diff --git a/src/SimpleModule.Cli/Templates/ModuleTemplates.cs b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs similarity index 95% rename from src/SimpleModule.Cli/Templates/ModuleTemplates.cs rename to cli/SimpleModule.Cli/Templates/ModuleTemplates.cs index 7db6fedb..52c0925f 100644 --- a/src/SimpleModule.Cli/Templates/ModuleTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs @@ -156,9 +156,7 @@ public string EventClass(string moduleName, string singularName) public string GetAllEndpoint(string moduleName, string singularName) { - var refPath = RefModulePath( - Path.Combine("Features", $"GetAll{_refModule}", $"GetAll{_refModule}Endpoint.cs") - ); + var refPath = RefModulePath(Path.Combine("Endpoints", _refModule!, $"GetAllEndpoint.cs")); if (refPath is null) { return FallbackGetAllEndpoint(moduleName, singularName); @@ -251,29 +249,12 @@ public string ModuleClass(string moduleName, string singularName) return FallbackModuleClass(moduleName, singularName); } - // Strip using directives and Map calls for features other than GetAll - var stripPatterns = new List(); var lines = File.ReadAllLines(refPath).ToList(); - foreach (var line in lines) - { - // Detect feature using lines like "using SimpleModule.Orders.Features.CreateOrder;" - if ( - line.Contains($".Features.", StringComparison.Ordinal) - && !line.Contains($".Features.GetAll", StringComparison.Ordinal) - ) - { - stripPatterns.Add(line.Trim()); - } - } - - // Strip extra feature using lines - lines.RemoveAll(line => stripPatterns.Any(p => line.Contains(p, StringComparison.Ordinal))); - - // Strip extra Map calls (keep only GetAll) + // Strip using directives for Endpoints (auto-discovered, not needed in module) lines.RemoveAll(line => - line.Contains(".Map(group);", StringComparison.Ordinal) - && !line.Contains($"GetAll", StringComparison.Ordinal) + line.Contains($".Endpoints.", StringComparison.Ordinal) + && line.TrimStart().StartsWith("using ", StringComparison.Ordinal) ); lines = TemplateExtractor.CollapseBlankLines(lines); @@ -934,18 +915,15 @@ public sealed record {{singularName}}CreatedEvent(int {{singularName}}Id) : IEve private static string FallbackModuleClass(string moduleName, string singularName) => $$""" - using Microsoft.AspNetCore.Builder; - using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Core; using SimpleModule.Database; using SimpleModule.{{moduleName}}.Contracts; - using SimpleModule.{{moduleName}}.Features.GetAll{{moduleName}}; namespace SimpleModule.{{moduleName}}; - [Module({{moduleName}}Constants.ModuleName)] + [Module({{moduleName}}Constants.ModuleName, RoutePrefix = {{moduleName}}Constants.RoutePrefix)] public class {{moduleName}}Module : IModule { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) @@ -953,12 +931,6 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddModuleDbContext<{{moduleName}}DbContext>(configuration, {{moduleName}}Constants.ModuleName); services.AddScoped(); } - - public void ConfigureEndpoints(IEndpointRouteBuilder endpoints) - { - var group = endpoints.MapGroup({{moduleName}}Constants.RoutePrefix); - GetAll{{moduleName}}Endpoint.Map(group); - } } """; @@ -1030,15 +1002,16 @@ private static string FallbackGetAllEndpoint(string moduleName, string singularN using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; using SimpleModule.{{moduleName}}.Contracts; - namespace SimpleModule.{{moduleName}}.Features.GetAll{{moduleName}}; + namespace SimpleModule.{{moduleName}}.Endpoints.{{moduleName}}; - public static class GetAll{{moduleName}}Endpoint + public class GetAllEndpoint : IEndpoint { - public static void Map(IEndpointRouteBuilder group) + public void Map(IEndpointRouteBuilder app) { - group.MapGet( + app.MapGet( "/", async (I{{singularName}}Contracts contracts) => { diff --git a/src/SimpleModule.Cli/Templates/ProjectTemplates.cs b/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs similarity index 99% rename from src/SimpleModule.Cli/Templates/ProjectTemplates.cs rename to cli/SimpleModule.Cli/Templates/ProjectTemplates.cs index 43030ed6..5e64c8cf 100644 --- a/src/SimpleModule.Cli/Templates/ProjectTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs @@ -30,9 +30,7 @@ public string Slnx(string projectName) ); // Strip /tests/modules/ folder entirely (module tests are now inside each module) - lines.RemoveAll(line => - line.Contains("/tests/modules/", StringComparison.Ordinal) - ); + lines.RemoveAll(line => line.Contains("/tests/modules/", StringComparison.Ordinal)); // Remove CLI project entry lines.RemoveAll(line => line.Contains("SimpleModule.Cli", StringComparison.Ordinal)); diff --git a/docs/plans/2026-03-11-user-role-management-design.md b/docs/plans/2026-03-11-user-role-management-design.md new file mode 100644 index 00000000..f7deac40 --- /dev/null +++ b/docs/plans/2026-03-11-user-role-management-design.md @@ -0,0 +1,62 @@ +# User & Role Management Admin UI + +## Overview + +React-based admin panel for managing users and roles, served via Inertia protocol from the Users module. All endpoints gated with `RequireRole("Admin")`. + +## Pages + +### Users +- **Admin/Users** — Paginated table with search by name/email. Columns: display name, email, roles, email confirmed, locked out, created date. Actions: edit, lock/unlock. +- **Admin/Users/Edit** — Edit form: display name, email, email confirmed toggle, role assignment (multi-select), lock/unlock account. + +### Roles +- **Admin/Roles** — Table listing all roles with description, user count, created date. Actions: edit, delete. +- **Admin/Roles/Create** — Form: name, description. +- **Admin/Roles/Edit** — Form: name, description. Shows assigned users. + +## API Endpoints + +All under `/admin`, require Admin role. + +``` +GET /admin/users → Inertia: Admin/Users (paginated, ?search=&page=) +GET /admin/users/{id}/edit → Inertia: Admin/Users/Edit +POST /admin/users/{id} → Update user (display name, email, emailConfirmed) +POST /admin/users/{id}/roles → Set roles (replaces all) +POST /admin/users/{id}/lock → Lock account +POST /admin/users/{id}/unlock → Unlock account + +GET /admin/roles → Inertia: Admin/Roles +GET /admin/roles/create → Inertia: Admin/Roles/Create +POST /admin/roles → Create role +GET /admin/roles/{id}/edit → Inertia: Admin/Roles/Edit +POST /admin/roles/{id} → Update role +DELETE /admin/roles/{id} → Delete role (fails if users assigned) +``` + +## Data Flow + +Endpoints use `UserManager` and `RoleManager` directly. Props passed to React via `Inertia.Render()`. Form submissions POST back, then redirect via Inertia. + +## File Changes + +| Action | File | +|--------|------| +| New | `Users/Pages/Admin/Users.tsx` | +| New | `Users/Pages/Admin/UsersEdit.tsx` | +| New | `Users/Pages/Admin/Roles.tsx` | +| New | `Users/Pages/Admin/RolesCreate.tsx` | +| New | `Users/Pages/Admin/RolesEdit.tsx` | +| New | `Users/Pages/index.ts` | +| New | `Users/Features/Admin/AdminUsersEndpoint.cs` | +| New | `Users/Features/Admin/AdminRolesEndpoint.cs` | +| Modify | `UsersModule.cs` — register admin endpoints | +| Modify | `Users/vite.config.ts` — add pages build | +| Modify | `Users/package.json` — add react peer deps | + +## Decisions + +- No new contracts/DTOs — admin endpoints use UserManager/RoleManager directly with anonymous objects for Inertia props. +- Existing Blazor self-service pages (login, register, manage profile, 2FA) remain untouched. +- Admin role + seed user already exist via OpenIddictSeedService. diff --git a/docs/plans/2026-03-11-user-role-management.md b/docs/plans/2026-03-11-user-role-management.md new file mode 100644 index 00000000..dbf37a9c --- /dev/null +++ b/docs/plans/2026-03-11-user-role-management.md @@ -0,0 +1,1185 @@ +# User & Role Management Admin UI Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build React admin pages for managing users (list, edit, lock/unlock, role assignment) and roles (CRUD), served via Inertia from the Users module, gated with Admin role. + +**Architecture:** Admin endpoints in `Features/Admin/` using `UserManager`/`RoleManager` directly. React pages in `Pages/Admin/`. Inertia protocol handles server→client data passing. Form submissions POST back to endpoints which redirect via Inertia. + +**Tech Stack:** ASP.NET Core Identity, Inertia.js, React 19, Vite, Tailwind CSS + +--- + +### Task 1: Users module Vite config for React pages + +The Users module already has a Vite config for building `Scripts/index.ts` → `Users.lib.module.js`. We need a second build for React pages → `Users.pages.js`. + +**Files:** +- Modify: `src/modules/Users/src/Users/vite.config.ts` +- Modify: `src/modules/Users/src/Users/package.json` +- Modify: `src/modules/Users/src/Users/Users.csproj` + +**Step 1: Update vite.config.ts to support two builds** + +Replace `src/modules/Users/src/Users/vite.config.ts` with: + +```ts +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +const buildTarget = process.env.VITE_BUILD_TARGET; + +const libConfig = defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'Scripts/index.ts'), + formats: ['es'], + fileName: () => 'Users.lib.module.js', + }, + outDir: 'wwwroot', + emptyOutDir: false, + rollupOptions: { + output: { inlineDynamicImports: true }, + }, + }, +}); + +const pagesConfig = defineConfig({ + plugins: [react()], + build: { + lib: { + entry: resolve(__dirname, 'Pages/index.ts'), + formats: ['es'], + fileName: () => 'Users.pages.js', + }, + outDir: 'wwwroot', + emptyOutDir: false, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime'], + output: { inlineDynamicImports: true }, + }, + }, +}); + +export default buildTarget === 'pages' ? pagesConfig : libConfig; +``` + +**Step 2: Update package.json to add react peer deps and dual build script** + +Replace `src/modules/Users/src/Users/package.json` with: + +```json +{ + "private": true, + "name": "@simplemodule/users", + "scripts": { + "build": "vite build && cross-env VITE_BUILD_TARGET=pages vite build", + "build:lib": "vite build", + "build:pages": "cross-env VITE_BUILD_TARGET=pages vite build", + "watch": "cross-env VITE_BUILD_TARGET=pages vite build --watch" + }, + "dependencies": { + "qrcode": "^1.5.4" + }, + "devDependencies": { + "@types/qrcode": "^1.5.6" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} +``` + +**Step 3: Update Users.csproj JsBuild target to run both builds** + +In `src/modules/Users/src/Users/Users.csproj`, update the JsBuild target Exec command: + +```xml + +``` + +**Step 4: Install cross-env in root devDependencies** + +Run: `npm install -D cross-env` + +**Step 5: Verify builds** + +Run from `src/modules/Users/src/Users/`: +```bash +npx vite build +VITE_BUILD_TARGET=pages npx vite build +``` +Expected: `wwwroot/Users.lib.module.js` and `wwwroot/Users.pages.js` both exist. + +**Step 6: Commit** + +```bash +git add src/modules/Users/src/Users/vite.config.ts src/modules/Users/src/Users/package.json src/modules/Users/src/Users/Users.csproj package.json package-lock.json +git commit -m "feat(users): configure dual Vite builds for lib + React pages" +``` + +--- + +### Task 2: Admin Users endpoint (C# backend) + +**Files:** +- Create: `src/modules/Users/src/Users/Features/Admin/AdminUsersEndpoint.cs` +- Modify: `src/modules/Users/src/Users/UsersModule.cs` + +**Step 1: Create AdminUsersEndpoint.cs** + +Create `src/modules/Users/src/Users/Features/Admin/AdminUsersEndpoint.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core.Inertia; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Users.Features.Admin; + +public static class AdminUsersEndpoint +{ + private const int PageSize = 20; + + public static void Map(IEndpointRouteBuilder endpoints) + { + var group = endpoints + .MapGroup("/admin/users") + .RequireAuthorization(policy => policy.RequireRole("Admin")); + + // List users + group.MapGet("/", async ( + UserManager userManager, + RoleManager roleManager, + string? search, + int page = 1) => + { + var query = userManager.Users.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim().ToLower(); + query = query.Where(u => + (u.Email != null && u.Email.ToLower().Contains(term)) + || u.DisplayName.ToLower().Contains(term) + || (u.UserName != null && u.UserName.ToLower().Contains(term))); + } + + var totalCount = await query.CountAsync(); + var totalPages = (int)Math.Ceiling((double)totalCount / PageSize); + page = Math.Clamp(page, 1, Math.Max(1, totalPages)); + + var users = await query + .OrderBy(u => u.DisplayName) + .Skip((page - 1) * PageSize) + .Take(PageSize) + .ToListAsync(); + + var userList = new List(); + foreach (var user in users) + { + var roles = await userManager.GetRolesAsync(user); + userList.Add(new + { + id = user.Id, + displayName = user.DisplayName, + email = user.Email, + emailConfirmed = user.EmailConfirmed, + roles = roles.ToList(), + isLockedOut = user.LockoutEnd.HasValue && user.LockoutEnd > DateTimeOffset.UtcNow, + createdAt = user.CreatedAt.ToString("O"), + }); + } + + return Inertia.Render("Users/Admin/Users", new + { + users = userList, + search = search ?? "", + page, + totalPages, + totalCount, + }); + }); + + // Edit user page + group.MapGet("/{id}/edit", async ( + string id, + UserManager userManager, + RoleManager roleManager) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + var userRoles = await userManager.GetRolesAsync(user); + var allRoles = await roleManager.Roles.OrderBy(r => r.Name).ToListAsync(); + + return Inertia.Render("Users/Admin/UsersEdit", new + { + user = new + { + id = user.Id, + displayName = user.DisplayName, + email = user.Email, + emailConfirmed = user.EmailConfirmed, + isLockedOut = user.LockoutEnd.HasValue && user.LockoutEnd > DateTimeOffset.UtcNow, + createdAt = user.CreatedAt.ToString("O"), + lastLoginAt = user.LastLoginAt?.ToString("O"), + }, + userRoles = userRoles.ToList(), + allRoles = allRoles.Select(r => new { id = r.Id, name = r.Name, description = r.Description }).ToList(), + }); + }); + + // Update user + group.MapPost("/{id}", async ( + string id, + HttpContext context, + UserManager userManager) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + var form = await context.Request.ReadFormAsync(); + user.DisplayName = form["displayName"].ToString(); + user.Email = form["email"].ToString(); + user.EmailConfirmed = form.ContainsKey("emailConfirmed"); + + await userManager.UpdateAsync(user); + + return Results.Redirect($"/admin/users/{id}/edit"); + }); + + // Set roles + group.MapPost("/{id}/roles", async ( + string id, + HttpContext context, + UserManager userManager) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + var form = await context.Request.ReadFormAsync(); + var newRoles = form["roles"].ToArray().Where(r => !string.IsNullOrEmpty(r)).ToList(); + var currentRoles = await userManager.GetRolesAsync(user); + + await userManager.RemoveFromRolesAsync(user, currentRoles); + if (newRoles.Count > 0) + await userManager.AddToRolesAsync(user, newRoles!); + + return Results.Redirect($"/admin/users/{id}/edit"); + }); + + // Lock account + group.MapPost("/{id}/lock", async ( + string id, + UserManager userManager) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + await userManager.SetLockoutEnabledAsync(user, true); + await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); + + return Results.Redirect($"/admin/users/{id}/edit"); + }); + + // Unlock account + group.MapPost("/{id}/unlock", async ( + string id, + UserManager userManager) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + await userManager.SetLockoutEndDateAsync(user, null); + await userManager.ResetAccessFailedCountAsync(user); + + return Results.Redirect($"/admin/users/{id}/edit"); + }); + } +} +``` + +**Step 2: Register in UsersModule.cs** + +Add to the using block: +```csharp +using SimpleModule.Users.Features.Admin; +``` + +Add at the end of `ConfigureEndpoints`, before the closing brace: +```csharp + // Admin endpoints + AdminUsersEndpoint.Map(endpoints); +``` + +**Step 3: Verify dotnet build succeeds** + +Run: `dotnet build` +Expected: 0 errors + +**Step 4: Commit** + +```bash +git add src/modules/Users/src/Users/Features/Admin/AdminUsersEndpoint.cs src/modules/Users/src/Users/UsersModule.cs +git commit -m "feat(users): add admin users management endpoints" +``` + +--- + +### Task 3: Admin Roles endpoint (C# backend) + +**Files:** +- Create: `src/modules/Users/src/Users/Features/Admin/AdminRolesEndpoint.cs` +- Modify: `src/modules/Users/src/Users/UsersModule.cs` + +**Step 1: Create AdminRolesEndpoint.cs** + +Create `src/modules/Users/src/Users/Features/Admin/AdminRolesEndpoint.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core.Inertia; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Users.Features.Admin; + +public static class AdminRolesEndpoint +{ + public static void Map(IEndpointRouteBuilder endpoints) + { + var group = endpoints + .MapGroup("/admin/roles") + .RequireAuthorization(policy => policy.RequireRole("Admin")); + + // List roles + group.MapGet("/", async ( + RoleManager roleManager, + UserManager userManager) => + { + var roles = await roleManager.Roles + .OrderBy(r => r.Name) + .ToListAsync(); + + var roleList = new List(); + foreach (var role in roles) + { + var usersInRole = role.Name is not null + ? await userManager.GetUsersInRoleAsync(role.Name) + : []; + roleList.Add(new + { + id = role.Id, + name = role.Name, + description = role.Description, + userCount = usersInRole.Count, + createdAt = role.CreatedAt.ToString("O"), + }); + } + + return Inertia.Render("Users/Admin/Roles", new { roles = roleList }); + }); + + // Create role page + group.MapGet("/create", () => + Inertia.Render("Users/Admin/RolesCreate")); + + // Create role + group.MapPost("/", async ( + HttpContext context, + RoleManager roleManager) => + { + var form = await context.Request.ReadFormAsync(); + var name = form["name"].ToString().Trim(); + var description = form["description"].ToString().Trim(); + + if (string.IsNullOrEmpty(name)) + return Results.Redirect("/admin/roles/create"); + + var role = new ApplicationRole + { + Name = name, + Description = string.IsNullOrEmpty(description) ? null : description, + }; + + var result = await roleManager.CreateAsync(role); + if (!result.Succeeded) + return Results.Redirect("/admin/roles/create"); + + return Results.Redirect("/admin/roles"); + }); + + // Edit role page + group.MapGet("/{id}/edit", async ( + string id, + RoleManager roleManager, + UserManager userManager) => + { + var role = await roleManager.FindByIdAsync(id); + if (role is null) + return Results.NotFound(); + + var usersInRole = role.Name is not null + ? await userManager.GetUsersInRoleAsync(role.Name) + : []; + + return Inertia.Render("Users/Admin/RolesEdit", new + { + role = new + { + id = role.Id, + name = role.Name, + description = role.Description, + createdAt = role.CreatedAt.ToString("O"), + }, + users = usersInRole.Select(u => new + { + id = u.Id, + displayName = u.DisplayName, + email = u.Email, + }).ToList(), + }); + }); + + // Update role + group.MapPost("/{id}", async ( + string id, + HttpContext context, + RoleManager roleManager) => + { + var role = await roleManager.FindByIdAsync(id); + if (role is null) + return Results.NotFound(); + + var form = await context.Request.ReadFormAsync(); + role.Name = form["name"].ToString().Trim(); + var description = form["description"].ToString().Trim(); + role.Description = string.IsNullOrEmpty(description) ? null : description; + + await roleManager.UpdateAsync(role); + + return Results.Redirect($"/admin/roles/{id}/edit"); + }); + + // Delete role + group.MapDelete("/{id}", async ( + string id, + RoleManager roleManager, + UserManager userManager) => + { + var role = await roleManager.FindByIdAsync(id); + if (role is null) + return Results.NotFound(); + + // Don't delete if users are assigned + var usersInRole = role.Name is not null + ? await userManager.GetUsersInRoleAsync(role.Name) + : []; + if (usersInRole.Count > 0) + return Results.BadRequest(new { error = "Cannot delete role with assigned users" }); + + await roleManager.DeleteAsync(role); + + return Results.Ok(); + }); + } +} +``` + +**Step 2: Register in UsersModule.cs** + +Add after `AdminUsersEndpoint.Map(endpoints);`: +```csharp + AdminRolesEndpoint.Map(endpoints); +``` + +**Step 3: Verify dotnet build succeeds** + +Run: `dotnet build` +Expected: 0 errors + +**Step 4: Commit** + +```bash +git add src/modules/Users/src/Users/Features/Admin/AdminRolesEndpoint.cs src/modules/Users/src/Users/UsersModule.cs +git commit -m "feat(users): add admin roles management endpoints" +``` + +--- + +### Task 4: React pages — Users list and Users edit + +**Files:** +- Create: `src/modules/Users/src/Users/Pages/Admin/Users.tsx` +- Create: `src/modules/Users/src/Users/Pages/Admin/UsersEdit.tsx` + +**Step 1: Create Users list page** + +Create `src/modules/Users/src/Users/Pages/Admin/Users.tsx`: + +```tsx +import { router } from '@inertiajs/react'; +import { useState, FormEvent } from 'react'; + +interface User { + id: string; + displayName: string; + email: string; + emailConfirmed: boolean; + roles: string[]; + isLockedOut: boolean; + createdAt: string; +} + +interface Props { + users: User[]; + search: string; + page: number; + totalPages: number; + totalCount: number; +} + +export default function Users({ users, search, page, totalPages, totalCount }: Props) { + const [searchValue, setSearchValue] = useState(search); + + function handleSearch(e: FormEvent) { + e.preventDefault(); + router.get('/admin/users', { search: searchValue, page: 1 }, { preserveState: true }); + } + + function goToPage(p: number) { + router.get('/admin/users', { search: searchValue, page: p }, { preserveState: true }); + } + + return ( +
+
+

Users

+ {totalCount} total +
+ +
+ setSearchValue(e.target.value)} + placeholder="Search by name or email..." + className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+ +
+ + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + ))} + +
NameEmailRolesStatusCreated
{user.displayName || '—'} + {user.email} + {!user.emailConfirmed && ( + unverified + )} + +
+ {user.roles.map((role) => ( + + {role} + + ))} +
+
+ {user.isLockedOut ? ( + Locked + ) : ( + Active + )} + + {new Date(user.createdAt).toLocaleDateString()} + + +
+
+ + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ ); +} +``` + +**Step 2: Create Users edit page** + +Create `src/modules/Users/src/Users/Pages/Admin/UsersEdit.tsx`: + +```tsx +import { router } from '@inertiajs/react'; + +interface UserDetail { + id: string; + displayName: string; + email: string; + emailConfirmed: boolean; + isLockedOut: boolean; + createdAt: string; + lastLoginAt: string | null; +} + +interface Role { + id: string; + name: string; + description: string | null; +} + +interface Props { + user: UserDetail; + userRoles: string[]; + allRoles: Role[]; +} + +export default function UsersEdit({ user, userRoles, allRoles }: Props) { + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + router.post(`/admin/users/${user.id}`, formData); + } + + function handleRolesSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + router.post(`/admin/users/${user.id}/roles`, formData); + } + + function handleLock() { + router.post(`/admin/users/${user.id}/lock`); + } + + function handleUnlock() { + router.post(`/admin/users/${user.id}/unlock`); + } + + return ( +
+
+ +

Edit User

+
+ +
+ Created: {new Date(user.createdAt).toLocaleString()} + {user.lastLoginAt && ( + Last login: {new Date(user.lastLoginAt).toLocaleString()} + )} +
+ + {/* User Details Form */} +
+

Details

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + {/* Roles Form */} +
+

Roles

+
+ {allRoles.map((role) => ( +
+ + +
+ ))} + {allRoles.length === 0 && ( +

No roles defined.

+ )} +
+ +
+ + {/* Lock/Unlock */} +
+

Account Status

+ {user.isLockedOut ? ( +
+

This account is locked.

+ +
+ ) : ( +
+

This account is active.

+ +
+ )} +
+
+ ); +} +``` + +**Step 3: Verify TypeScript compiles** + +Run from repo root: `npx tsc --noEmit` +Expected: No errors + +**Step 4: Commit** + +```bash +git add src/modules/Users/src/Users/Pages/Admin/Users.tsx src/modules/Users/src/Users/Pages/Admin/UsersEdit.tsx +git commit -m "feat(users): add admin users list and edit React pages" +``` + +--- + +### Task 5: React pages — Roles list, create, edit + +**Files:** +- Create: `src/modules/Users/src/Users/Pages/Admin/Roles.tsx` +- Create: `src/modules/Users/src/Users/Pages/Admin/RolesCreate.tsx` +- Create: `src/modules/Users/src/Users/Pages/Admin/RolesEdit.tsx` + +**Step 1: Create Roles list page** + +Create `src/modules/Users/src/Users/Pages/Admin/Roles.tsx`: + +```tsx +import { router } from '@inertiajs/react'; + +interface Role { + id: string; + name: string; + description: string | null; + userCount: number; + createdAt: string; +} + +interface Props { + roles: Role[]; +} + +export default function Roles({ roles }: Props) { + function handleDelete(id: string, name: string) { + if (!confirm(`Delete role "${name}"?`)) return; + router.delete(`/admin/roles/${id}`, { + onError: () => alert('Cannot delete role with assigned users.'), + }); + } + + return ( +
+
+

Roles

+ +
+ +
+ + + + + + + + + + + + {roles.map((role) => ( + + + + + + + + ))} + +
NameDescriptionUsersCreated
{role.name} + {role.description || '—'} + + + {role.userCount} + + + {new Date(role.createdAt).toLocaleDateString()} + +
+ + +
+
+
+
+ ); +} +``` + +**Step 2: Create Roles create page** + +Create `src/modules/Users/src/Users/Pages/Admin/RolesCreate.tsx`: + +```tsx +import { router } from '@inertiajs/react'; + +export default function RolesCreate() { + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + router.post('/admin/roles', formData); + } + + return ( +
+
+ +

Create Role

+
+ +
+
+
+ + +
+
+ + +
+ +
+
+
+ ); +} +``` + +**Step 3: Create Roles edit page** + +Create `src/modules/Users/src/Users/Pages/Admin/RolesEdit.tsx`: + +```tsx +import { router } from '@inertiajs/react'; + +interface RoleDetail { + id: string; + name: string; + description: string | null; + createdAt: string; +} + +interface UserSummary { + id: string; + displayName: string; + email: string; +} + +interface Props { + role: RoleDetail; + users: UserSummary[]; +} + +export default function RolesEdit({ role, users }: Props) { + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + router.post(`/admin/roles/${role.id}`, formData); + } + + return ( +
+
+ +

Edit Role

+
+ +
+
+
+ + +
+
+ + +
+
+ Created: {new Date(role.createdAt).toLocaleString()} +
+ +
+
+ +
+

Assigned Users ({users.length})

+ {users.length === 0 ? ( +

No users assigned to this role.

+ ) : ( +
    + {users.map((user) => ( +
  • +
    + {user.displayName || '—'} + {user.email} +
    + +
  • + ))} +
+ )} +
+
+ ); +} +``` + +**Step 4: Commit** + +```bash +git add src/modules/Users/src/Users/Pages/Admin/ +git commit -m "feat(users): add admin roles list, create, and edit React pages" +``` + +--- + +### Task 6: Page registry and final build verification + +**Files:** +- Create: `src/modules/Users/src/Users/Pages/index.ts` + +**Step 1: Create page registry** + +Create `src/modules/Users/src/Users/Pages/index.ts`: + +```ts +import Users from './Admin/Users'; +import UsersEdit from './Admin/UsersEdit'; +import Roles from './Admin/Roles'; +import RolesCreate from './Admin/RolesCreate'; +import RolesEdit from './Admin/RolesEdit'; + +export const pages: Record = { + 'Users/Admin/Users': Users, + 'Users/Admin/UsersEdit': UsersEdit, + 'Users/Admin/Roles': Roles, + 'Users/Admin/RolesCreate': RolesCreate, + 'Users/Admin/RolesEdit': RolesEdit, +}; +``` + +**Step 2: Run full build** + +```bash +npm install +dotnet build +``` +Expected: 0 errors. Vite builds produce `Users.lib.module.js` and `Users.pages.js` in `wwwroot/`. + +**Step 3: Commit** + +```bash +git add src/modules/Users/src/Users/Pages/index.ts +git commit -m "feat(users): add page registry for admin React pages" +``` + +--- + +## Summary + +| Task | Description | +|------|-------------| +| 1 | Dual Vite config (lib + pages builds) | +| 2 | Admin users endpoints (list, edit, update, roles, lock/unlock) | +| 3 | Admin roles endpoints (list, create, edit, update, delete) | +| 4 | React pages: Users list + Users edit | +| 5 | React pages: Roles list, create, edit | +| 6 | Page registry + final build verification | diff --git a/docs/plans/2026-03-13-future-roadmap.md b/docs/plans/2026-03-13-future-roadmap.md new file mode 100644 index 00000000..29b65aa0 --- /dev/null +++ b/docs/plans/2026-03-13-future-roadmap.md @@ -0,0 +1,237 @@ +# SimpleModule: Future Plans & Problems Roadmap + +## Context + +SimpleModule is a modular monolith framework for .NET with compile-time module discovery via Roslyn source generators. It currently has three working modules (Products, Orders, Users), a React 19 + Inertia.js frontend served via Blazor SSR, and a CI pipeline. The framework is functional but pre-production — several infrastructure gaps need closing before it's ready for real-world use. + +This document catalogs known problems, missing capabilities, and planned improvements, organized into prioritized phases and a full reference catalog. + +--- + +## Phase 1: Foundation (Fix What's Broken) + +These are blocking issues or significant gaps that undermine the framework's value proposition. + +### 1.1 Request Validation +**Problem**: Endpoints manually parse `ReadFormAsync()` with no validation. Invalid input silently produces bad data. +**Plan**: Integrate a validation pipeline (FluentValidation or minimal API filters). Source generator could auto-wire validators discovered by convention. +**Files**: All endpoint classes across modules, Core (new validation abstractions). + +### 1.2 EF Core Migrations +**Problem**: Uses `EnsureCreated()` — no schema versioning, no safe production upgrades. Database changes are untracked. +**Plan**: Add per-module migration support. Each module's DbContext gets its own migration history. CLI (`sm`) should scaffold migrations. +**Files**: SimpleModule.Database, module DbContexts, SimpleModule.Cli. + +### 1.3 Consistent Endpoint Pattern +**Problem**: Products uses both `IEndpoint` classes AND `ConfigureEndpoints()` escape hatch simultaneously. Confusing for framework users. +**Plan**: Standardize on `IEndpoint` for all CRUD. Reserve `ConfigureEndpoints()` strictly for non-standard routes (WebSocket, file upload, etc.). Migrate Products module. +**Files**: Products module endpoints, documentation. + +### 1.4 Error Handling & Problem Details +**Problem**: No structured error responses. Exceptions bubble as 500s with no useful payload. +**Plan**: Add global exception middleware returning RFC 7807 Problem Details. Module-specific error types. Inertia error page support. +**Files**: SimpleModule.Core (middleware), SimpleModule.Api (registration). + +### 1.5 Use Generated TypeScript Types +**Problem**: React pages define inline TypeScript interfaces that duplicate [Dto] definitions. Generated `contracts.d.ts` exists but is unused. +**Plan**: Wire `extract-ts-types.mjs` into build pipeline. Replace inline interfaces with imports from generated types. Single source of truth. +**Files**: All React page components, tools/extract-ts-types.mjs, module vite configs. + +--- + +## Phase 2: Capability (Build What's Missing) + +These add significant value and unblock real application development. + +### 2.1 Pagination & Filtering +**Problem**: All list endpoints return `ToListAsync()` — entire table. No pagination, search, or sort. +**Plan**: Add `PagedResult` to Core. Standard query parameters (`page`, `pageSize`, `sort`, `search`). Source generator could generate filtered query extensions from [Dto] properties. +**Files**: SimpleModule.Core (PagedResult, query helpers), all list endpoints, React list pages. + +### 2.2 Client-Side Form Validation & UX +**Problem**: No validation error display, no loading states on buttons, no toast/notification system. +**Plan**: +- Inertia error bag → display field-level errors +- `useForm` hook wrapper with loading state +- Toast component for success/error notifications +**Files**: ClientApp (shared components), all module page components. + +### 2.3 Authorization Policies +**Problem**: Auth exists (Identity + OpenIddict) but no fine-grained authorization. No role-based endpoint protection. +**Plan**: Module-level authorization policies. Source generator discovers `[Authorize]` on endpoints. Admin-only routes for Products/Orders management. +**Files**: SimpleModule.Core (policy abstractions), module endpoints, generator. + +### 2.4 Event Handler Auto-Discovery +**Problem**: `IEventHandler` exists and works, but handlers must be manually registered in DI. `OrderCreatedEvent` is declared but no handlers are wired. +**Plan**: Source generator discovers `IEventHandler` implementations and generates DI registration. Same pattern as IEndpoint discovery. +**Files**: SimpleModule.Generator, SimpleModule.Core (events). + +### 2.5 Dashboard Module +**Problem**: No landing page after login. No overview of system state. +**Plan**: Dashboard module with widget system. Each module contributes widgets (order count, product stats, user activity). Uses event bus or contracts for cross-module data. +**Files**: New module: src/modules/Dashboard/. + +### 2.6 File Upload Support +**Problem**: No file handling infrastructure. Common need for product images, user avatars, document attachments. +**Plan**: `IFileStorage` abstraction in Core (local disk + S3 compatible). Upload endpoint pattern. Image processing pipeline. +**Files**: SimpleModule.Core (IFileStorage), new endpoints, React upload components. + +--- + +## Phase 3: Polish (Production Readiness) + +These make the difference between a demo and a deployable system. + +### 3.1 Observability +**Problem**: Structured logging exists (`[LoggerMessage]`) but no metrics, tracing, or health check depth. +**Plan**: OpenTelemetry integration. Per-module health checks. Request duration metrics. Distributed tracing through event bus. +**Files**: SimpleModule.Core (telemetry), SimpleModule.Api (configuration), module health checks. + +### 3.2 Caching +**Problem**: Every request hits the database. No caching layer. +**Plan**: `IModuleCache` abstraction. In-memory default, Redis optional. Cache invalidation via event bus (when product updated, invalidate product cache). +**Files**: SimpleModule.Core (caching abstractions), module services. + +### 3.3 Rate Limiting +**Problem**: No rate limiting on any endpoint. API abuse possible. +**Plan**: ASP.NET Core rate limiting middleware. Per-endpoint policies. Module-configurable limits. +**Files**: SimpleModule.Api (middleware registration), endpoint attributes. + +### 3.4 API Versioning +**Problem**: No versioning strategy. Breaking changes would affect all consumers. +**Plan**: URL-based versioning (`/v1/products`). RoutePrefix on [Module] supports this. Version negotiation in Inertia responses. +**Files**: Module attributes, generator (version-aware routing). + +### 3.5 Security Hardening +**Problem**: No CSRF on API endpoints (Inertia handles some), no Content-Security-Policy, no rate limiting on auth endpoints. +**Plan**: CSP headers, anti-forgery on state-changing endpoints, brute-force protection on login/token endpoints, security headers middleware. +**Files**: SimpleModule.Api (middleware), Users module (auth endpoints). + +### 3.6 Background Jobs +**Problem**: No async processing. Long operations (email sending, report generation, data import) would block requests. +**Plan**: Lightweight job queue. Could use `IEventBus` pattern with persistent queue backend. Or integrate Hangfire/Quartz. +**Files**: SimpleModule.Core (job abstractions), new infrastructure project. + +### 3.7 Deployment & Configuration +**Problem**: Docker exists but no Kubernetes manifests, no environment-specific configuration, no secrets management guidance. +**Plan**: Helm chart or K8s manifests. Document configuration hierarchy. Environment-specific appsettings. Secrets via environment variables or vault. +**Files**: Infrastructure directory, documentation. + +--- + +## Phase 4: Developer Experience + +These make the framework pleasant to build with. + +### 4.1 CLI Enhancements (`sm` tool) +**Problem**: CLI exists but feature generation templates have TODO placeholders. Limited scaffolding. +**Plan**: Complete `sm add feature` command. Add `sm add endpoint`, `sm add event`, `sm add migration`. Interactive prompts. +**Files**: SimpleModule.Cli (FeatureTemplates.cs, commands). + +### 4.2 Hot Reload for Module Pages +**Problem**: Module Vite builds require manual `npm run build` or `npm run watch`. No integrated dev experience. +**Plan**: `npm run dev` script that watches all modules. Vite HMR through Inertia. Single command development experience. +**Files**: Root package.json (scripts), module vite configs. + +### 4.3 Documentation Site +**Problem**: CLAUDE.md and design docs exist but no user-facing documentation. +**Plan**: Doc site (Docusaurus or similar) covering: getting started, module creation guide, architecture overview, API reference. +**Files**: New docs/ directory. + +### 4.4 Integration Test Harness +**Problem**: Tests exist but no shared test utilities. Each module reinvents WebApplicationFactory setup. +**Plan**: `SimpleModule.Testing` package with: test server builder, authenticated test client, database seeding helpers, Inertia response assertions. +**Files**: New project: src/SimpleModule.Testing/. + +### 4.5 Source Generator Diagnostics +**Problem**: When source generator fails or produces unexpected output, debugging is opaque. +**Plan**: Add analyzer diagnostics: warnings for common mistakes (missing [Module], IEndpoint without public constructor, [Dto] with unsupported types). Emit diagnostic comments in generated code. +**Files**: SimpleModule.Generator (diagnostic descriptors). + +--- + +## Known Problems (Full Catalog) + +### Framework Core +| # | Problem | Severity | Phase | +|---|---------|----------|-------| +| F1 | No request validation pipeline | High | 1 | +| F2 | No EF Core migrations | High | 1 | +| F3 | No structured error responses (Problem Details) | High | 1 | +| F4 | Event handlers not auto-discovered by generator | Medium | 2 | +| F5 | No pagination/filtering abstractions | Medium | 2 | +| F6 | No caching layer | Medium | 3 | +| F7 | No background job system | Medium | 3 | +| F8 | No file storage abstraction | Medium | 2 | +| F9 | No rate limiting | Medium | 3 | +| F10 | No API versioning | Low | 3 | +| F11 | Menu URLs are untyped strings | Low | 4 | + +### Frontend +| # | Problem | Severity | Phase | +|---|---------|----------|-------| +| FE1 | Generated TS types unused (inline duplicates) | High | 1 | +| FE2 | No client-side validation error display | High | 2 | +| FE3 | No loading states during form submission | Medium | 2 | +| FE4 | No toast/notification system | Medium | 2 | +| FE5 | No shared React component library | Medium | 2 | +| FE6 | QRCode module partially integrated | Low | 2 | +| FE7 | Product Browse page has minimal styling vs others | Low | 2 | +| FE8 | No Vite HMR integration for dev workflow | Medium | 4 | + +### Architecture +| # | Problem | Severity | Phase | +|---|---------|----------|-------| +| A1 | Mixed endpoint patterns (IEndpoint + ConfigureEndpoints) | High | 1 | +| A2 | Manual form parsing in endpoints (ReadFormAsync) | High | 1 | +| A3 | Seed data hard-coded in DbContext (not environment-aware) | Medium | 3 | +| A4 | TypeScript extraction tool is fragile (depends on generator output format) | Medium | 4 | +| A5 | No cross-module query pattern (only contracts interfaces) | Low | 3 | + +### Production / Operations +| # | Problem | Severity | Phase | +|---|---------|----------|-------| +| P1 | No observability (metrics, tracing) | High | 3 | +| P2 | No security headers (CSP, HSTS beyond default) | Medium | 3 | +| P3 | No brute-force protection on auth endpoints | Medium | 3 | +| P4 | No K8s/production deployment manifests | Medium | 3 | +| P5 | No secrets management guidance | Low | 3 | + +### Testing +| # | Problem | Severity | Phase | +|---|---------|----------|-------| +| T1 | No shared test utilities / test harness | Medium | 4 | +| T2 | No frontend tests (Playwright setup exists, no tests) | Medium | 4 | +| T3 | Integration tests only for Products, not Orders/Users | Medium | 2 | +| T4 | No load/performance testing | Low | 3 | + +### Developer Experience +| # | Problem | Severity | Phase | +|---|---------|----------|-------| +| DX1 | CLI feature templates have TODO placeholders | Medium | 4 | +| DX2 | No user-facing documentation site | Medium | 4 | +| DX3 | Source generator failures are hard to debug | Low | 4 | +| DX4 | New module creation requires many manual steps | Medium | 4 | + +--- + +## Potential New Modules + +| Module | Purpose | Dependencies | Priority | +|--------|---------|-------------|----------| +| **Dashboard** | Landing page with widgets from other modules | Products, Orders, Users contracts | High | +| **Notifications** | In-app + email notifications, subscribes to events | Event bus, Users | Medium | +| **Settings** | App-wide configuration UI (feature flags, system settings) | None | Medium | +| **Audit** | Track entity changes, user actions | Event bus, all modules | Medium | +| **FileStorage** | Centralized file/image management | Core abstractions | Medium | +| **Reports** | Exportable reports, scheduled generation | Products, Orders, background jobs | Low | + +--- + +## Verification + +This is a planning document — verification means reviewing it periodically against actual project state: +1. Check off completed items as they're implemented +2. Re-prioritize based on what you're actually building +3. Update severity ratings as the project evolves +4. Remove items that become irrelevant diff --git a/docs/plans/2026-03-13-host-restructure-design.md b/docs/plans/2026-03-13-host-restructure-design.md new file mode 100644 index 00000000..45c211e3 --- /dev/null +++ b/docs/plans/2026-03-13-host-restructure-design.md @@ -0,0 +1,113 @@ +# SimpleModule Host Restructure Design + +**Date:** 2026-03-13 +**Goal:** Rename SimpleModule.Api → SimpleModule.Host and extract reusable concerns into focused packages. +**Approach:** Bottom-up — create all packages first, then rename Api → Host. + +## New Packages + +### 1. SimpleModule.Blazor (NuGet — Razor Class Library) + +**Location:** `src/SimpleModule.Blazor/` + +Composable Blazor SSR components and the Inertia page renderer. + +**Contents:** +- `InertiaPageRenderer.cs` — Blazor SSR implementation of `IInertiaPageRenderer` +- **Composable components** (extracted from current `MainLayout.razor`): + - `ModuleNav.razor` — renders menu items from `IMenuRegistry` + - `UserDropdown.razor` — avatar, name, dropdown menu items, logout form + - `DarkModeToggle.razor` — theme toggle button +- **Shell components:** + - `InertiaShell.razor` — full HTML shell for Inertia pages (importmap, layout, page JSON) + - `InertiaPage.razor` — `
` + script tag +- `DarkModeScript.razor` — `applyTheme()`/`toggleTheme()`/MutationObserver inline script + +**NOT included:** `App.razor`, `Routes.razor`, `MainLayout.razor` — these stay in Host (host-specific branding and assembly references). + +**csproj:** `Microsoft.NET.Sdk.Razor`, references `SimpleModule.Core`, ``. + +### 2. @simplemodule/client (npm package) + +**Location:** `src/SimpleModule.Client/` + +Reusable Vite plugin and Inertia page resolver for the React frontend. + +**Contents:** +- `vite-plugin-vendor.ts` — `vendorBuildPlugin()` made configurable (vendor list, output dir as parameters) +- `resolve-page.ts` — Inertia page resolver function (module name → dynamic import) +- `index.ts` — public exports +- `package.json` — peer deps on React, React-DOM, @inertiajs/react, vite, esbuild + +**Host's ClientApp after extraction:** +- `app.tsx` → ~5 lines: imports `resolvePage` from `@simplemodule/client` +- `vite.config.ts` → ~10 lines: imports vendor plugin from `@simplemodule/client` + +### 3. @simplemodule/theme-default (npm package) + +**Location:** `src/SimpleModule.Theme.Default/` + +Full design system CSS — theme variables, dark mode, component styles, utilities, animations. + +**Contents:** +- `theme.css` — all 543 LOC from current `Styles/app.css`: `@theme` variables, dark mode overrides, base layer, component layer (glass-card, buttons, badges, alerts, code-block, panel, card, nav-link, spinner, user-dropdown, dash-card, validation), utilities (gradient-text, gradient-border), bg-mesh animation, table styling, scrollbar +- `package.json` — `@simplemodule/theme-default` + +**Host's Styles after extraction:** +```css +@import '@simplemodule/theme-default/theme.css'; +@source "../../modules/"; +``` + +**Future themes:** Same pattern — `@simplemodule/theme-*`. Host swaps one import line. + +### 4. Dashboard Module + +**Location:** `src/modules/Dashboard/src/Dashboard/` + +Extracted from current `Home.razor` (312 LOC). Converts Blazor SSR page → React/Inertia, consistent with all other modules. + +**Contents:** +- `DashboardModule.cs` — `[Module("Dashboard", RoutePrefix = "dashboard")]` +- `Pages/Home.tsx` — landing page / dashboard UI +- OAuth PKCE flow, token tester, API tester as React components +- `Endpoints/` — API endpoints for dashboard features +- `vite.config.ts` + `package.json` — standard module Vite library build +- `Dashboard.Contracts/` — if cross-module communication is needed + +## SimpleModule.Host (renamed from Api) + +**Location:** `src/SimpleModule.Host/` (renamed from `src/SimpleModule.Api/`) + +Thin host that assembles packages and modules. + +**What stays:** +- `Program.cs` — bootstrap, service registration, middleware pipeline +- `appsettings.json` + `Properties/launchSettings.json` +- `Components/App.razor` — HTML shell (uses `` from Blazor package) +- `Components/Routes.razor` — router with module assembly references +- `Components/Layout/MainLayout.razor` — assembles ``, ``, `` with host branding +- `Components/Pages/OAuthCallback.razor` — host-specific OAuth redirect +- `ClientApp/app.tsx` — slim, imports from `@simplemodule/client` +- `ClientApp/vite.config.ts` — slim, imports plugin from `@simplemodule/client` +- `Styles/app.css` — slim, imports from `@simplemodule/theme-default` +- `wwwroot/js/shell.js` — dropdown toggle logic + +**References:** SimpleModule.Core, SimpleModule.Database, SimpleModule.Blazor, SimpleModule.Generator, all modules (Users, Products, Orders, Dashboard). + +**csproj:** keeps `PublishAot`, Tailwind build target, Vite build target, TS type extraction. + +## Solution-Wide Changes + +- `SimpleModule.slnx` — remove `SimpleModule.Api`, add `SimpleModule.Host`, `SimpleModule.Blazor`, Dashboard module projects +- `CLAUDE.md` — update all references from `SimpleModule.Api` → `SimpleModule.Host` +- Root `package.json` — update workspace patterns if needed + +## Execution Order (Bottom-Up) + +1. Create `SimpleModule.Blazor` — extract components + renderer +2. Create `@simplemodule/client` — extract Vite plugin + page resolver +3. Create `@simplemodule/theme-default` — extract design system CSS +4. Create Dashboard module — convert Home.razor → React/Inertia +5. Rename `SimpleModule.Api` → `SimpleModule.Host` — rewire references, slim down files +6. Update solution file, CLAUDE.md, package.json workspaces diff --git a/docs/plans/2026-03-13-host-restructure-plan.md b/docs/plans/2026-03-13-host-restructure-plan.md new file mode 100644 index 00000000..bd6e476a --- /dev/null +++ b/docs/plans/2026-03-13-host-restructure-plan.md @@ -0,0 +1,1680 @@ +# Host Restructure Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Rename SimpleModule.Api → SimpleModule.Host and extract reusable concerns into focused packages (SimpleModule.Blazor, @simplemodule/client, @simplemodule/theme-default, Dashboard module). + +**Architecture:** Bottom-up extraction — create each package first with code moved from SimpleModule.Api, verify builds, then rename Api → Host as the final step. Each task produces a working build. + +**Tech Stack:** .NET 10 (Razor Class Library), React 19, Inertia.js, Vite, Tailwind CSS, npm workspaces. + +--- + +### Task 1: Create SimpleModule.Blazor — Project Setup + +**Files:** +- Create: `src/SimpleModule.Blazor/SimpleModule.Blazor.csproj` +- Create: `src/SimpleModule.Blazor/_Imports.razor` + +**Step 1: Create the csproj** + +```xml + + + net10.0 + + + + + + +``` + +**Step 2: Create _Imports.razor** + +```razor +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Authorization +@using SimpleModule.Blazor +@using SimpleModule.Blazor.Components +``` + +**Step 3: Add to solution file** + +In `SimpleModule.slnx`, add inside `/src/` folder: +```xml + +``` + +**Step 4: Add ProjectReference from Api** + +In `src/SimpleModule.Api/SimpleModule.Api.csproj`, add: +```xml + +``` + +**Step 5: Build to verify** + +Run: `dotnet build src/SimpleModule.Blazor/SimpleModule.Blazor.csproj` +Expected: BUILD SUCCEEDED + +**Step 6: Commit** + +```bash +git add src/SimpleModule.Blazor/ SimpleModule.slnx src/SimpleModule.Api/SimpleModule.Api.csproj +git commit -m "feat: scaffold SimpleModule.Blazor Razor class library" +``` + +--- + +### Task 2: Extract InertiaPageRenderer to SimpleModule.Blazor + +**Files:** +- Move: `src/SimpleModule.Api/Inertia/InertiaPageRenderer.cs` → `src/SimpleModule.Blazor/Inertia/InertiaPageRenderer.cs` +- Modify: `src/SimpleModule.Api/Program.cs:66` (update using) + +**Step 1: Create the file in Blazor project** + +Create `src/SimpleModule.Blazor/Inertia/InertiaPageRenderer.cs`: + +```csharp +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Http; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.Blazor.Inertia; + +public sealed class InertiaPageRenderer(IServiceProvider services, ILoggerFactory loggerFactory) + : IInertiaPageRenderer +{ + public async Task RenderPageAsync(HttpContext httpContext, string pageJson) + { + await using var renderer = new HtmlRenderer(services, loggerFactory); + var html = await renderer.Dispatcher.InvokeAsync(async () => + { + var output = await renderer.RenderComponentAsync( + ParameterView.FromDictionary( + new Dictionary + { + ["PageJson"] = pageJson, + ["HttpContext"] = httpContext, + } + ) + ); + return output.ToHtmlString(); + }); + + httpContext.Response.ContentType = "text/html; charset=utf-8"; + await httpContext.Response.WriteAsync(html); + } +} +``` + +Note: This references `Components.InertiaShell` which we'll create in the next task. For now, the renderer references it by type. We'll move InertiaShell in Task 3. + +**Step 2: Delete old file** + +Delete `src/SimpleModule.Api/Inertia/InertiaPageRenderer.cs`. + +**Step 3: Update Program.cs** + +In `src/SimpleModule.Api/Program.cs`, change: +- Line 7: `using SimpleModule.Api.Inertia;` → `using SimpleModule.Blazor.Inertia;` + +**Step 4: Build to verify** + +Run: `dotnet build` +Expected: BUILD SUCCEEDED (will fail until Task 3 completes — defer build check) + +--- + +### Task 3: Extract Shell Components to SimpleModule.Blazor + +**Files:** +- Move: `src/SimpleModule.Api/Components/InertiaShell.razor` → `src/SimpleModule.Blazor/Components/InertiaShell.razor` +- Move: `src/SimpleModule.Api/Components/Pages/InertiaPage.razor` → `src/SimpleModule.Blazor/Components/InertiaPage.razor` +- Create: `src/SimpleModule.Blazor/Components/DarkModeScript.razor` + +**Step 1: Create InertiaShell.razor in Blazor project** + +Create `src/SimpleModule.Blazor/Components/InertiaShell.razor`. This must be adapted — it currently references `SimpleModule.Api.Components.Layout` and `SimpleModule.Api.Components.Pages`. The layout will be passed as a parameter instead of hardcoded: + +```razor +@using Microsoft.AspNetCore.Components.Web + + + + + + + + @if (HeadContent is not null) + { + @HeadContent + } + + + + + @if (BodyPrefix is not null) + { + @BodyPrefix + } + + + + + + @if (BodySuffix is not null) + { + @BodySuffix + } + + + +@code { + [Parameter] public string PageJson { get; set; } = ""; + [Parameter] public Microsoft.AspNetCore.Http.HttpContext? HttpContext { get; set; } + [Parameter, EditorRequired] public Type Layout { get; set; } = default!; + [Parameter] public RenderFragment? HeadContent { get; set; } + [Parameter] public RenderFragment? BodyPrefix { get; set; } + [Parameter] public RenderFragment? BodySuffix { get; set; } +} +``` + +**Step 2: Create InertiaPage.razor in Blazor project** + +Create `src/SimpleModule.Blazor/Components/InertiaPage.razor`: + +```razor +
+ + +@code { + [Parameter] public string PageJson { get; set; } = ""; +} +``` + +**Step 3: Create DarkModeScript.razor** + +Create `src/SimpleModule.Blazor/Components/DarkModeScript.razor`: + +```razor + +``` + +**Step 4: Delete old files from Api** + +Delete: +- `src/SimpleModule.Api/Components/InertiaShell.razor` +- `src/SimpleModule.Api/Components/Pages/InertiaPage.razor` + +**Step 5: Update InertiaPageRenderer to use the new InertiaShell** + +The renderer in `src/SimpleModule.Blazor/Inertia/InertiaPageRenderer.cs` needs the host's layout type. Update it to accept a configurable shell component type: + +Create `src/SimpleModule.Blazor/Inertia/InertiaOptions.cs`: + +```csharp +using Microsoft.AspNetCore.Components; + +namespace SimpleModule.Blazor.Inertia; + +public class InertiaOptions +{ + public Type ShellComponent { get; set; } = typeof(Components.InertiaShell); +} +``` + +Update `InertiaPageRenderer.cs` to accept options: + +```csharp +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.Blazor.Inertia; + +public sealed class InertiaPageRenderer( + IServiceProvider services, + ILoggerFactory loggerFactory, + IOptions options) + : IInertiaPageRenderer +{ + public async Task RenderPageAsync(HttpContext httpContext, string pageJson) + { + await using var renderer = new HtmlRenderer(services, loggerFactory); + var html = await renderer.Dispatcher.InvokeAsync(async () => + { + var output = await renderer.RenderComponentAsync( + options.Value.ShellComponent, + ParameterView.FromDictionary( + new Dictionary + { + ["PageJson"] = pageJson, + ["HttpContext"] = httpContext, + } + ) + ); + return output.ToHtmlString(); + }); + + httpContext.Response.ContentType = "text/html; charset=utf-8"; + await httpContext.Response.WriteAsync(html); + } +} +``` + +Create `src/SimpleModule.Blazor/ServiceCollectionExtensions.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Blazor.Inertia; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.Blazor; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddSimpleModuleBlazor( + this IServiceCollection services, + Action? configure = null) + { + services.AddScoped(); + if (configure is not null) + services.Configure(configure); + else + services.Configure(_ => { }); + return services; + } +} +``` + +**Step 6: Update Api's Program.cs** + +Replace: +```csharp +using SimpleModule.Api.Inertia; +... +builder.Services.AddScoped(); +``` + +With: +```csharp +using SimpleModule.Blazor; +... +builder.Services.AddSimpleModuleBlazor(); +``` + +**Step 7: Update Api's InertiaShell usage** + +Create a new host-specific `InertiaShell.razor` at `src/SimpleModule.Api/Components/InertiaShell.razor` that wraps the Blazor package's shell with host-specific head content: + +```razor +@using SimpleModule.Blazor.Components + + + + + + + + SimpleModule + + +
+
+ + + +
+ +@code { + [Parameter] public string PageJson { get; set; } = ""; + [Parameter] public Microsoft.AspNetCore.Http.HttpContext? HttpContext { get; set; } +} +``` + +**Step 8: Update Api's App.razor to use DarkModeScript** + +Update `src/SimpleModule.Api/Components/App.razor` to import DarkModeScript from the Blazor package instead of inlining the script. + +**Step 9: Delete the old Inertia directory from Api** + +Delete `src/SimpleModule.Api/Inertia/` directory entirely. + +**Step 10: Build to verify** + +Run: `dotnet build` +Expected: BUILD SUCCEEDED + +**Step 11: Commit** + +```bash +git add -A +git commit -m "feat: extract shell components and InertiaPageRenderer to SimpleModule.Blazor" +``` + +--- + +### Task 4: Extract Composable Nav Components to SimpleModule.Blazor + +**Files:** +- Create: `src/SimpleModule.Blazor/Components/ModuleNav.razor` +- Create: `src/SimpleModule.Blazor/Components/UserDropdown.razor` +- Create: `src/SimpleModule.Blazor/Components/DarkModeToggle.razor` +- Modify: `src/SimpleModule.Api/Components/Layout/MainLayout.razor` (use the new components) + +**Step 1: Create ModuleNav.razor** + +Create `src/SimpleModule.Blazor/Components/ModuleNav.razor`: + +```razor +@using SimpleModule.Core.Menu +@inject IMenuRegistry MenuRegistry + + + +@code { + [CascadingParameter] + public Microsoft.AspNetCore.Http.HttpContext? HttpContext { get; set; } + + [Parameter] public bool IsAuthenticated { get; set; } + [Parameter] public RenderFragment? AuthenticatedPrefix { get; set; } + [Parameter] public RenderFragment? AuthenticatedSuffix { get; set; } + [Parameter] public RenderFragment? AnonymousSuffix { get; set; } + + private IReadOnlyList NavbarItems => MenuRegistry.GetItems(MenuSection.Navbar); + + private string NavLinkClass(string path) + { + var currentPath = HttpContext?.Request.Path.ToString() ?? ""; + var isActive = currentPath.Equals(path, StringComparison.OrdinalIgnoreCase); + return isActive + ? "text-sm font-medium text-primary no-underline px-3 py-1.5 rounded-lg bg-primary-subtle transition-all duration-200" + : "text-sm text-text-muted no-underline px-3 py-1.5 rounded-lg hover:text-text hover:bg-surface-raised transition-all duration-200"; + } +} +``` + +**Step 2: Create UserDropdown.razor** + +Create `src/SimpleModule.Blazor/Components/UserDropdown.razor`: + +```razor +@using SimpleModule.Core.Menu +@inject IMenuRegistry MenuRegistry + +
+ +
+
+
@DisplayName
+
@UserEmail
+
+
+ @{ + string? lastGroup = null; + foreach (var item in DropdownItems) + { + if (lastGroup is not null && item.Group != lastGroup) + { +
+ } + lastGroup = item.Group; + + @((MarkupString)item.Icon) + @item.Label + + } + } +
+
+
+
+ +@code { + [Parameter, EditorRequired] public string DisplayName { get; set; } = ""; + [Parameter] public string UserEmail { get; set; } = ""; + [Parameter] public string UserInitial { get; set; } = "U"; + [Parameter] public string LogoutUrl { get; set; } = "/Identity/Account/Logout"; + + private IReadOnlyList DropdownItems => MenuRegistry.GetItems(MenuSection.UserDropdown); +} +``` + +**Step 3: Create DarkModeToggle.razor** + +Create `src/SimpleModule.Blazor/Components/DarkModeToggle.razor`: + +```razor + +``` + +**Step 4: Update MainLayout.razor to use composable components** + +Replace `src/SimpleModule.Api/Components/Layout/MainLayout.razor` to compose from the new building blocks: + +```razor +@using SimpleModule.Core.Menu +@using SimpleModule.Blazor.Components +@inherits LayoutComponentBase + + + +
+ @Body +
+ + + +@code { + [CascadingParameter] + public HttpContext? HttpContext { get; set; } + + private bool IsAuthenticated => HttpContext?.User?.Identity?.IsAuthenticated == true; + private string DisplayName => HttpContext?.User?.Identity?.Name ?? "User"; + private string UserEmail => HttpContext?.User?.Identity?.Name ?? ""; + private string UserInitial => (HttpContext?.User?.Identity?.Name ?? "U").Substring(0, 1).ToUpper(); + + private string NavLinkClass(string path) + { + var currentPath = HttpContext?.Request.Path.ToString() ?? ""; + var isActive = currentPath.Equals(path, StringComparison.OrdinalIgnoreCase); + return isActive + ? "text-sm font-medium text-primary no-underline px-3 py-1.5 rounded-lg bg-primary-subtle transition-all duration-200" + : "text-sm text-text-muted no-underline px-3 py-1.5 rounded-lg hover:text-text hover:bg-surface-raised transition-all duration-200"; + } +} +``` + +**Step 5: Build to verify** + +Run: `dotnet build` +Expected: BUILD SUCCEEDED + +**Step 6: Commit** + +```bash +git add -A +git commit -m "feat: extract ModuleNav, UserDropdown, DarkModeToggle to SimpleModule.Blazor" +``` + +--- + +### Task 5: Create @simplemodule/client npm Package + +**Files:** +- Create: `src/SimpleModule.Client/package.json` +- Create: `src/SimpleModule.Client/src/vite-plugin-vendor.ts` +- Create: `src/SimpleModule.Client/src/resolve-page.ts` +- Create: `src/SimpleModule.Client/src/index.ts` +- Modify: `src/SimpleModule.Api/ClientApp/app.tsx` (slim down) +- Modify: `src/SimpleModule.Api/ClientApp/vite.config.ts` (slim down) +- Modify: `package.json` (add workspace) + +**Step 1: Create package.json** + +Create `src/SimpleModule.Client/package.json`: + +```json +{ + "private": true, + "name": "@simplemodule/client", + "type": "module", + "main": "src/index.ts", + "peerDependencies": { + "@inertiajs/react": "^2.0.0", + "esbuild": "*", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^6.0.0" + } +} +``` + +**Step 2: Create resolve-page.ts** + +Create `src/SimpleModule.Client/src/resolve-page.ts`: + +```typescript +export async function resolvePage(name: string) { + const moduleName = name.split('/')[0]; + const mod = await import( + /* @vite-ignore */ + `/_content/${moduleName}/${moduleName}.pages.js` + ); + const page = mod.pages[name]; + return page.default ? page : { default: page }; +} +``` + +**Step 3: Create vite-plugin-vendor.ts** + +Create `src/SimpleModule.Client/src/vite-plugin-vendor.ts`: + +```typescript +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import * as esbuild from 'esbuild'; +import type { Plugin } from 'vite'; + +export interface VendorEntry { + pkg: string; + file: string; + externals: string[]; +} + +export const defaultVendors: VendorEntry[] = [ + { pkg: 'react', file: 'react', externals: [] }, + { pkg: 'react-dom', file: 'react-dom', externals: ['react'] }, + { pkg: 'react/jsx-runtime', file: 'react-jsx-runtime', externals: ['react'] }, + { pkg: 'react-dom/client', file: 'react-dom-client', externals: ['react', 'react-dom'] }, + { + pkg: '@inertiajs/react', + file: 'inertiajs-react', + externals: ['react', 'react-dom', 'react/jsx-runtime', 'react-dom/client'], + }, +]; + +export function vendorPaths( + vendors: VendorEntry[] = defaultVendors, + prefix = '/js/vendor', +): Record { + return Object.fromEntries(vendors.map((v) => [v.pkg, `${prefix}/${v.file}.js`])); +} + +function getExportNames(require_: NodeRequire, pkg: string): string[] { + try { + return Object.keys(require_(pkg)).filter((k) => k !== 'default' && k !== '__esModule'); + } catch { + return []; + } +} + +export function vendorBuildPlugin(options?: { + vendors?: VendorEntry[]; + outDir: string; +}): Plugin { + const vendors = options?.vendors ?? defaultVendors; + + return { + name: 'build-vendors', + apply: 'build', + async buildStart() { + const outDir = options?.outDir ?? path.resolve(process.cwd(), '../wwwroot/js/vendor'); + const require_ = createRequire(import.meta.url); + + if (vendors.every((v) => existsSync(path.join(outDir, `${v.file}.js`)))) return; + mkdirSync(outDir, { recursive: true }); + + for (const v of vendors) { + const outfile = path.join(outDir, `${v.file}.js`); + + await esbuild.build({ + entryPoints: [v.pkg], + bundle: true, + format: 'esm', + platform: 'browser', + external: v.externals, + outfile, + logLevel: 'warning', + }); + + let code = readFileSync(outfile, 'utf-8'); + + const imports: string[] = []; + for (let i = 0; i < v.externals.length; i++) { + const ext = v.externals[i]; + const re = new RegExp( + `__require\\("${ext.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}"\\)`, + 'g', + ); + if (re.test(code)) { + imports.push(`import * as __ext${i} from "${ext}";`); + code = code.replace(re, `__ext${i}`); + } + } + if (imports.length) code = `${imports.join('\n')}\n${code}`; + + const exportNames = getExportNames(require_, v.pkg); + if (exportNames.length) { + const match = code.match(/export\s+default\s+(.+?)\s*;\s*$/m); + if (match) { + const named = exportNames.map((e) => ` ${e}`).join(',\n'); + code = code.replace( + match[0], + `var __mod = ${match[1]};\nexport default __mod;\nexport var {\n${named}\n} = __mod;\n`, + ); + } + } + + writeFileSync(outfile, code); + } + }, + }; +} +``` + +**Step 4: Create index.ts** + +Create `src/SimpleModule.Client/src/index.ts`: + +```typescript +export { resolvePage } from './resolve-page'; +export { + vendorBuildPlugin, + vendorPaths, + defaultVendors, + type VendorEntry, +} from './vite-plugin-vendor'; +``` + +**Step 5: Update root package.json workspaces** + +In root `package.json`, add the new workspace: + +```json +"workspaces": [ + "src/modules/*/src/*", + "src/SimpleModule.Api/ClientApp", + "src/SimpleModule.Client" +] +``` + +**Step 6: Slim down ClientApp/app.tsx** + +Replace `src/SimpleModule.Api/ClientApp/app.tsx`: + +```typescript +import { resolvePage } from '@simplemodule/client'; +import { createInertiaApp } from '@inertiajs/react'; +import { createRoot } from 'react-dom/client'; + +createInertiaApp({ + resolve: resolvePage, + setup({ el, App, props }) { + createRoot(el).render(); + }, +}); +``` + +**Step 7: Slim down ClientApp/vite.config.ts** + +Replace `src/SimpleModule.Api/ClientApp/vite.config.ts`: + +```typescript +import path from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defaultVendors, vendorBuildPlugin, vendorPaths } from '@simplemodule/client'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + vendorBuildPlugin({ + outDir: path.resolve(__dirname, '../wwwroot/js/vendor'), + }), + react(), + ], + build: { + outDir: path.resolve(__dirname, '../wwwroot/js'), + emptyOutDir: false, + rollupOptions: { + input: path.resolve(__dirname, 'app.tsx'), + external: defaultVendors.map((v) => v.pkg), + output: { + entryFileNames: 'app.js', + paths: vendorPaths(), + }, + }, + }, +}); +``` + +**Step 8: Install dependencies and build** + +Run: `npm install` +Run: `npm run check` +Expected: No lint errors + +**Step 9: Commit** + +```bash +git add -A +git commit -m "feat: extract @simplemodule/client npm package with Vite vendor plugin and page resolver" +``` + +--- + +### Task 6: Create @simplemodule/theme-default npm Package + +**Files:** +- Create: `src/SimpleModule.Theme.Default/package.json` +- Create: `src/SimpleModule.Theme.Default/theme.css` +- Modify: `src/SimpleModule.Api/Styles/app.css` (slim down to import) + +**Step 1: Create package.json** + +Create `src/SimpleModule.Theme.Default/package.json`: + +```json +{ + "private": true, + "name": "@simplemodule/theme-default", + "main": "theme.css" +} +``` + +**Step 2: Move CSS** + +Copy the entire contents of `src/SimpleModule.Api/Styles/app.css` (all 543 lines) to `src/SimpleModule.Theme.Default/theme.css`. + +Remove the `@source` directive from the theme file (line 2: `@source "../../modules/";`) — that stays in the host. + +Also remove the `@import "tailwindcss";` line — that stays in the host (the host's Tailwind build processes this). + +So `theme.css` starts at the `@theme {` block (line 9 of the original) and goes to end of file. + +**Step 3: Slim down host's app.css** + +Replace `src/SimpleModule.Api/Styles/app.css`: + +```css +@import "tailwindcss"; +@import "@simplemodule/theme-default/theme.css"; +@source "../../modules/"; +``` + +**Step 4: Add workspace to root package.json** + +In root `package.json`, update workspaces: + +```json +"workspaces": [ + "src/modules/*/src/*", + "src/SimpleModule.Api/ClientApp", + "src/SimpleModule.Client", + "src/SimpleModule.Theme.Default" +] +``` + +**Step 5: Install and build** + +Run: `npm install` +Run: `dotnet build src/SimpleModule.Api/SimpleModule.Api.csproj` +Expected: BUILD SUCCEEDED (Tailwind resolves the import via node_modules) + +**Step 6: Commit** + +```bash +git add -A +git commit -m "feat: extract @simplemodule/theme-default npm package with design system CSS" +``` + +--- + +### Task 7: Create Dashboard Module — Scaffold + +**Files:** +- Create: `src/modules/Dashboard/src/Dashboard/Dashboard.csproj` +- Create: `src/modules/Dashboard/src/Dashboard/DashboardModule.cs` +- Create: `src/modules/Dashboard/src/Dashboard/DashboardConstants.cs` +- Create: `src/modules/Dashboard/src/Dashboard/package.json` +- Create: `src/modules/Dashboard/src/Dashboard/vite.config.ts` +- Create: `src/modules/Dashboard/src/Dashboard/Pages/index.ts` + +**Step 1: Create Dashboard.csproj** + +```xml + + + net10.0 + + + + + + + + + +``` + +**Step 2: Create DashboardConstants.cs** + +```csharp +namespace SimpleModule.Dashboard; + +internal static class DashboardConstants +{ + public const string ModuleName = "Dashboard"; + public const string RoutePrefix = ""; +} +``` + +Note: RoutePrefix is empty string — the dashboard serves the root `/` route. + +**Step 3: Create DashboardModule.cs** + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Core.Menu; + +namespace SimpleModule.Dashboard; + +[Module(DashboardConstants.ModuleName, RoutePrefix = DashboardConstants.RoutePrefix)] +public class DashboardModule : IModule +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + } + + public void ConfigureMenu(IMenuBuilder menus) + { + } + + public void ConfigureEndpoints(IEndpointRouteBuilder endpoints) + { + endpoints.MapGet( + "/", + (HttpContext context) => + { + var isAuthenticated = context.User?.Identity?.IsAuthenticated == true; + var displayName = context.User?.Identity?.Name ?? "User"; + var isDevelopment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development"; + + return Inertia.Render( + "Dashboard/Home", + new { isAuthenticated, displayName, isDevelopment } + ); + } + ); + } +} +``` + +**Step 4: Create package.json** + +```json +{ + "private": true, + "name": "@simplemodule/dashboard", + "scripts": { + "build": "vite build", + "watch": "vite build --watch" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} +``` + +**Step 5: Create vite.config.ts** + +```typescript +import { resolve } from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + build: { + lib: { + entry: resolve(__dirname, 'Pages/index.ts'), + formats: ['es'], + fileName: () => 'Dashboard.pages.js', + }, + outDir: 'wwwroot', + emptyOutDir: false, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime', '@inertiajs/react'], + output: { inlineDynamicImports: true }, + }, + }, +}); +``` + +**Step 6: Create Pages/index.ts (placeholder)** + +```typescript +import Home from './Home'; + +export const pages: Record = { + 'Dashboard/Home': Home, +}; +``` + +**Step 7: Add to solution and Api csproj** + +In `SimpleModule.slnx`, add a Dashboard folder: +```xml + + + +``` + +In `src/SimpleModule.Api/SimpleModule.Api.csproj`, add: +```xml + +``` + +**Step 8: Build .NET to verify scaffold** + +Run: `dotnet build src/modules/Dashboard/src/Dashboard/Dashboard.csproj` +Expected: BUILD SUCCEEDED (React page not yet created, but csproj compiles) + +**Step 9: Commit** + +```bash +git add -A +git commit -m "feat: scaffold Dashboard module" +``` + +--- + +### Task 8: Create Dashboard Module — React Pages + +**Files:** +- Create: `src/modules/Dashboard/src/Dashboard/Pages/Home.tsx` +- Delete: `src/SimpleModule.Api/Components/Pages/Home.razor` + +**Step 1: Create Home.tsx** + +Convert the Blazor `Home.razor` to a React component. Create `src/modules/Dashboard/src/Dashboard/Pages/Home.tsx`: + +```tsx +interface HomeProps { + isAuthenticated: boolean; + displayName: string; + isDevelopment: boolean; +} + +function DashboardView({ displayName }: { displayName: string }) { + return ( + <> +
+

+ Welcome back, {displayName} +

+

Here's your development dashboard

+
+ + + +
+ + +
+ + + + ); +} + +function UserInfoPanel() { + // Client-side fetch for user info + const [info, setInfo] = React.useState | null>(null); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + fetch('/api/users/me') + .then((res) => { + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return res.json(); + }) + .then(setInfo) + .catch((e) => setError(e.message)); + }, []); + + return ( +
+

User Info

+
+ {error ? ( + Failed to load: {error} + ) : !info ? ( +
+ Loading user info +
+ ) : ( + <> + + + + {info.roles && ( + + )} + + )} +
+
+ ); +} + +function InfoRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +function TokenTester() { + const [token, setToken] = React.useState(null); + const [loading, setLoading] = React.useState(false); + + React.useEffect(() => { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const state = params.get('state'); + if (!code) return; + + window.history.replaceState({}, '', '/'); + const verifier = sessionStorage.getItem('pkce_verifier'); + const savedState = sessionStorage.getItem('pkce_state'); + if (state !== savedState) return; + + sessionStorage.removeItem('pkce_verifier'); + sessionStorage.removeItem('pkce_state'); + + fetch('/connect/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: 'simplemodule-client', + code, + redirect_uri: `${window.location.origin}/oauth-callback`, + code_verifier: verifier || '', + }), + }) + .then((res) => res.json()) + .then((data) => setToken(data.access_token)) + .catch(() => {}); + }, []); + + async function startOAuth() { + setLoading(true); + const arr = new Uint8Array(32); + crypto.getRandomValues(arr); + const verifier = btoa(String.fromCharCode(...arr)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + const challenge = btoa(String.fromCharCode(...new Uint8Array(hash))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + + const state = crypto.randomUUID(); + sessionStorage.setItem('pkce_verifier', verifier); + sessionStorage.setItem('pkce_state', state); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: 'simplemodule-client', + redirect_uri: `${window.location.origin}/oauth-callback`, + scope: 'openid profile email', + state, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + window.location.href = `/connect/authorize?${params}`; + } + + const claims = React.useMemo(() => { + if (!token) return null; + try { + const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + return Object.entries(payload).map(([key, val]) => ({ + key, + value: + key === 'exp' || key === 'iat' || key === 'nbf' + ? new Date((val as number) * 1000).toLocaleString() + : typeof val === 'object' + ? JSON.stringify(val) + : String(val), + })); + } catch { + return null; + } + }, [token]); + + return ( +
+

Token Tester

+
+

OAuth2 Authorization Code + PKCE

+

+ Obtain an access token using the{' '} + + simplemodule-client + {' '} + application. +

+ + {token && ( + <> +
{token}
+ {claims && ( + <> +

Decoded Claims

+
+ + + + + + + + + {claims.map((c) => ( + + + + + ))} + +
ClaimValue
{c.key}{c.value}
+
+ + )} + + )} +
+
+ ); +} + +function ApiTester() { + const [status, setStatus] = React.useState<{ ok: boolean; code: string; text: string } | null>( + null, + ); + const [response, setResponse] = React.useState('Click an endpoint above to make a request.'); + const [loading, setLoading] = React.useState(false); + + async function callApi(url: string) { + setLoading(true); + setStatus(null); + setResponse(''); + + try { + const token = sessionStorage.getItem('access_token'); + const headers: Record = {}; + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(url, { headers }); + setStatus({ ok: res.ok, code: String(res.status), text: `${res.statusText} — ${url}` }); + + const text = await res.text(); + try { + setResponse(JSON.stringify(JSON.parse(text), null, 2)); + } catch { + setResponse(text); + } + } catch (e: any) { + setStatus({ ok: false, code: 'Error', text: e.message }); + setResponse(e.message); + } finally { + setLoading(false); + } + } + + const endpoints = ['/api/users/me', '/api/users', '/api/products', '/api/orders']; + + return ( +
+

API Tester

+
+

Call Protected Endpoints

+
+ {endpoints.map((url) => ( + + ))} +
+
+ {loading && ( + <> + Calling... + + )} + {status && ( + <> + {status.code} + {' '}{status.text} + + )} +
+
{response}
+
+
+ ); +} + +function LandingView({ isDevelopment }: { isDevelopment: boolean }) { + return ( +
+
+
+ S +
+

+ SimpleModule +

+

+ Modular monolith framework for .NET — AOT‑compatible, zero reflection +

+ + + + {isDevelopment && ( +
+ Quick Start (Development Only) + Email:{' '} + + admin@simplemodule.dev + +   Password:{' '} + + Admin123! + +
+ )} + + +
+
+ ); +} + +import React from 'react'; + +export default function Home({ isAuthenticated, displayName, isDevelopment }: HomeProps) { + return isAuthenticated ? ( + + ) : ( + + ); +} +``` + +**Step 2: Delete Home.razor from Api** + +Delete `src/SimpleModule.Api/Components/Pages/Home.razor`. + +**Step 3: Update Routes.razor** + +Remove the `@page "/"` route from the Blazor router since Dashboard module now handles it via Inertia. The `Home.razor` page had `@page "/"`, but since we deleted it, Routes.razor should still work — it only routes to Blazor pages that exist. The Dashboard module's endpoint will handle `/` via the Inertia middleware. + +**Step 4: Build and verify** + +Run: `dotnet build` +Run: `npm install && npm run check` +Expected: BUILD SUCCEEDED, no lint errors + +**Step 5: Commit** + +```bash +git add -A +git commit -m "feat: create Dashboard module with React pages, remove Home.razor" +``` + +--- + +### Task 9: Rename SimpleModule.Api → SimpleModule.Host + +**Files:** +- Rename: `src/SimpleModule.Api/` → `src/SimpleModule.Host/` +- Rename: `src/SimpleModule.Api/SimpleModule.Api.csproj` → `src/SimpleModule.Host/SimpleModule.Host.csproj` +- Modify: `SimpleModule.slnx` (update project path) +- Modify: all `ProjectReference` paths that reference SimpleModule.Api +- Modify: `src/SimpleModule.Host/Program.cs` (update namespace usings) +- Modify: `src/SimpleModule.Host/Components/_Imports.razor` +- Modify: `src/SimpleModule.Host/Components/App.razor` +- Modify: `src/SimpleModule.Host/Components/Routes.razor` +- Modify: `src/SimpleModule.Host/Components/Layout/MainLayout.razor` +- Modify: `src/SimpleModule.Host/Properties/launchSettings.json` +- Modify: root `package.json` (update workspace path) +- Modify: `CLAUDE.md` + +**Step 1: Rename directory** + +```bash +git mv src/SimpleModule.Api src/SimpleModule.Host +``` + +**Step 2: Rename csproj** + +```bash +git mv src/SimpleModule.Host/SimpleModule.Api.csproj src/SimpleModule.Host/SimpleModule.Host.csproj +``` + +**Step 3: Update namespaces in csproj** + +In `SimpleModule.Host.csproj`, no namespace changes needed — the csproj doesn't declare a root namespace, so it defaults to the project name. + +**Step 4: Update solution file** + +In `SimpleModule.slnx`, change: +```xml + +``` +to: +```xml + +``` + +**Step 5: Update test project references** + +Check `tests/SimpleModule.Tests.Shared/` and any test projects that reference SimpleModule.Api — update their `ProjectReference` paths. + +The csproj has ``. Find any test project that references `SimpleModule.Api.csproj` and update the path. + +**Step 6: Update _Imports.razor** + +```razor +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Authorization +@using SimpleModule.Host.Components +@using SimpleModule.Host.Components.Layout +``` + +**Step 7: Update Program.cs usings** + +Change `using SimpleModule.Api.Components;` → `using SimpleModule.Host.Components;` +Change `using SimpleModule.Api.Inertia;` → already updated to `using SimpleModule.Blazor.Inertia;` in Task 3. + +**Step 8: Update App.razor namespace reference** + +If `App.razor` references `SimpleModule.Api` anywhere, update to `SimpleModule.Host`. + +**Step 9: Update Routes.razor** + +Update any `typeof(App).Assembly` reference — it should still work since `App` is resolved via `_Imports.razor`. + +**Step 10: Update launchSettings.json** + +Update application name reference if present (usually just the profile name). + +**Step 11: Update root package.json workspaces** + +Change `"src/SimpleModule.Api/ClientApp"` → `"src/SimpleModule.Host/ClientApp"`. + +**Step 12: Update CLAUDE.md** + +Replace all occurrences of `SimpleModule.Api` with `SimpleModule.Host` throughout the file. + +**Step 13: Update biome.json if needed** + +Check if `biome.json` references `SimpleModule.Api` in any paths. + +**Step 14: Build and verify** + +Run: `dotnet build` +Run: `npm install` +Run: `npm run check` +Expected: BUILD SUCCEEDED, no lint errors + +**Step 15: Run tests** + +Run: `dotnet test` +Expected: All tests pass + +**Step 16: Commit** + +```bash +git add -A +git commit -m "refactor: rename SimpleModule.Api to SimpleModule.Host" +``` + +--- + +### Task 10: Final Verification and Cleanup + +**Step 1: Full build** + +Run: `dotnet build` +Expected: BUILD SUCCEEDED + +**Step 2: Run all tests** + +Run: `dotnet test` +Expected: All tests pass + +**Step 3: Lint check** + +Run: `npm run check` +Expected: No errors + +**Step 4: Verify the app runs** + +Run: `dotnet run --project src/SimpleModule.Host` +Expected: App starts on https://localhost:5001 + +**Step 5: Verify in browser** + +- Navigate to `https://localhost:5001/` — should show landing page (now served by Dashboard module via Inertia) +- Log in — should show dashboard +- Navigate to `/products/browse` — should work (modules unchanged) +- Check dark mode toggle works +- Check user dropdown works + +**Step 6: Update CLAUDE.md build commands** + +Ensure all references match the new structure: +```bash +dotnet run --project src/SimpleModule.Host +``` + +**Step 7: Final commit if any cleanup needed** + +```bash +git add -A +git commit -m "chore: final cleanup after host restructure" +``` diff --git a/docs/plans/2026-03-16-iviewendpoint-design.md b/docs/plans/2026-03-16-iviewendpoint-design.md new file mode 100644 index 00000000..b802dd63 --- /dev/null +++ b/docs/plans/2026-03-16-iviewendpoint-design.md @@ -0,0 +1,38 @@ +# IViewEndpoint Interface Design + +## Goal + +Separate view endpoints (Inertia pages) from API endpoints by introducing an `IViewEndpoint` interface. View endpoints should be excluded from Swagger/OpenAPI documentation automatically. + +## Design + +### New Interface — `SimpleModule.Core.IViewEndpoint` + +```csharp +public interface IViewEndpoint +{ + void Map(IEndpointRouteBuilder app); +} +``` + +Independent from `IEndpoint` (no inheritance). Same signature — acts as a marker for the source generator. + +### Source Generator Changes (`ModuleDiscovererGenerator`) + +1. Resolve `SimpleModule.Core.IViewEndpoint` symbol alongside `IEndpoint` +2. Replace namespace-based classification (`.Views.` check) with interface-based: + - `IEndpoint` implementors → API endpoints + - `IViewEndpoint` implementors → view endpoints +3. Page name derivation unchanged (strip `Endpoint`/`View` suffix from class name) +4. Generated `MapModuleEndpoints()`: view endpoint route groups get `.ExcludeFromDescription()` to hide from Swagger + +### Module Migration + +All existing view endpoints (in `Views/` folders across Products, Users, Dashboard, Orders) change from `IEndpoint` to `IViewEndpoint`. + +### Unchanged + +- `Inertia.Render()` usage +- Route grouping with `ViewPrefix` +- `ConfigureEndpoints()` escape hatch +- `GenerateViewPages()` output diff --git a/docs/plans/2026-03-16-iviewendpoint-plan.md b/docs/plans/2026-03-16-iviewendpoint-plan.md new file mode 100644 index 00000000..51c2c8dc --- /dev/null +++ b/docs/plans/2026-03-16-iviewendpoint-plan.md @@ -0,0 +1,313 @@ +# IViewEndpoint Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Separate view endpoints from API endpoints via a new `IViewEndpoint` marker interface, with automatic Swagger exclusion for views. + +**Architecture:** Add `IViewEndpoint` to Core with identical signature to `IEndpoint`. Update the source generator to classify endpoints by interface type instead of namespace convention. View route groups get `.ExcludeFromDescription()` in generated code. + +**Tech Stack:** C# / Roslyn IIncrementalGenerator / ASP.NET Core Minimal APIs / xUnit + FluentAssertions + +--- + +### Task 1: Add IViewEndpoint interface to Core + +**Files:** +- Create: `src/SimpleModule.Core/IViewEndpoint.cs` + +**Step 1: Create the interface** + +```csharp +using Microsoft.AspNetCore.Routing; + +namespace SimpleModule.Core; + +public interface IViewEndpoint +{ + void Map(IEndpointRouteBuilder app); +} +``` + +**Step 2: Verify build** + +Run: `dotnet build src/SimpleModule.Core` +Expected: Build succeeded + +**Step 3: Commit** + +```bash +git add src/SimpleModule.Core/IViewEndpoint.cs +git commit -m "feat: add IViewEndpoint marker interface" +``` + +--- + +### Task 2: Update source generator to use interface-based classification + +**Files:** +- Modify: `src/SimpleModule.Generator/ModuleDiscovererGenerator.cs` + +**Step 1: Resolve the IViewEndpoint symbol** + +In `ExtractDiscoveryData`, after resolving `IEndpoint` (line ~56), add: + +```csharp +var viewEndpointInterfaceSymbol = compilation.GetTypeByMetadataName( + "SimpleModule.Core.IViewEndpoint" +); +``` + +**Step 2: Update FindEndpointTypes to accept both interface symbols** + +Change the signature to: + +```csharp +private static void FindEndpointTypes( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol endpointInterfaceSymbol, + INamedTypeSymbol? viewEndpointInterfaceSymbol, + string moduleName, + List endpoints, + List views +) +``` + +**Step 3: Replace namespace-based classification with interface check** + +Replace the body of the type classification (lines 275-314) with: + +```csharp +if ( + !typeSymbol.IsAbstract + && !typeSymbol.IsStatic +) +{ + var fqn = typeSymbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ); + + if (viewEndpointInterfaceSymbol is not null + && ImplementsInterface(typeSymbol, viewEndpointInterfaceSymbol)) + { + var className = typeSymbol.Name; + if (className.EndsWith("Endpoint", StringComparison.Ordinal)) + className = className.Substring( + 0, + className.Length - "Endpoint".Length + ); + else if (className.EndsWith("View", StringComparison.Ordinal)) + className = className.Substring( + 0, + className.Length - "View".Length + ); + + views.Add( + new ViewInfo + { + FullyQualifiedName = fqn, + Page = moduleName + "/" + className, + } + ); + } + else if (ImplementsInterface(typeSymbol, endpointInterfaceSymbol)) + { + endpoints.Add( + new EndpointInfo { FullyQualifiedName = fqn } + ); + } +} +``` + +**Step 4: Update the call site in ExtractDiscoveryData** + +Pass `viewEndpointInterfaceSymbol` to `FindEndpointTypes`: + +```csharp +FindEndpointTypes( + assembly.GlobalNamespace, + endpointInterfaceSymbol, + viewEndpointInterfaceSymbol, + module.ModuleName, + module.Endpoints, + module.Views +); +``` + +**Step 5: Add `.ExcludeFromDescription()` to view route groups in GenerateEndpointExtensions** + +In the generated view group line (currently line 548), change from: + +```csharp +$" var viewGroup = app.MapGroup(\"{module.ViewPrefix}\").WithTags(\"{module.ModuleName}\");" +``` + +to: + +```csharp +$" var viewGroup = app.MapGroup(\"{module.ViewPrefix}\").WithTags(\"{module.ModuleName}\").ExcludeFromDescription();" +``` + +Also add the `using Microsoft.AspNetCore.Http;` import at the top of the generated file (for `ExcludeFromDescription`). Add after the existing `using Microsoft.AspNetCore.Routing;` line: + +```csharp +sb.AppendLine("using Microsoft.AspNetCore.Http;"); +``` + +For views without a ViewPrefix (the else branch), add `.ExcludeFromDescription()` on the individual endpoint mappings isn't possible directly — but this case means views are mapped directly on `app`, which is unusual. Leave as-is for now since all modules use ViewPrefix. + +**Step 6: Verify build** + +Run: `dotnet build src/SimpleModule.Generator` +Expected: Build succeeded + +**Step 7: Commit** + +```bash +git add src/SimpleModule.Generator/ModuleDiscovererGenerator.cs +git commit -m "feat: classify endpoints by IViewEndpoint interface, exclude views from Swagger" +``` + +--- + +### Task 3: Update generator tests for IViewEndpoint + +**Files:** +- Modify: `tests/SimpleModule.Generator.Tests/ViewDiscoveryTests.cs` + +**Step 1: Update all test sources to use IViewEndpoint instead of IEndpoint in Views namespace** + +In every test that has view endpoints in a `.Views.` namespace, change `IEndpoint` to `IViewEndpoint`. For example, in `EndpointInViewsNamespace_DiscoveredAsView_RoutedUnderViewPrefix`: + +```csharp +namespace TestApp.Views +{ + public class CreateEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/create", () => "create"); + } + } +} +``` + +Apply this to all 5 tests that use view endpoints: +- `EndpointInViewsNamespace_DiscoveredAsView_RoutedUnderViewPrefix` +- `PageNameDerived_FromModuleNameAndClassName_StrippingEndpointSuffix` +- `ViewPages_GeneratesTypeScriptIndex` +- `ModuleWithViewsAndEndpoints_BothCoexist` +- `ViewClassName_WithViewSuffix_StrippedCorrectly` + +**Step 2: Update assertions to expect `.ExcludeFromDescription()`** + +In tests that check for `viewGroup`, update the assertion: + +```csharp +endpointExt.Should().Contain("var viewGroup = app.MapGroup(\"/test\").WithTags(\"Test\").ExcludeFromDescription()"); +``` + +**Step 3: Add a new test — IEndpoint in Views namespace is NOT discovered as view** + +```csharp +[Fact] +public void IEndpointInViewsNamespace_DiscoveredAsEndpoint_NotView() +{ + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Test", RoutePrefix = "/api/test", ViewPrefix = "/test")] + public class TestModule : IModule { } + } + + namespace TestApp.Views + { + public class ListEndpoint : IEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/", () => "list"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = result + .GeneratedTrees.First(t => + t.FilePath.EndsWith("EndpointExtensions.g.cs", StringComparison.Ordinal) + ) + .GetText() + .ToString(); + + endpointExt.Should().Contain("new global::TestApp.Views.ListEndpoint().Map(group)"); + endpointExt.Should().NotContain("viewGroup"); +} +``` + +**Step 4: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add tests/SimpleModule.Generator.Tests/ViewDiscoveryTests.cs +git commit -m "test: update view discovery tests for IViewEndpoint interface" +``` + +--- + +### Task 4: Migrate Products view endpoints to IViewEndpoint + +**Files:** +- Modify: `src/modules/Products/src/Products/Views/BrowseEndpoint.cs` +- Modify: `src/modules/Products/src/Products/Views/ManageEndpoint.cs` +- Modify: `src/modules/Products/src/Products/Views/CreateEndpoint.cs` +- Modify: `src/modules/Products/src/Products/Views/EditEndpoint.cs` + +**Step 1: In each file, change `IEndpoint` to `IViewEndpoint`** + +Example for BrowseEndpoint.cs: + +```csharp +public class BrowseEndpoint : IViewEndpoint +``` + +Same for ManageEndpoint, CreateEndpoint, EditEndpoint. + +**Step 2: Verify build** + +Run: `dotnet build src/modules/Products/src/Products` +Expected: Build succeeded + +**Step 3: Commit** + +```bash +git add src/modules/Products/src/Products/Views/ +git commit -m "refactor: migrate Products view endpoints to IViewEndpoint" +``` + +--- + +### Task 5: Full build and verify + +**Step 1: Build the whole solution** + +Run: `dotnet build` +Expected: Build succeeded + +**Step 2: Run all tests** + +Run: `dotnet test` +Expected: All tests pass + +**Step 3: Commit (if any remaining changes)** + +Only if needed. diff --git a/docs/plans/2026-03-16-split-generator.md b/docs/plans/2026-03-16-split-generator.md new file mode 100644 index 00000000..f29ede4a --- /dev/null +++ b/docs/plans/2026-03-16-split-generator.md @@ -0,0 +1,121 @@ +# Split ModuleDiscovererGenerator Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Split the 1080-line `ModuleDiscovererGenerator.cs` into 3 partial-class files for maintainability. + +**Architecture:** Use C# `partial class` to split by concern — orchestration, code emitters, and data models. All files stay in the same namespace and project. Pure refactoring, no behavior changes. + +**Tech Stack:** C# / Roslyn source generators / netstandard2.0 + +--- + +### Task 1: Create ModuleDiscovererGenerator.Models.cs + +**Files:** +- Create: `src/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs` +- Modify: `src/SimpleModule.Generator/ModuleDiscovererGenerator.cs` (remove moved code) + +**Step 1: Create the Models file** + +Create `src/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs` containing: +- The `using` statements needed: `System.Collections.Generic`, `System.Collections.Immutable` +- `partial class ModuleDiscovererGenerator` in namespace `SimpleModule.Generator` +- Move the entire `#region Equatable data model for incremental caching` section (lines 914–1033): + - `DiscoveryData` record struct + - `ModuleInfoRecord` record struct + - `EndpointInfoRecord` record struct + - `ViewInfoRecord` record struct + - `DtoTypeInfoRecord` record struct + - `DtoPropertyInfoRecord` record struct +- Move the entire `#region Mutable working types` section (lines 1037–1078): + - `ModuleInfo` class + - `EndpointInfo` class + - `ViewInfo` class + - `DtoTypeInfo` class + - `DtoPropertyInfo` class + +**Step 2: Remove moved code from original file** + +In `ModuleDiscovererGenerator.cs`, delete everything from `#region Equatable data model` through the closing `#endregion` of Mutable working types (lines 914–1078). + +**Step 3: Build to verify** + +Run: `dotnet build src/SimpleModule.Generator/SimpleModule.Generator.csproj` +Expected: Build succeeds with no errors. + +**Step 4: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests/` +Expected: All tests pass. + +**Step 5: Commit** + +```bash +git add src/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs src/SimpleModule.Generator/ModuleDiscovererGenerator.cs +git commit -m "refactor: extract data models from ModuleDiscovererGenerator into partial class" +``` + +--- + +### Task 2: Create ModuleDiscovererGenerator.Emitters.cs + +**Files:** +- Create: `src/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` +- Modify: `src/SimpleModule.Generator/ModuleDiscovererGenerator.cs` (remove moved code) + +**Step 1: Create the Emitters file** + +Create `src/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` containing: +- The `using` statements needed: `System`, `System.Collections.Immutable`, `System.Linq`, `System.Text`, `Microsoft.CodeAnalysis`, `Microsoft.CodeAnalysis.Text` +- `partial class ModuleDiscovererGenerator` in namespace `SimpleModule.Generator` +- Move all `Generate*` methods: + - `GenerateModuleExtensions` (lines 407–468) + - `GenerateEndpointExtensions` (lines 470–569) + - `GenerateMenuExtensions` (lines 571–605) + - `GenerateJsonResolver` (lines 607–685) + - `GenerateTypeScriptDefinitions` (lines 687–722) + - `GenerateViewPages` (lines 724–776) + - `GenerateRazorComponentExtensions` (lines 865–906) +- Move the utility methods used exclusively by emitters: + - `MapCSharpTypeToTypeScript` (lines 778–830) + - `GetModuleFieldName` (lines 908–912) + +**Step 2: Remove moved code from original file** + +In `ModuleDiscovererGenerator.cs`, delete all the `Generate*` methods, `MapCSharpTypeToTypeScript`, and `GetModuleFieldName`. The file should now contain only: +- `Initialize` method +- `ExtractDiscoveryData` method +- `FindModuleTypes`, `FindEndpointTypes`, `FindDtoTypes` methods +- `HasComponentBaseDescendant`, `InheritsFrom`, `ImplementsInterface`, `DeclaresMethod` helper methods + +**Step 3: Build to verify** + +Run: `dotnet build src/SimpleModule.Generator/SimpleModule.Generator.csproj` +Expected: Build succeeds with no errors. + +**Step 4: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests/` +Expected: All tests pass. + +**Step 5: Commit** + +```bash +git add src/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs src/SimpleModule.Generator/ModuleDiscovererGenerator.cs +git commit -m "refactor: extract code emitters from ModuleDiscovererGenerator into partial class" +``` + +--- + +### Task 3: Final verification + +**Step 1: Full solution build** + +Run: `dotnet build` +Expected: Entire solution builds with no errors. + +**Step 2: Full test suite** + +Run: `dotnet test` +Expected: All tests pass. diff --git a/docs/plans/2026-03-16-ui-library-design.md b/docs/plans/2026-03-16-ui-library-design.md new file mode 100644 index 00000000..a512e883 --- /dev/null +++ b/docs/plans/2026-03-16-ui-library-design.md @@ -0,0 +1,164 @@ +# Design: `@simplemodule/ui` — Shared Component Library + +**Date:** 2026-03-16 +**Status:** Approved + +## Overview + +A shadcn-style shared React component library for SimpleModule. Components are source files owned by the project (not a compiled npm package), built on Radix UI headless primitives and styled with cva + Tailwind referencing theme CSS variables. + +## Architecture + +- **Package:** `src/SimpleModule.UI/` as npm workspace `@simplemodule/ui` +- **Stack:** Radix UI (headless), class-variance-authority (cva), tailwind-merge + clsx via `cn()` +- **Design tokens:** Stay in `@simplemodule/theme-default` as CSS variables. Components reference them via Tailwind classes (`bg-primary`, `text-danger`, etc.) +- **No pre-build step:** Modules import source TSX directly; Vite handles transpilation + +### File Structure + +``` +src/SimpleModule.UI/ +├── package.json +├── components/ # active components (added via CLI) +│ └── index.ts # barrel re-export +├── lib/ +│ └── utils.ts # cn() helper +└── registry/ + ├── registry.json # component metadata + └── templates/ # all available component templates + ├── button.tsx + ├── input.tsx + ├── dialog.tsx + └── ... +``` + +### Module Consumption + +```tsx +import { Button, Card, Input, Badge } from '@simplemodule/ui'; +``` + +## Component List (Starter Set — ~18 components) + +| Component | Radix Primitive | Key Variants | +|-----------|----------------|--------------| +| Button | Slot (asChild) | variant: primary, secondary, ghost, danger, outline / size: sm, default, lg | +| Input | — | variant: default, error | +| Textarea | — | — | +| Label | @radix-ui/react-label | — | +| Select | @radix-ui/react-select | — | +| Checkbox | @radix-ui/react-checkbox | — | +| Radio Group | @radix-ui/react-radio-group | — | +| Switch | @radix-ui/react-switch | — | +| Dialog | @radix-ui/react-dialog | — | +| Dropdown Menu | @radix-ui/react-dropdown-menu | — | +| Popover | @radix-ui/react-popover | — | +| Tabs | @radix-ui/react-tabs | — | +| Table | — | — | +| Card | — | — | +| Badge | — | variant: success, danger, warning, info, default | +| Alert | — | variant: success, danger, warning, info | +| Separator | @radix-ui/react-separator | — | +| Spinner | — | size: sm, default, lg | + +## Styling Approach + +Components use cva for variant-driven styling with Tailwind classes that resolve to theme CSS variables. + +Example (Button): +```tsx +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 rounded-xl text-sm font-semibold transition-all duration-200 active:scale-[0.97] cursor-pointer', + { + variants: { + variant: { + primary: 'text-white bg-gradient-to-br from-primary to-accent shadow-(--shadow-primary) hover:shadow-(--shadow-primary-hover) hover:-translate-y-px', + secondary: 'bg-surface text-text border border-border hover:bg-surface-raised hover:border-border-strong', + ghost: 'bg-transparent text-text-secondary hover:bg-primary-subtle hover:text-primary', + danger: 'text-white bg-danger shadow-(--shadow-danger) hover:bg-danger-hover hover:shadow-(--shadow-danger-hover) hover:-translate-y-px', + outline: 'bg-transparent text-primary border-2 border-primary/30 hover:bg-primary-subtle hover:border-primary', + }, + size: { + sm: 'px-3.5 py-1.5 text-xs rounded-lg', + default: 'px-5 py-2.5', + lg: 'px-8 py-3.5 text-base', + }, + }, + defaultVariants: { variant: 'primary', size: 'default' }, + } +); +``` + +## Theming + +Components are fully theme-aware through CSS variables: + +``` +@simplemodule/theme-default (theme.css) + defines: --color-primary, --shadow-primary, etc. + ↓ +Tailwind @theme block + maps: bg-primary → var(--color-primary) + ↓ +@simplemodule/ui components + uses: Tailwind classes like 'bg-primary', 'text-danger' + ↓ +Browser renders current variable values +``` + +To create a new theme, create a new theme package with the same CSS variable names and different values. Swap the import in `app.css`. All components update automatically. + +### Shadow Tokens (new, added to theme) + +```css +@theme { + --shadow-primary: 0 4px 14px rgba(13, 148, 136, 0.35); + --shadow-primary-hover: 0 6px 20px rgba(13, 148, 136, 0.5); + --shadow-danger: 0 4px 14px rgba(225, 29, 72, 0.25); + --shadow-danger-hover: 0 6px 20px rgba(225, 29, 72, 0.4); +} +``` + +## CLI Tool + +**Location:** `tools/add-component.mjs` + npm script alias `npm run ui:add` + +**Usage:** +```bash +npm run ui:add -- button # add single component +npm run ui:add -- dialog badge # add multiple +npm run ui:add -- --list # show available components +``` + +**Behavior:** +1. Reads `registry/registry.json` for component metadata +2. Copies template from `registry/templates/.tsx` → `components/.tsx` +3. Auto-installs Radix packages if needed +4. Resolves dependencies (e.g., dialog pulls in button) +5. Updates `components/index.ts` barrel export + +### Registry Format + +```json +{ + "button": { + "name": "Button", + "file": "button.tsx", + "dependencies": [], + "radixPackage": null + }, + "dialog": { + "name": "Dialog", + "file": "dialog.tsx", + "dependencies": ["button"], + "radixPackage": "@radix-ui/react-dialog" + } +} +``` + +## Migration + +- **No breaking changes.** Existing CSS classes (`btn-primary`, `glass-card`, etc.) stay in theme +- Modules adopt `@simplemodule/ui` components incrementally +- CSS classes can be retired once all modules have migrated off them +- No module Vite config changes needed — UI source gets bundled into each module's `.pages.js` diff --git a/docs/plans/2026-03-16-ui-library-plan.md b/docs/plans/2026-03-16-ui-library-plan.md new file mode 100644 index 00000000..4a0d7f54 --- /dev/null +++ b/docs/plans/2026-03-16-ui-library-plan.md @@ -0,0 +1,1707 @@ +# @simplemodule/ui Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Create a shadcn-style shared React component library with ~18 components, a CLI tool, and Radix UI primitives — all themed via CSS variables. + +**Architecture:** New npm workspace `@simplemodule/ui` at `src/SimpleModule.UI/`. Components are source TSX files styled with cva + Tailwind classes referencing `@simplemodule/theme-default` CSS variables. A CLI tool (`tools/add-component.mjs`) copies component templates into the active components directory. + +**Tech Stack:** Radix UI, class-variance-authority (cva), clsx, tailwind-merge, React 19 + +**Design doc:** `docs/plans/2026-03-16-ui-library-design.md` + +--- + +### Task 1: Scaffold `@simplemodule/ui` package and install dependencies + +**Files:** +- Create: `src/SimpleModule.UI/package.json` +- Create: `src/SimpleModule.UI/lib/utils.ts` +- Create: `src/SimpleModule.UI/components/index.ts` +- Create: `src/SimpleModule.UI/registry/registry.json` +- Modify: `package.json` (root — add workspace) + +**Step 1: Create package.json** + +```json +{ + "private": true, + "name": "@simplemodule/ui", + "type": "module", + "main": "components/index.ts", + "exports": { + ".": "./components/index.ts", + "./lib/utils": "./lib/utils.ts" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "tailwind-merge": "^3.0.0", + "@radix-ui/react-slot": "^1.2.0" + } +} +``` + +**Step 2: Add workspace to root package.json** + +Add `"src/SimpleModule.UI"` to the `workspaces` array in `package.json`: + +```json +"workspaces": [ + "src/modules/*/src/*", + "src/SimpleModule.Client", + "src/SimpleModule.Theme.Default", + "src/SimpleModule.UI", + "src/SimpleModule.Host/ClientApp" +] +``` + +**Step 3: Create `cn()` utility** + +Create `src/SimpleModule.UI/lib/utils.ts`: + +```ts +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +**Step 4: Create empty barrel export** + +Create `src/SimpleModule.UI/components/index.ts`: + +```ts +// Components are added here by the CLI tool (npm run ui:add) +``` + +**Step 5: Create empty registry** + +Create `src/SimpleModule.UI/registry/registry.json`: + +```json +{} +``` + +**Step 6: Install dependencies** + +Run: `npm install` +Expected: Clean install with new workspace resolved + +**Step 7: Verify TypeScript resolves the package** + +Run: `npx tsc --noEmit --project tsconfig.json 2>&1 | head -5` +Expected: No errors related to `@simplemodule/ui` + +**Step 8: Commit** + +```bash +git add src/SimpleModule.UI/ package.json package-lock.json +git commit -m "feat: scaffold @simplemodule/ui workspace with cn() utility" +``` + +--- + +### Task 2: Add shadow tokens to theme + +**Files:** +- Modify: `src/SimpleModule.Theme.Default/theme.css:6-64` (inside `@theme` block) + +**Step 1: Add shadow tokens** + +Add these lines at the end of the `@theme` block (before the closing `}`), after the `--color-muted` line: + +```css + /* --- Shadows (themeable) --- */ + --shadow-primary: 0 4px 14px rgba(13, 148, 136, 0.35); + --shadow-primary-hover: 0 6px 20px rgba(13, 148, 136, 0.5); + --shadow-danger: 0 4px 14px rgba(225, 29, 72, 0.25); + --shadow-danger-hover: 0 6px 20px rgba(225, 29, 72, 0.4); +``` + +**Step 2: Verify the host app builds CSS** + +Run: `cd src/SimpleModule.Host && dotnet build 2>&1 | tail -3` +Expected: Build succeeded + +**Step 3: Commit** + +```bash +git add src/SimpleModule.Theme.Default/theme.css +git commit -m "feat: add shadow tokens to theme for themeable button/danger shadows" +``` + +--- + +### Task 3: Create Button component template + +**Files:** +- Create: `src/SimpleModule.UI/registry/templates/button.tsx` +- Modify: `src/SimpleModule.UI/registry/registry.json` + +**Step 1: Create button template** + +Create `src/SimpleModule.UI/registry/templates/button.tsx`: + +```tsx +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '../lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 rounded-xl text-sm font-semibold transition-all duration-200 active:scale-[0.97] cursor-pointer disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + primary: + 'text-white bg-gradient-to-br from-primary to-accent shadow-(--shadow-primary) hover:shadow-(--shadow-primary-hover) hover:-translate-y-px', + secondary: + 'bg-surface text-text border border-border hover:bg-surface-raised hover:border-border-strong', + ghost: 'bg-transparent text-text-secondary hover:bg-primary-subtle hover:text-primary', + danger: + 'text-white bg-danger shadow-(--shadow-danger) hover:bg-danger-hover hover:shadow-(--shadow-danger-hover) hover:-translate-y-px', + outline: + 'bg-transparent text-primary border-2 border-primary/30 hover:bg-primary-subtle hover:border-primary', + }, + size: { + sm: 'px-3.5 py-1.5 text-xs rounded-lg', + default: 'px-5 py-2.5', + lg: 'px-8 py-3.5 text-base', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'default', + }, + }, +); + +interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ; + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; +export type { ButtonProps }; +``` + +**Step 2: Add button to registry** + +Update `src/SimpleModule.UI/registry/registry.json`: + +```json +{ + "button": { + "name": "Button", + "file": "button.tsx", + "dependencies": [], + "radixPackages": ["@radix-ui/react-slot"], + "exports": ["Button", "buttonVariants"] + } +} +``` + +**Step 3: Commit** + +```bash +git add src/SimpleModule.UI/registry/ +git commit -m "feat: add Button component template to registry" +``` + +--- + +### Task 4: Create Input, Textarea, Label component templates + +**Files:** +- Create: `src/SimpleModule.UI/registry/templates/input.tsx` +- Create: `src/SimpleModule.UI/registry/templates/textarea.tsx` +- Create: `src/SimpleModule.UI/registry/templates/label.tsx` +- Modify: `src/SimpleModule.UI/registry/registry.json` + +**Step 1: Create input template** + +Create `src/SimpleModule.UI/registry/templates/input.tsx`: + +```tsx +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '../lib/utils'; + +const inputVariants = cva( + 'w-full px-4 py-3 bg-surface border rounded-xl text-sm text-text transition-all duration-200 placeholder:text-text-muted outline-none focus:border-primary focus:ring-4 focus:ring-primary-ring', + { + variants: { + variant: { + default: 'border-border', + error: 'border-danger focus:border-danger focus:ring-danger-bg', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +interface InputProps extends React.InputHTMLAttributes, VariantProps {} + +const Input = React.forwardRef(({ className, variant, type, ...props }, ref) => { + return ; +}); +Input.displayName = 'Input'; + +export { Input, inputVariants }; +export type { InputProps }; +``` + +**Step 2: Create textarea template** + +Create `src/SimpleModule.UI/registry/templates/textarea.tsx`: + +```tsx +import * as React from 'react'; +import { cn } from '../lib/utils'; + +interface TextareaProps extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef(({ className, ...props }, ref) => { + return ( +