From 3d918de1386b273451137f3cd649bb0f12348b41 Mon Sep 17 00:00:00 2001
From: Boromir <boromir@squad.dev>
Date: Fri, 8 May 2026 11:41:12 -0700
Subject: [PATCH 1/7] docs(scribe): log Ralph CI fix sprint session [skip ci]

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .squad/agents/ralph/history.md | 27 +++++++++++++++
 .squad/decisions/decisions.md  | 63 ++++++++++++++++++++++++++++++++++
 2 files changed, 90 insertions(+)

diff --git a/.squad/agents/ralph/history.md b/.squad/agents/ralph/history.md
index a91babde..2cc8e36b 100644
--- a/.squad/agents/ralph/history.md
+++ b/.squad/agents/ralph/history.md
@@ -134,3 +134,30 @@ Initial setup complete.
 - Remote state: `origin` now has only `dev` and `main`
 
 **Board state after:** 0 open issues, 0 open PRs. Board clear.
+
+### 2026-05-08 — Board Sweep: Release Labeling, Mutex Rename, CI Failures Filed
+
+**Trigger:** User "Ralph, go" — autonomous board sweep.
+
+**Board scan result:** 2 open issues (#265, #266). 0 open PRs at scan start.
+
+**Actions taken:**
+
+- **Issue #265** (Milestone Review): Decided Option A — release candidate, v1.5.0 minor bump (2 additive user-facing enhancements #259, #260; no breaking changes; CI green). Applied `release-candidate` label, removed `pending-review`, commented decision on issue. Issue auto-closed by `milestone-blog.yml`.
+- **Issue #266** (Rename `_clearMutex → _dbMutex`): Delegated to Sam. Sam created branch `squad/266-rename-clear-mutex-to-db-mutex`, renamed field across 7 sites in `src/AppHost/MongoDbResourceBuilderExtensions.cs` (1 declaration + 6 usage sites + 1 comment), ran pre-push gates (build 0 errors, Architecture.Tests 15/15, Domain.Tests 42/42, Integration.Tests 12/12), opened PR #267 targeting `dev`.
+- Identified 2 pre-existing CI failures; filed Issue #268 (`squad-mark-released.yml` fails — `GITHUB_TOKEN` lacks `project` scope for GraphQL) and Issue #269 (Blog→README Sync fails — direct push to `main` blocked by branch protection). Both labeled `squad:boromir,bug`.
+
+**Board state after:** Issue #265 closed, PR #267 open targeting `dev` (awaiting merge), Issues #268 and #269 queued for Boromir.
+
+### 2026-05-08 — CI Fix Sprint
+
+**Session issues closed:**
+
+- **#266** — rename `_clearMutex` → `_dbMutex` in `MongoDbResourceBuilderExtensions.cs` (PR #267, squash-merged)
+- **#268** — `squad-mark-released.yml` GraphQL permission error; added pre-flight `GH_PROJECT_TOKEN` validation, fixed `permissions: contents: read`, pinned `actions/github-script@v7` (PR #271, squash-merged)
+- **#269** — `blog-readme-sync.yml` direct push to `main` blocked by branch protection; changed to `git push origin HEAD:dev` (PR #270, squash-merged)
+
+**Notes:**
+- Board clear at end of session. No open squad issues or PRs.
+- `GH_PROJECT_TOKEN` secret must be set manually in repo Settings → Secrets with a PAT scoped to `project` for squad-mark-released to work.
+
diff --git a/.squad/decisions/decisions.md b/.squad/decisions/decisions.md
index 8d68b1c5..274a105f 100644
--- a/.squad/decisions/decisions.md
+++ b/.squad/decisions/decisions.md
@@ -2268,3 +2268,66 @@ Add a pre-commit git hook (`.github/hooks/pre-commit`) that runs `markdownlint-c
 - Contributors must run `npm install` to get the linter; the hook warns them if they haven't.
 
 ---
+
+---
+
+# Decision: GitHub Projects V2 requires a classic PAT with `project` scope
+
+**Date:** 2026-05-08  
+**Author:** Boromir (DevOps Engineer)  
+**Issue:** #268  
+**PR:** #271 (squash-merged)  
+**Status:** ✅ Implemented
+
+## Context
+
+The `squad-mark-released.yml` workflow uses `actions/github-script` to call the GitHub Projects V2 GraphQL API. The previous `permissions: repository-projects: write` block applied only to `GITHUB_TOKEN` and was ineffective for Projects V2 mutations.
+
+## Decision
+
+1. **`GH_PROJECT_TOKEN` secret is required** for any workflow that calls the GitHub Projects V2 GraphQL API. The default `GITHUB_TOKEN` cannot be used — it is not an integration token with project scope.
+
+2. **Required PAT scopes:**
+   - Classic PAT: `project` OAuth scope
+   - Fine-grained PAT: "Projects" → read + write
+
+3. **Workflow `permissions` block** should be `contents: read` (minimum) in workflows that rely solely on a custom PAT for external API access.
+
+4. **Pre-flight validation** — any workflow using `GH_PROJECT_TOKEN` must include a validation step that checks the secret is set and fails early with an actionable error message if it is missing.
+
+## Setup
+
+To configure the secret:
+1. Create a classic PAT at https://github.com/settings/tokens with `project` scope
+2. Add it as repository secret: Settings → Secrets and variables → Actions → `GH_PROJECT_TOKEN`
+
+---
+
+# Decision: Blog README Sync pushes to `dev`, not `main`
+
+**Date:** 2026-05-08  
+**Author:** Boromir (DevOps Engineer)  
+**Issue:** #269  
+**PR:** #270 (squash-merged)  
+**Status:** ✅ Implemented
+
+## Context
+
+The `blog-readme-sync.yml` workflow reads `docs/blog/index.md` from `main` and writes an updated `README.md`. It previously pushed directly back to `main`, which violates branch protection rules (direct pushes blocked, PR + "Build Solution" check required).
+
+## Decision
+
+The workflow's "Commit updated README" step now uses `git push origin HEAD:dev` instead of `git push`. README updates flow into `dev` and are released to `main` via the normal dev→main PR/release cycle.
+
+## Rationale
+
+- No new secrets or PAT permissions required.
+- Consistent with the project's branch flow: `squad/*` → `dev` → `main`.
+- `README.md` on `main` is slightly behind `dev` until the next release, which is acceptable — it reflects the released state.
+
+## Alternatives Considered
+
+- **Option A (PAT bypass):** Create a PAT with bypass permission. Rejected — adds secret management overhead and bypasses protection intentionally.
+- **Option B (PR from workflow):** Have the workflow open a PR to main. Rejected — requires `pull-requests: write`, adds noise, and needs the "Build Solution" check to pass before merge.
+- **Option C (push to dev) ← CHOSEN:** Simple one-line fix, no new permissions.
+

From d3ab2fa281484e10025a41b19033035c2b6baa73 Mon Sep 17 00:00:00 2001
From: Boromir <boromir@squad.dev>
Date: Fri, 8 May 2026 11:51:27 -0700
Subject: [PATCH 2/7] =?UTF-8?q?.squad:=20Merge=20Sprint=2018=20release=20d?=
 =?UTF-8?q?ecisions=20inbox=20=E2=86=92=20decisions.md?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Scribe merged 7 decision inbox files from Sprint 18 release orchestration:
- aragorn-apphost-tests-parallel-fix.md (xunit serialization fix)
- aragorn-board-sync-theme-closeout.md (board cleanup verification)
- aragorn-pr-review-262-257.md (PR #262, #257 approvals)
- boromir-249-apphost-clear-hardening.md (issue #249 AC1-AC3 implementation)
- boromir-cleanup-240.md (worktree cleanup)
- boromir-release-pr-opened.md (PR #272 opened)
- boromir-theme-cleanup.md (post-theme branch cleanup)

Session artifacts created:
- .squad/orchestration-log/2026-05-08T18:49:02Z-boromir.md
- .squad/log/2026-05-08T18:49:02Z-release-pr272.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .squad/decisions.md | 127 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 127 insertions(+)

diff --git a/.squad/decisions.md b/.squad/decisions.md
index 19a2253b..c6a79ef8 100644
--- a/.squad/decisions.md
+++ b/.squad/decisions.md
@@ -1948,3 +1948,130 @@ This model choice supersedes the Layer 0 defaults in all squad sessions going fo
 - Should this decision apply retroactively to existing tests in the codebase? (Not in scope for this decision; can be a future refactoring sprint.)
 - Should PR reviews include explicit checks for TDD violations? (Already covered by Aragorn's PR gate; this formalizes the standard.)
 - If other squad members would benefit from GPT-5.4 overrides, document those decisions separately with similar rationale.
+
+---
+
+## Sprint 18 Release Decisions
+
+### AppHost.Tests CI Hang Fix — Parallel Collection Serialization
+
+**Date:** 2025-07-25  
+**Author:** Aragorn (Lead Developer)  
+**Branch:** squad/247-mongo-clear-command-tests  
+**PR:** #251  
+
+#### Problem
+
+`AppHost.Tests` was hanging in CI (PR #251) while all other test jobs were green. Root cause: `xunit.runner.json` had `parallelizeTestCollections: true`. The assembly contains two xUnit collections that each boot a full Aspire host (with DCP + Docker MongoDB). When both collections started simultaneously, they competed for DCP resources, causing `App.StartAsync()` to hang indefinitely.
+
+#### Fix
+
+Changed `tests/AppHost.Tests/xunit.runner.json`:
+
+```diff
+- "parallelizeTestCollections": true
++ "parallelizeTestCollections": false
+```
+
+This serializes xUnit collections, allowing only one Aspire host to start at a time. No Docker volume conflicts, no DCP contention, no hang.
+
+#### Rationale
+
+Minimum-correct change: one line, zero code changes, zero regression risk. Sequential execution adds ~5-10 minutes over prior parallel execution — well within the 45-minute CI budget.
+
+---
+
+### PR Review Outcomes — #262 and #257
+
+**Author:** Aragorn (Lead Developer)  
+**Date:** 2026-05-08  
+
+#### PR #262 — APPROVED and squash-merged ✅
+
+**Branch:** `squad/259-extract-withcleardatabasecommand`  
+**Closes:** #259  
+**Author:** Sam (Backend/.NET)  
+
+Sam's refactor cleanly extracts the inline `WithCommand` clear-data block from `AppHost.cs` into `MongoDbResourceBuilderExtensions`. All checklist items passed. CI green.
+
+Two Copilot inline comments flagged for follow-up (not blocking current single-instance project):
+
+1. **Static SemaphoreSlim** — `_clearMutex` is `static readonly` at class level, violating the extension's reusability contract. Harmless in this project but should be fixed before a second MongoDB resource is added.
+2. **Hard-coded database name in UX strings** — Parameter drives logic but UI strings still hard-code "myblog".
+
+**Decision:** Both are legitimate design defects but do not affect current behavior. A follow-up issue should be raised.
+
+#### PR #257 — APPROVED and squash-merged ✅
+
+**Branch:** `squad/256-fix-squad-mark-released-token`  
+**Closes:** #256  
+**Author:** mpaulosky  
+
+Correct fix: `secrets.GITHUB_TOKEN` lacks Projects V2 GraphQL mutation rights; `secrets.GH_PROJECT_TOKEN` (a PAT with project scope) is the correct credential. No hardcoded secrets. No `.squad/` files.
+
+Branch was behind dev after PR #262 landed. Required manual: `git fetch → git merge origin/dev → git push` before merge. **Lesson:** Merge concurrent PRs in strict priority order and update downstream branches before attempting merge.
+
+---
+
+### Issue #249 AppHost Clear Hardening — Implementation Choices
+
+**Date:** 2026-05-08  
+**Author:** Boromir  
+
+Issue #249 asks for three resilience properties on the `clear-myblog-data` operator action. All are AppHost/runtime concerns.
+
+#### AC1: UpdateState gates only on mongo health
+
+**Decision:** The existing `UpdateState` lambda was already correct. Added an explicit comment rather than a code change.
+
+**Rationale:** `UpdateState` receives a snapshot of the resource the command belongs to (`mongodb`). Checking the `web` resource's state from here would couple the clear command's availability to the liveness of the application layer—not a valid reason to disable a DBA operator action.
+
+#### AC2: Single-run protection via SemaphoreSlim(1,1) + WaitAsync(0)
+
+**Decision:** Declared `var clearMutex = new SemaphoreSlim(1, 1)` in top-level statements scope and applied `WaitAsync(0)` (non-blocking try-acquire) at the top of the `executeCommand` lambda. Failure to acquire returns `{ Success = false, Message = "..." }` immediately. The semaphore is released in `finally`.
+
+**Rationale:**
+
+- `WaitAsync(0)` is the idiomatic .NET non-blocking semaphore try-acquire
+- `finally` guarantees release even on early-return paths, preventing permanent lock-out
+- Top-level scope is appropriate: the `clearMutex` is a process-lifetime singleton and protects a single resource
+
+#### AC3: Best-effort per-collection via per-collection try/catch
+
+**Decision:** Wrapped each `DeleteManyAsync` call in its own `catch` block for
+`Exception ex when (ex is not OperationCanceledException)`. Caught exceptions are logged at Warning
+level, appended to a warnings list, and do NOT halt the loop. The final result message appends
+`⚠️ {N} collection(s) had errors: ...` when warnings exist. `OperationCanceledException` is
+intentionally excluded to allow operator-initiated cancellation to propagate normally.
+
+**Rationale:** Issue #249 AC3 states: *"the action continues remaining collections and returns warnings plus partial-progress results."* This implementation satisfies that literally.
+
+#### Gimli Follow-up (AC4)
+
+Tests should cover: (1) UpdateState returns Enabled with mongo Healthy regardless of web state; (2) Two concurrent handler invocations—exactly one succeeds, other returns failure; (3) Simulate a handler where the second collection throws—assert first and third collections appear in results, second in warnings, Success = true overall.
+
+---
+
+### Sprint 18 Release PR #272 Opened
+
+**Date:** 2026-05-08  
+**Author:** Boromir (DevOps)  
+
+Release PR **#272** has been opened to promote `dev` → `main` for Sprint 18.
+
+**PR:** [#272 — [RELEASE] Promote dev to main — Sprint 18](https://github.com/mpaulosky/MyBlog/pull/272)  
+**Branch:** `dev` → `main`  
+**Commits ahead:** 55  
+**Sprint 18 PRs included:** #262, #263, #264, #267, #270, #271  
+
+**CI status at PR open:**
+
+- ✅ Squad CI (`ci.yml`) — green on latest `dev` commit
+- ⚠️ Test Suite — 1 flaky failure: `SeedMyBlogData Concurrent Invocations` (timing race in test harness; not a production regression). Squad CI is the authoritative gate.
+
+**Next steps:**
+
+1. Aragorn reviews scope and approves
+2. PR CI must pass before merge
+3. Merge to `main` via squash merge
+4. Tag `main` with appropriate `vX.Y.Z` after CI green

From 70d4c4bc8a36541191cfd4091d4a73885066a640 Mon Sep 17 00:00:00 2001
From: Boromir <boromir@squad.dev>
Date: Sat, 9 May 2026 07:23:22 -0700
Subject: [PATCH 3/7] =?UTF-8?q?wip(theme):=20save=20in-progress=20CSS=20ch?=
 =?UTF-8?q?anges=20=E2=80=94=20@theme=20inline=20removed,=20outline=20fixe?=
 =?UTF-8?q?s?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Preserving unstaged changes ahead of #276 implementation.
- Removed @theme inline block (to be restored per issue #276)
- Fixed outline shorthand on .valid/.invalid form validation classes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/Web/Styles/input.css    | 17 ++---------------
 src/Web/wwwroot/css/app.css |  4 ++--
 2 files changed, 4 insertions(+), 17 deletions(-)

diff --git a/src/Web/Styles/input.css b/src/Web/Styles/input.css
index b8a88127..0d92710a 100644
--- a/src/Web/Styles/input.css
+++ b/src/Web/Styles/input.css
@@ -12,19 +12,6 @@
 /* ─── Tailwind design tokens (runtime CSS var resolution) ────────────────── */
 /* @theme inline generates utility classes (bg-primary-400, text-primary-50,  */
 /* etc.) whose values resolve from CSS vars at runtime.                       */
-@theme inline {
-	--color-primary-50:  var(--primary-50);
-	--color-primary-100: var(--primary-100);
-	--color-primary-200: var(--primary-200);
-	--color-primary-300: var(--primary-300);
-	--color-primary-400: var(--primary-400);
-	--color-primary-500: var(--primary-500);
-	--color-primary-600: var(--primary-600);
-	--color-primary-700: var(--primary-700);
-	--color-primary-800: var(--primary-800);
-	--color-primary-900: var(--primary-900);
-	--color-primary-950: var(--primary-950);
-}
 
 @layer base {
 	/* ─── Common element standardization ──────────────────────────────────── */
@@ -87,11 +74,11 @@
 
 	/* ─── Blazor form validation ──────────────────────────────────────────── */
 	.valid.modified:not([type=checkbox]) {
-		@apply outline outline-1 outline-green-500;
+		@apply outline-1 outline-green-500;
 	}
 
 	.invalid {
-		@apply outline outline-1 outline-red-500;
+		@apply outline-1 outline-red-500;
 	}
 
 	.validation-message {
diff --git a/src/Web/wwwroot/css/app.css b/src/Web/wwwroot/css/app.css
index 0430827a..e89c9109 100644
--- a/src/Web/wwwroot/css/app.css
+++ b/src/Web/wwwroot/css/app.css
@@ -153,11 +153,11 @@
 
 	/* ─── Blazor form validation ─────────────────────────────────────────────── */
 	.valid.modified:not([type=checkbox]) {
-		@apply outline outline-1 outline-green-500;
+		@apply outline-1 outline-green-500;
 	}
 
 	.invalid {
-		@apply outline outline-1 outline-red-500;
+		@apply outline-1 outline-red-500;
 	}
 
 	.validation-message {

From 84e3e44436c87a26dbb6eedd5911a5431789d753 Mon Sep 17 00:00:00 2001
From: Boromir <boromir@squad.dev>
Date: Sat, 9 May 2026 08:12:38 -0700
Subject: [PATCH 4/7] feat(themes): compress palettes to pastel range; restore
 @theme inline
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Compress all four OKLCH palettes so --primary-950 = old --primary-500
  lightness (pastel top end, near-white bottom unchanged)
- Fix yellow hue drift: hold hue at 87-103° throughout (was drifting to
  amber/brown 47-62° at 500-950)
- Restore @theme inline block in input.css mapping --color-primary-* to
  --primary-* CSS vars (was missing, breaking bg-primary-* utilities)
- Rebuild tailwind.css (output is gitignored, built on deploy)

Closes #276 (partial - palette foundation, element assignments follow)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/Web/Styles/input.css  | 13 ++++++
 src/Web/Styles/themes.css | 85 ++++++++++++++++++++-------------------
 2 files changed, 56 insertions(+), 42 deletions(-)

diff --git a/src/Web/Styles/input.css b/src/Web/Styles/input.css
index 0d92710a..43f3ae69 100644
--- a/src/Web/Styles/input.css
+++ b/src/Web/Styles/input.css
@@ -12,6 +12,19 @@
 /* ─── Tailwind design tokens (runtime CSS var resolution) ────────────────── */
 /* @theme inline generates utility classes (bg-primary-400, text-primary-50,  */
 /* etc.) whose values resolve from CSS vars at runtime.                       */
+@theme inline {
+	--color-primary-50:  var(--primary-50);
+	--color-primary-100: var(--primary-100);
+	--color-primary-200: var(--primary-200);
+	--color-primary-300: var(--primary-300);
+	--color-primary-400: var(--primary-400);
+	--color-primary-500: var(--primary-500);
+	--color-primary-600: var(--primary-600);
+	--color-primary-700: var(--primary-700);
+	--color-primary-800: var(--primary-800);
+	--color-primary-900: var(--primary-900);
+	--color-primary-950: var(--primary-950);
+}
 
 @layer base {
 	/* ─── Common element standardization ──────────────────────────────────── */
diff --git a/src/Web/Styles/themes.css b/src/Web/Styles/themes.css
index e6008214..c364026f 100644
--- a/src/Web/Styles/themes.css
+++ b/src/Web/Styles/themes.css
@@ -1,61 +1,62 @@
 /* ─── Theme colour palettes (OKLCH) ──────────────────────────────────────── */
 /* Applied to <html> by themeManager.js when a user picks a colour.          */
-/* Standard Tailwind 4 OKLCH values for blue, red, green, and yellow.         */
+/* Pastel-compressed OKLCH palettes: 950 ≈ old-500 lightness, 50 ≈ old-50.   */
+/* Yellow hue corrected to stay in 87–103° range throughout (no amber drift). */
 
 @layer base {
 	:root.color-blue {
 		--primary-50:  oklch(97.08% 0.0138 238.07);
-		--primary-100: oklch(94.26% 0.0315 246.17);
-		--primary-200: oklch(89.33% 0.0608 248.28);
-		--primary-300: oklch(81.29% 0.1009 251.57);
-		--primary-400: oklch(71.64% 0.1435 254.62);
-		--primary-500: oklch(62.34% 0.1783 259.22);
-		--primary-600: oklch(54.65% 0.2154 263.19);
-		--primary-700: oklch(46.39% 0.2024 264.23);
-		--primary-800: oklch(39.67% 0.1633 262.64);
-		--primary-900: oklch(35.16% 0.1215 261.97);
-		--primary-950: oklch(28.21% 0.1094 261.97);
+		--primary-100: oklch(95.67% 0.0226 242.12);
+		--primary-200: oklch(94.26% 0.0315 246.17);
+		--primary-300: oklch(91.80% 0.0461 247.22);
+		--primary-400: oklch(89.33% 0.0608 248.28);
+		--primary-500: oklch(85.31% 0.0809 249.93);
+		--primary-600: oklch(81.29% 0.1009 251.57);
+		--primary-700: oklch(76.47% 0.1222 253.09);
+		--primary-800: oklch(71.64% 0.1435 254.62);
+		--primary-900: oklch(66.99% 0.1609 256.92);
+		--primary-950: oklch(62.34% 0.1783 259.22);
 	}
 
 	:root.color-red {
 		--primary-50:  oklch(97.14% 0.0127 17.38);
-		--primary-100: oklch(94.52% 0.0237 25.71);
-		--primary-200: oklch(88.47% 0.0522 22.23);
-		--primary-300: oklch(80.85% 0.1033 21.41);
-		--primary-400: oklch(73.08% 0.1671 21.73);
-		--primary-500: oklch(63.77% 0.2265 22.55);
-		--primary-600: oklch(57.65% 0.2451 25.34);
-		--primary-700: oklch(50.11% 0.2249 26.89);
-		--primary-800: oklch(44.09% 0.1906 26.12);
-		--primary-900: oklch(38.06% 0.1491 25.51);
-		--primary-950: oklch(25.77% 0.0921 26.04);
+		--primary-100: oklch(95.83% 0.0182 21.55);
+		--primary-200: oklch(94.52% 0.0237 25.71);
+		--primary-300: oklch(91.50% 0.0379 23.97);
+		--primary-400: oklch(88.47% 0.0522 22.23);
+		--primary-500: oklch(84.66% 0.0777 21.82);
+		--primary-600: oklch(80.85% 0.1033 21.41);
+		--primary-700: oklch(76.97% 0.1352 21.57);
+		--primary-800: oklch(73.08% 0.1671 21.73);
+		--primary-900: oklch(68.42% 0.1968 22.14);
+		--primary-950: oklch(63.77% 0.2265 22.55);
 	}
 
 	:root.color-green {
-		--primary-50:  oklch(98.2%  0.0182 155.83);
-		--primary-100: oklch(96.26% 0.0373 156.07);
-		--primary-200: oklch(92.59% 0.0792 157.37);
-		--primary-300: oklch(87.08% 0.1332 153.53);
-		--primary-400: oklch(79.27% 0.191  151.11);
-		--primary-500: oklch(72.33% 0.2166 150.66);
-		--primary-600: oklch(62.74% 0.1971 152.35);
-		--primary-700: oklch(52.7%  0.1771 153.84);
-		--primary-800: oklch(44.77% 0.1387 154.01);
-		--primary-900: oklch(38.25% 0.1005 152.94);
-		--primary-950: oklch(26.61% 0.0653 152.94);
+		--primary-50:  oklch(98.20% 0.0182 155.83);
+		--primary-100: oklch(97.23% 0.0278 155.95);
+		--primary-200: oklch(96.26% 0.0373 156.07);
+		--primary-300: oklch(94.43% 0.0583 156.72);
+		--primary-400: oklch(92.59% 0.0792 157.37);
+		--primary-500: oklch(89.84% 0.1062 155.45);
+		--primary-600: oklch(87.08% 0.1332 153.53);
+		--primary-700: oklch(83.17% 0.1621 152.32);
+		--primary-800: oklch(79.27% 0.1910 151.11);
+		--primary-900: oklch(75.80% 0.2038 150.88);
+		--primary-950: oklch(72.33% 0.2166 150.66);
 	}
 
 	:root.color-yellow {
 		--primary-50:  oklch(98.71% 0.0256 102.21);
-		--primary-100: oklch(97.48% 0.0458 103.08);
-		--primary-200: oklch(95.41% 0.0769 101.56);
-		--primary-300: oklch(91.74% 0.1453  99.21);
-		--primary-400: oklch(85.25% 0.1871  87.2);
-		--primary-500: oklch(79.5%  0.175   62.03);
-		--primary-600: oklch(70.57% 0.1751  52.34);
-		--primary-700: oklch(62.06% 0.1647  48.27);
-		--primary-800: oklch(54.33% 0.1438  48.29);
-		--primary-900: oklch(47.64% 0.1206  47.44);
-		--primary-950: oklch(28.63% 0.0661  53.81);
+		--primary-100: oklch(98.09% 0.0357 102.64);
+		--primary-200: oklch(97.48% 0.0458 103.08);
+		--primary-300: oklch(96.44% 0.0614 102.32);
+		--primary-400: oklch(95.41% 0.0769 101.56);
+		--primary-500: oklch(93.57% 0.1111 100.38);
+		--primary-600: oklch(91.74% 0.1453  99.21);
+		--primary-700: oklch(88.50% 0.1662  93.20);
+		--primary-800: oklch(85.25% 0.1871  87.20);
+		--primary-900: oklch(82.38% 0.1810  87.20);
+		--primary-950: oklch(79.50% 0.1750  87.20);
 	}
 }

From b3b6da96a3693832b7c8b1981b82da2cbfa2032f Mon Sep 17 00:00:00 2001
From: Boromir <boromir@squad.dev>
Date: Sat, 9 May 2026 08:25:56 -0700
Subject: [PATCH 5/7] feat(theme): apply pastel shade pattern to nav, footer,
 layout, and tables

- Nav bar: bg-primary-500 dark:bg-primary-900, text-primary-900 dark:text-primary-500
- Footer: matching bg/text pattern, border-primary-300 dark:border-primary-700
- .nav-link: text-primary-900 dark:text-primary-500, hover:text-primary-700/200
- .nav-link.active: border-primary-900 dark:border-primary-500
- MainLayout outer div: bg-primary-100 dark:bg-primary-800
- Hamburger: hover/text using primary-400/800 and primary text colours
- thead (Index + ManageRoles): bg-primary-500 dark:bg-primary-900 text-primary-900 dark:text-primary-500
- Row hover: primary-100/800 (primary-50/950 near-white in compressed palette)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/Web/Components/Layout/MainLayout.razor        |  2 +-
 src/Web/Components/Layout/NavMenu.razor           |  6 +++---
 src/Web/Features/BlogPosts/List/Index.razor       |  4 ++--
 src/Web/Features/UserManagement/ManageRoles.razor |  4 ++--
 src/Web/Styles/input.css                          | 12 ++++++------
 5 files changed, 14 insertions(+), 14 deletions(-)

diff --git a/src/Web/Components/Layout/MainLayout.razor b/src/Web/Components/Layout/MainLayout.razor
index 219df93b..2834ba06 100644
--- a/src/Web/Components/Layout/MainLayout.razor
+++ b/src/Web/Components/Layout/MainLayout.razor
@@ -1,7 +1,7 @@
 ﻿@inherits LayoutComponentBase
 
 
-<div class="min-h-screen flex flex-col bg-primary-400 dark:bg-primary-800 text-primary-800 dark:text-primary-300">
+<div class="min-h-screen flex flex-col bg-primary-100 dark:bg-primary-800 text-primary-900 dark:text-primary-100">
 	<NavMenu/>
 
 	<main id="main-content" tabindex="-1" class="mx-auto w-full max-w-7xl flex-1 px-4 pt-20 pb-8 outline-none focus:outline-none focus-visible:outline-none">
diff --git a/src/Web/Components/Layout/NavMenu.razor b/src/Web/Components/Layout/NavMenu.razor
index 11017fbc..e981096e 100644
--- a/src/Web/Components/Layout/NavMenu.razor
+++ b/src/Web/Components/Layout/NavMenu.razor
@@ -1,7 +1,7 @@
 <header>
 	<nav class="fixed top-0 left-0 right-0 z-50
-		bg-primary-600 dark:bg-primary-600
-		border-b-2 border-primary-200 dark:border-primary-200" aria-label="Main navigation">
+		bg-primary-500 dark:bg-primary-900
+		border-b-2 border-primary-300 dark:border-primary-700" aria-label="Main navigation">
 		<div class="mx-auto max-w-7xl px-4">
 			<div class="flex items-center justify-between h-16">
 
@@ -11,7 +11,7 @@
 				@* Mobile hamburger (pure CSS peer toggle — no JS required) *@
 				<input type="checkbox" id="menu-toggle" class="peer sr-only" aria-hidden="true" />
 				<label for="menu-toggle"
-							 class="md:hidden cursor-pointer p-2 rounded hover:bg-primary-500 dark:hover:bg-primary-600 text-white"
+							 class="md:hidden cursor-pointer p-2 rounded hover:bg-primary-400 dark:hover:bg-primary-800 text-primary-900 dark:text-primary-500"
 							 aria-label="Toggle navigation" role="button" tabindex="0">
 					<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
 						<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
diff --git a/src/Web/Features/BlogPosts/List/Index.razor b/src/Web/Features/BlogPosts/List/Index.razor
index 89c422ba..a02a4c26 100644
--- a/src/Web/Features/BlogPosts/List/Index.razor
+++ b/src/Web/Features/BlogPosts/List/Index.razor
@@ -46,7 +46,7 @@ else
 {
     <div class="rounded-lg shadow bg-white dark:bg-gray-800 overflow-hidden">
         <table class="w-full text-sm text-left text-gray-900 dark:text-gray-50">
-            <thead class="bg-gray-100 dark:bg-gray-900 text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
+            <thead class="bg-primary-500 dark:bg-primary-900 text-primary-900 dark:text-primary-500 border-b border-primary-300 dark:border-primary-700">
                 <tr>
                     <th class="px-4 py-3">Title</th>
                     <th class="px-4 py-3">Author</th>
@@ -62,7 +62,7 @@ else
             <tbody>
                 @foreach (var post in _posts)
                 {
-                    <tr class="odd:bg-white odd:dark:bg-gray-800 even:bg-gray-50 even:dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 hover:bg-primary-50 dark:hover:bg-primary-950 transition">
+                    <tr class="odd:bg-white odd:dark:bg-gray-800 even:bg-gray-50 even:dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 hover:bg-primary-100 dark:hover:bg-primary-800 transition">
                         <td class="px-4 py-3">@post.Title</td>
                         <td class="px-4 py-3">@post.Author</td>
                         <td class="px-4 py-3">@(post.IsPublished ? "Yes" : "No")</td>
diff --git a/src/Web/Features/UserManagement/ManageRoles.razor b/src/Web/Features/UserManagement/ManageRoles.razor
index 660b2af6..e52ba274 100644
--- a/src/Web/Features/UserManagement/ManageRoles.razor
+++ b/src/Web/Features/UserManagement/ManageRoles.razor
@@ -28,7 +28,7 @@ else
 
     <div class="rounded-lg shadow bg-white dark:bg-gray-800 overflow-hidden">
         <table class="w-full text-sm text-left text-gray-900 dark:text-gray-50">
-            <thead class="bg-gray-100 dark:bg-gray-900 text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
+            <thead class="bg-primary-500 dark:bg-primary-900 text-primary-900 dark:text-primary-500 border-b border-primary-300 dark:border-primary-700">
                 <tr>
                     <th class="px-4 py-3">User</th>
                     <th class="px-4 py-3">Email</th>
@@ -39,7 +39,7 @@ else
             <tbody>
                 @foreach (var user in _users)
                 {
-                    <tr class="odd:bg-white odd:dark:bg-gray-800 even:bg-gray-50 even:dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 hover:bg-primary-50 dark:hover:bg-primary-950 transition">
+                    <tr class="odd:bg-white odd:dark:bg-gray-800 even:bg-gray-50 even:dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 hover:bg-primary-100 dark:hover:bg-primary-800 transition">
                         <td class="px-4 py-3">@user.Name</td>
                         <td class="px-4 py-3">@user.Email</td>
                         <td class="px-4 py-3">@string.Join(", ", user.Roles)</td>
diff --git a/src/Web/Styles/input.css b/src/Web/Styles/input.css
index 43f3ae69..3bc2bc5a 100644
--- a/src/Web/Styles/input.css
+++ b/src/Web/Styles/input.css
@@ -52,20 +52,20 @@
 @layer components {
 	/* ─── Nav Links ───────────────────────────────────────────────────────── */
 	.nav-link {
-		@apply text-primary-100 dark:text-primary-100
-		hover:text-primary-400 dark:hover:text-primary-400
+		@apply text-primary-900 dark:text-primary-500
+		hover:text-primary-700 dark:hover:text-primary-200
 		transition-colors;
 	}
 
 	.nav-link.active {
-		@apply font-bold border-b-2 border-white;
+		@apply font-bold border-b-2 border-primary-900 dark:border-primary-500;
 	}
 
 	/* ─── Footer ─────────────────────────────────────────────────────────── */
 	footer {
-		@apply border-t-2 border-primary-200 dark:border-primary-200
-		bg-primary-600 dark:bg-primary-600
-		text-primary-50 dark:text-primary-100
+		@apply border-t-2 border-primary-300 dark:border-primary-700
+		bg-primary-500 dark:bg-primary-900
+		text-primary-900 dark:text-primary-500
 		py-3 text-center text-sm font-medium shadow-lg;
 	}
 

From c3220c8bc055dec7c7507fca07bd3e88e1f88274 Mon Sep 17 00:00:00 2001
From: Boromir <boromir@squad.dev>
Date: Sat, 9 May 2026 08:31:04 -0700
Subject: [PATCH 6/7] chore(build): split tw:build into dev (no minify) and
 tw:build:prod (minify)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 package.json | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 5c53a417..83c1473a 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,8 @@
   "name": "myblog",
   "private": true,
   "scripts": {
-    "tw:build": "npx @tailwindcss/cli -i ./src/Web/Styles/input.css -o ./src/Web/wwwroot/css/tailwind.css --minify",
+    "tw:build": "npx @tailwindcss/cli -i ./src/Web/Styles/input.css -o ./src/Web/wwwroot/css/tailwind.css",
+    "tw:build:prod": "npx @tailwindcss/cli -i ./src/Web/Styles/input.css -o ./src/Web/wwwroot/css/tailwind.css --minify",
     "tw:watch": "npx @tailwindcss/cli -i ./src/Web/Styles/input.css -o ./src/Web/wwwroot/css/tailwind.css --watch"
   },
   "devDependencies": {

From be5d46ad4c014f9346f5411910b2c9a544bff9aa Mon Sep 17 00:00:00 2001
From: Boromir <boromir@squad.dev>
Date: Sat, 9 May 2026 09:01:32 -0700
Subject: [PATCH 7/7] feat(theme): centralise table, form, and alert CSS into
 input.css component classes

- Add .table-wrapper, .data-table (CSS nesting for thead/th/tbody/td)
- Add .form-panel, .form-group, .form-label, .form-input
- Add .btn-secondary, .alert-error, .alert-warning, .alert-dismiss
- Simplify Index.razor and ManageRoles.razor to use .table-wrapper/.data-table
- Simplify Create.razor and Edit.razor to use .form-panel/.form-group/.form-label/.form-input
- Replace inline alert divs with .alert-error/.alert-warning/.alert-dismiss
- Update RazorSmokeTests assertion to match classless <td> after centralisation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../Features/BlogPosts/Create/Create.razor    | 26 +++----
 src/Web/Features/BlogPosts/Edit/Edit.razor    | 24 +++---
 src/Web/Features/BlogPosts/List/Index.razor   | 38 +++++-----
 .../Features/UserManagement/ManageRoles.razor | 28 +++----
 src/Web/Styles/input.css                      | 73 +++++++++++++++++++
 .../Components/RazorSmokeTests.cs             |  2 +-
 6 files changed, 132 insertions(+), 59 deletions(-)

diff --git a/src/Web/Features/BlogPosts/Create/Create.razor b/src/Web/Features/BlogPosts/Create/Create.razor
index 9ea46af9..25364977 100644
--- a/src/Web/Features/BlogPosts/Create/Create.razor
+++ b/src/Web/Features/BlogPosts/Create/Create.razor
@@ -11,33 +11,33 @@
 
 @if (_error is not null)
 {
-    <div class="rounded-lg border border-red-300 bg-red-50 text-red-700 px-4 py-3 text-sm mb-4 flex items-center justify-between" role="alert">
+    <div class="alert-error" role="alert">
         <span>@_error</span>
-        <button type="button" class="ml-4 cursor-pointer text-red-700 hover:text-red-900" @onclick="() => _error = null">&times;</button>
+        <button type="button" class="alert-dismiss" @onclick="() => _error = null">&times;</button>
     </div>
 }
 
-<div class="rounded-lg shadow p-6 bg-white dark:bg-gray-800 max-w-2xl">
+<div class="form-panel">
     <EditForm Model="_model" OnValidSubmit="HandleSubmit">
         <DataAnnotationsValidator />
         <ValidationSummary class="mb-4" />
 
-        <div class="mb-4">
-            <label class="block text-sm font-medium text-gray-900 dark:text-gray-50 mb-1">Title</label>
-            <InputText class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-50 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-400" @bind-Value="_model.Title" />
+        <div class="form-group">
+            <label class="form-label">Title</label>
+            <InputText class="form-input" @bind-Value="_model.Title" />
         </div>
-        <div class="mb-4">
-            <label class="block text-sm font-medium text-gray-900 dark:text-gray-50 mb-1">Author</label>
-            <InputText class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-50 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-400" @bind-Value="_model.Author" />
+        <div class="form-group">
+            <label class="form-label">Author</label>
+            <InputText class="form-input" @bind-Value="_model.Author" />
         </div>
-        <div class="mb-4">
-            <label class="block text-sm font-medium text-gray-900 dark:text-gray-50 mb-1">Content</label>
-            <InputTextArea class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-50 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-400" rows="8" @bind-Value="_model.Content" />
+        <div class="form-group">
+            <label class="form-label">Content</label>
+            <InputTextArea class="form-input" rows="8" @bind-Value="_model.Content" />
         </div>
 
         <div class="flex gap-2">
             <button type="submit" class="btn-primary">Create</button>
-            <a href="/blog" class="px-4 py-2 rounded font-medium border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-50 hover:bg-gray-100 dark:hover:bg-gray-700 transition">Cancel</a>
+            <a href="/blog" class="btn-secondary">Cancel</a>
         </div>
     </EditForm>
 </div>
diff --git a/src/Web/Features/BlogPosts/Edit/Edit.razor b/src/Web/Features/BlogPosts/Edit/Edit.razor
index b5f8668c..a358dff7 100644
--- a/src/Web/Features/BlogPosts/Edit/Edit.razor
+++ b/src/Web/Features/BlogPosts/Edit/Edit.razor
@@ -11,20 +11,20 @@
 
 @if (_error is not null)
 {
-    <div class="rounded-lg border border-red-300 bg-red-50 text-red-700 px-4 py-3 text-sm mb-4 flex items-center justify-between" role="alert">
+    <div class="alert-error" role="alert">
         <span>@_error</span>
-        <button type="button" class="ml-4 cursor-pointer text-red-700 hover:text-red-900" @onclick="() => _error = null">&times;</button>
+        <button type="button" class="alert-dismiss" @onclick="() => _error = null">&times;</button>
     </div>
 }
 
 @if (_concurrencyError)
 {
-    <div class="rounded-lg border border-yellow-300 bg-yellow-50 text-yellow-700 px-4 py-3 text-sm mb-4 flex items-center justify-between" role="alert">
+    <div class="alert-warning" role="alert">
         <div>
             <strong class="font-semibold">Concurrency Conflict:</strong> This post was modified by another user. 
             Please reload the page to get the latest version.
         </div>
-        <button type="button" class="ml-4 cursor-pointer text-yellow-700 hover:text-yellow-900" @onclick="() => _concurrencyError = false">&times;</button>
+        <button type="button" class="alert-dismiss" @onclick="() => _concurrencyError = false">&times;</button>
     </div>
 }
 
@@ -34,23 +34,23 @@
 }
 else if (_model is not null)
 {
-    <div class="rounded-lg shadow p-6 bg-white dark:bg-gray-800 max-w-2xl">
+    <div class="form-panel">
         <EditForm Model="_model" OnValidSubmit="HandleSubmit">
             <DataAnnotationsValidator />
             <ValidationSummary class="mb-4" />
 
-            <div class="mb-4">
-                <label class="block text-sm font-medium text-gray-900 dark:text-gray-50 mb-1">Title</label>
-                <InputText class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-50 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-400" @bind-Value="_model.Title" />
+            <div class="form-group">
+                <label class="form-label">Title</label>
+                <InputText class="form-input" @bind-Value="_model.Title" />
             </div>
-            <div class="mb-4">
-                <label class="block text-sm font-medium text-gray-900 dark:text-gray-50 mb-1">Content</label>
-                <InputTextArea class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-50 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-400" rows="8" @bind-Value="_model.Content" />
+            <div class="form-group">
+                <label class="form-label">Content</label>
+                <InputTextArea class="form-input" rows="8" @bind-Value="_model.Content" />
             </div>
 
             <div class="flex gap-2">
                 <button type="submit" class="btn-primary">Save</button>
-                <a href="/blog" class="px-4 py-2 rounded font-medium border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-50 hover:bg-gray-100 dark:hover:bg-gray-700 transition">Cancel</a>
+                <a href="/blog" class="btn-secondary">Cancel</a>
             </div>
         </EditForm>
     </div>
diff --git a/src/Web/Features/BlogPosts/List/Index.razor b/src/Web/Features/BlogPosts/List/Index.razor
index a02a4c26..db0b5df2 100644
--- a/src/Web/Features/BlogPosts/List/Index.razor
+++ b/src/Web/Features/BlogPosts/List/Index.razor
@@ -11,20 +11,20 @@
 
 @if (_error is not null)
 {
-    <div class="rounded-lg border border-red-300 bg-red-50 text-red-700 px-4 py-3 text-sm mb-4 flex items-center justify-between" role="alert">
+    <div class="alert-error" role="alert">
         <span>@_error</span>
-        <button type="button" class="ml-4 cursor-pointer text-red-700 hover:text-red-900" @onclick="() => _error = null">&times;</button>
+        <button type="button" class="alert-dismiss" @onclick="() => _error = null">&times;</button>
     </div>
 }
 
 @if (_concurrencyError)
 {
-    <div class="rounded-lg border border-yellow-300 bg-yellow-50 text-yellow-700 px-4 py-3 text-sm mb-4 flex items-center justify-between" role="alert">
+    <div class="alert-warning" role="alert">
         <div>
             <strong class="font-semibold">Concurrency Conflict:</strong> This post was modified by another user.
             Please reload the page to get the latest version.
         </div>
-        <button type="button" class="ml-4 cursor-pointer text-yellow-700 hover:text-yellow-900" @onclick="() => _concurrencyError = false">&times;</button>
+        <button type="button" class="alert-dismiss" @onclick="() => _concurrencyError = false">&times;</button>
     </div>
 }
 
@@ -44,17 +44,17 @@ else if (!_posts.Any())
 }
 else
 {
-    <div class="rounded-lg shadow bg-white dark:bg-gray-800 overflow-hidden">
-        <table class="w-full text-sm text-left text-gray-900 dark:text-gray-50">
-            <thead class="bg-primary-500 dark:bg-primary-900 text-primary-900 dark:text-primary-500 border-b border-primary-300 dark:border-primary-700">
+    <div class="table-wrapper">
+        <table class="data-table">
+            <thead>
                 <tr>
-                    <th class="px-4 py-3">Title</th>
-                    <th class="px-4 py-3">Author</th>
-                    <th class="px-4 py-3">Published</th>
-                    <th class="px-4 py-3">Created</th>
+                    <th>Title</th>
+                    <th>Author</th>
+                    <th>Published</th>
+                    <th>Created</th>
                     <AuthorizeView Roles="Author,Admin">
                         <Authorized>
-                            <th class="px-4 py-3"></th>
+                            <th></th>
                         </Authorized>
                     </AuthorizeView>
                 </tr>
@@ -62,15 +62,15 @@ else
             <tbody>
                 @foreach (var post in _posts)
                 {
-                    <tr class="odd:bg-white odd:dark:bg-gray-800 even:bg-gray-50 even:dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 hover:bg-primary-100 dark:hover:bg-primary-800 transition">
-                        <td class="px-4 py-3">@post.Title</td>
-                        <td class="px-4 py-3">@post.Author</td>
-                        <td class="px-4 py-3">@(post.IsPublished ? "Yes" : "No")</td>
-                        <td class="px-4 py-3">@post.CreatedAt.ToShortDateString()</td>
+                    <tr>
+                        <td>@post.Title</td>
+                        <td>@post.Author</td>
+                        <td>@(post.IsPublished ? "Yes" : "No")</td>
+                        <td>@post.CreatedAt.ToShortDateString()</td>
                         <AuthorizeView Roles="Author,Admin">
                             <Authorized>
-                                <td class="px-4 py-3">
-                                    <a href="/blog/edit/@post.Id" class="inline-block px-3 py-1 text-sm rounded font-medium border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-50 hover:bg-gray-100 dark:hover:bg-gray-700 transition mr-1">Edit</a>
+                                <td>
+                                    <a href="/blog/edit/@post.Id" class="btn-secondary inline-block mr-1">Edit</a>
                                     <button class="inline-block px-3 py-1 text-sm rounded font-medium bg-red-600 text-white hover:bg-red-700 transition"
                                             @onclick="() => ConfirmDelete(post)">Delete</button>
                                 </td>
diff --git a/src/Web/Features/UserManagement/ManageRoles.razor b/src/Web/Features/UserManagement/ManageRoles.razor
index e52ba274..c25ebb09 100644
--- a/src/Web/Features/UserManagement/ManageRoles.razor
+++ b/src/Web/Features/UserManagement/ManageRoles.razor
@@ -15,9 +15,9 @@ else
 {
     @if (_error is not null)
     {
-        <div class="rounded-lg border border-red-300 bg-red-50 text-red-700 px-4 py-3 text-sm mb-4 flex items-center justify-between" role="alert">
+        <div class="alert-error" role="alert">
             <span>@_error</span>
-            <button type="button" class="ml-4 cursor-pointer text-red-700 hover:text-red-900" aria-label="Close error" @onclick="() => _error = null">&times;</button>
+            <button type="button" class="alert-dismiss" aria-label="Close error" @onclick="() => _error = null">&times;</button>
         </div>
     }
 
@@ -26,24 +26,24 @@ else
         <p class="text-gray-600 dark:text-gray-400 mb-4">Available roles: @string.Join(", ", _availableRoles.Select(r => r.Name))</p>
     }
 
-    <div class="rounded-lg shadow bg-white dark:bg-gray-800 overflow-hidden">
-        <table class="w-full text-sm text-left text-gray-900 dark:text-gray-50">
-            <thead class="bg-primary-500 dark:bg-primary-900 text-primary-900 dark:text-primary-500 border-b border-primary-300 dark:border-primary-700">
+    <div class="table-wrapper">
+        <table class="data-table">
+            <thead>
                 <tr>
-                    <th class="px-4 py-3">User</th>
-                    <th class="px-4 py-3">Email</th>
-                    <th class="px-4 py-3">Current Roles</th>
-                    <th class="px-4 py-3">Actions</th>
+                    <th>User</th>
+                    <th>Email</th>
+                    <th>Current Roles</th>
+                    <th>Actions</th>
                 </tr>
             </thead>
             <tbody>
                 @foreach (var user in _users)
                 {
-                    <tr class="odd:bg-white odd:dark:bg-gray-800 even:bg-gray-50 even:dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 hover:bg-primary-100 dark:hover:bg-primary-800 transition">
-                        <td class="px-4 py-3">@user.Name</td>
-                        <td class="px-4 py-3">@user.Email</td>
-                        <td class="px-4 py-3">@string.Join(", ", user.Roles)</td>
-                        <td class="px-4 py-3">
+                    <tr>
+                        <td>@user.Name</td>
+                        <td>@user.Email</td>
+                        <td>@string.Join(", ", user.Roles)</td>
+                        <td>
                             @foreach (var role in _availableRoles.Where(r => !user.Roles.Contains(r.Name)))
                             {
                                 <button class="inline-block px-3 py-1 text-sm rounded border border-green-600 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 transition mr-1 mb-1"
diff --git a/src/Web/Styles/input.css b/src/Web/Styles/input.css
index 3bc2bc5a..414d3d11 100644
--- a/src/Web/Styles/input.css
+++ b/src/Web/Styles/input.css
@@ -85,6 +85,79 @@
 		rounded-lg shadow;
 	}
 
+	/* ─── Tables ──────────────────────────────────────────────────────────── */
+	.table-wrapper {
+		@apply rounded-lg shadow bg-white dark:bg-gray-800 overflow-hidden;
+	}
+
+	.data-table {
+		@apply w-full text-sm text-left text-gray-900 dark:text-gray-50;
+
+		& thead {
+			@apply bg-primary-500 dark:bg-primary-900
+			text-primary-900 dark:text-primary-500
+			border-b border-primary-300 dark:border-primary-700;
+		}
+
+		& thead th {
+			@apply px-4 py-3;
+		}
+
+		& tbody tr {
+			@apply odd:bg-white odd:dark:bg-gray-800 even:bg-gray-50 even:dark:bg-gray-900
+			border-b border-gray-200 dark:border-gray-700
+			hover:bg-primary-100 dark:hover:bg-primary-800 transition;
+		}
+
+		& tbody td {
+			@apply px-4 py-3;
+		}
+	}
+
+	/* ─── Forms ───────────────────────────────────────────────────────────── */
+	.form-panel {
+		@apply rounded-lg shadow p-6 bg-white dark:bg-gray-800 max-w-2xl;
+	}
+
+	.form-group {
+		@apply mb-4;
+	}
+
+	.form-label {
+		@apply block text-sm font-medium text-gray-900 dark:text-gray-50 mb-1;
+	}
+
+	.form-input {
+		@apply w-full rounded border border-gray-300 dark:border-gray-600
+		bg-white dark:bg-gray-700
+		text-gray-900 dark:text-gray-50
+		px-3 py-2 text-sm
+		focus:outline-none focus:ring-2 focus:ring-primary-400;
+	}
+
+	/* ─── Secondary button (Cancel / neutral action) ──────────────────────── */
+	.btn-secondary {
+		@apply px-4 py-2 rounded font-medium
+		border border-gray-300 dark:border-gray-600
+		text-gray-900 dark:text-gray-50
+		hover:bg-gray-100 dark:hover:bg-gray-700 transition;
+	}
+
+	/* ─── Alert banners ───────────────────────────────────────────────────── */
+	.alert-error {
+		@apply rounded-lg border border-red-300 bg-red-50 text-red-700
+		px-4 py-3 text-sm mb-4 flex items-center justify-between;
+	}
+
+	.alert-warning {
+		@apply rounded-lg border border-yellow-300 bg-yellow-50 text-yellow-700
+		px-4 py-3 text-sm mb-4 flex items-center justify-between;
+	}
+
+	.alert-dismiss {
+		@apply ml-4 cursor-pointer hover:opacity-75;
+	}
+
 	/* ─── Blazor form validation ──────────────────────────────────────────── */
 	.valid.modified:not([type=checkbox]) {
 		@apply outline-1 outline-green-500;
diff --git a/tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs b/tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs
index 8f9aa84b..086ac951 100644
--- a/tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs
+++ b/tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs
@@ -448,7 +448,7 @@ public void ManageRolesRemoveButtonSendsCommandAndRefreshesUsers()
 		cut.WaitForAssertion(() =>
 		{
 			cut.Markup.Should().Contain("+ Author");
-			cut.Markup.Should().Contain("<td class=\"px-4 py-3\">Admin</td>");
+			cut.Markup.Should().Contain("<td>Admin</td>");
 		});
 	}
 
