Light/Dark theming

This commit is contained in:
2026-05-18 21:00:38 +02:00
parent ecc799ae7f
commit 66607e51eb
32 changed files with 968 additions and 207 deletions

View File

@@ -1,4 +1,4 @@
# RpgRoller # RpgRoller
RpgRoller is an ASP.NET Core and Blazor Server app for lightweight tabletop campaign play, character sheets, and dice workflows. RpgRoller is an ASP.NET Core and Blazor Server app for lightweight tabletop campaign play, character sheets, and dice workflows.
@@ -54,7 +54,8 @@ Frontend:
- `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions - `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions
- `RpgRoller/Components/WorkspaceQueryService.cs`: browser-facing read client for workspace data - `RpgRoller/Components/WorkspaceQueryService.cs`: browser-facing read client for workspace data
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser interop for auth forms, session storage, SSE wiring, and DOM helpers - `RpgRoller/wwwroot/js/rpgroller-api.js`: browser interop for auth forms, session storage, SSE wiring, and DOM helpers
- `RpgRoller/wwwroot/styles.css`: app styling and responsive layout - `RpgRoller/wwwroot/styles.css`: app styling, light and dark theme variables, and responsive layout
- `RpgRoller/wwwroot/images/light.png` and `RpgRoller/wwwroot/images/dark.png`: themed workspace background art
Current repo note: Current repo note:
@@ -75,6 +76,7 @@ Current repo note:
- Supported campaign rulesets: D6 System, D&D 5e, and Rolemaster - Supported campaign rulesets: D6 System, D&D 5e, and Rolemaster
- Account registration, login, session-based auth, and role-aware authorization - Account registration, login, session-based auth, and role-aware authorization
- Admin tools for user listing, role updates, account deletion, and direct SQLite database download - Admin tools for user listing, role updates, account deletion, and direct SQLite database download
- Per-user light and dark theme preference with OS-based initial selection
- Campaign creation, roster reads, participant-scoped visibility, and owner and admin deletion - Campaign creation, roster reads, participant-scoped visibility, and owner and admin deletion
- Character creation, activation, owner transfer, campaign reassignment or unlinking, and owner and admin deletion - Character creation, activation, owner transfer, campaign reassignment or unlinking, and owner and admin deletion
- Skill groups with reusable defaults plus skill and skill-group create, edit, reassign, and delete flows - Skill groups with reusable defaults plus skill and skill-group create, edit, reassign, and delete flows

View File

@@ -1,4 +1,4 @@
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory) public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
{ {
@@ -12,8 +12,7 @@ public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTe
Assert.Equal("alice", registerResult.Username); Assert.Equal("alice", registerResult.Username);
Assert.Contains(registerResult.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); Assert.Contains(registerResult.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
var duplicate = await client.PostAsJsonAsync("/api/auth/register", var duplicate = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest("alice", "Password123", "Alice 2"));
new RegisterRequest("alice", "Password123", "Alice 2"));
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
var loginResult = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "Password123")); var loginResult = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "Password123"));
@@ -21,13 +20,39 @@ public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTe
var me = await GetAsync<MeResponse>(client, "/api/me"); var me = await GetAsync<MeResponse>(client, "/api/me");
Assert.Equal(registerResult.Id, me.User.Id); Assert.Equal(registerResult.Id, me.User.Id);
Assert.Null(me.User.ThemePreference);
Assert.Null(me.ActiveCharacterId); Assert.Null(me.ActiveCharacterId);
Assert.Null(me.CurrentCampaignId); Assert.Null(me.CurrentCampaignId);
var themeUser = await PutAsync<UpdateThemePreferenceRequest, UserSummary>(client, "/api/me/theme", new("dark"));
Assert.Equal("dark", themeUser.ThemePreference);
var themedMe = await GetAsync<MeResponse>(client, "/api/me");
Assert.Equal("dark", themedMe.User.ThemePreference);
var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password")); var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password"));
Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode);
} }
[Fact]
public async Task ThemePreferenceEndpoint_RequiresAuthAndValidTheme()
{
using var factory = CreateFactory();
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
var unauthorized = await client.PutAsJsonAsync("/api/me/theme", new UpdateThemePreferenceRequest("dark"));
Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode);
var unauthorizedInvalid = await client.PutAsJsonAsync("/api/me/theme", new UpdateThemePreferenceRequest("sepia"));
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedInvalid.StatusCode);
await RegisterAsync(client, "theme-api", "Password123", "Theme Api");
await LoginAsync(client, "theme-api", "Password123");
var invalid = await client.PutAsJsonAsync("/api/me/theme", new UpdateThemePreferenceRequest("sepia"));
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
}
[Fact] [Fact]
public async Task UsernamesEndpoint_RequiresAuthAndReturnsAlphabeticalList() public async Task UsernamesEndpoint_RequiresAuthAndReturnsAlphabeticalList()
{ {
@@ -54,10 +79,7 @@ public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTe
await RegisterAsync(client, "proxy-user", "Password123", "Proxy User"); await RegisterAsync(client, "proxy-user", "Password123", "Proxy User");
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") { Content = JsonContent.Create(new LoginRequest("proxy-user", "Password123")) };
{
Content = JsonContent.Create(new LoginRequest("proxy-user", "Password123"))
};
request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https"); request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https");
using var response = await client.SendAsync(request); using var response = await client.SendAsync(request);

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -159,6 +159,7 @@ public sealed class HostingCoverageTests
usersColumns.Add(usersTableInfoReader.GetString(1)); usersColumns.Add(usersTableInfoReader.GetString(1));
Assert.Contains("Roles", usersColumns); Assert.Contains("Roles", usersColumns);
Assert.Contains("ThemePreference", usersColumns);
using var usersRoleCommand = verifyConnection.CreateCommand(); using var usersRoleCommand = verifyConnection.CreateCommand();
usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';"; usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';";
@@ -214,6 +215,11 @@ public sealed class HostingCoverageTests
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
Assert.Equal(1, retryHistoryCount); Assert.Equal(1, retryHistoryCount);
using var themeHistoryCommand = verifyConnection.CreateCommand();
themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';";
var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar());
Assert.Equal(1, themeHistoryCount);
} }
[Fact] [Fact]
@@ -359,6 +365,11 @@ public sealed class HostingCoverageTests
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
Assert.Equal(1, retryHistoryCount); Assert.Equal(1, retryHistoryCount);
using var themeHistoryCommand = verifyConnection.CreateCommand();
themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';";
var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar());
Assert.Equal(1, themeHistoryCount);
} }
[Fact] [Fact]
@@ -481,6 +492,15 @@ public sealed class HostingCoverageTests
Assert.Contains("FumbleRange", skillGroupColumns); Assert.Contains("FumbleRange", skillGroupColumns);
using var usersTableInfoCommand = verifyConnection.CreateCommand();
usersTableInfoCommand.CommandText = "PRAGMA table_info('Users');";
using var usersTableInfoReader = usersTableInfoCommand.ExecuteReader();
var usersColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (usersTableInfoReader.Read())
usersColumns.Add(usersTableInfoReader.GetString(1));
Assert.Contains("ThemePreference", usersColumns);
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand(); using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';"; authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar()); var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
@@ -490,5 +510,10 @@ public sealed class HostingCoverageTests
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
Assert.Equal(1, retryHistoryCount); Assert.Equal(1, retryHistoryCount);
using var themeHistoryCommand = verifyConnection.CreateCommand();
themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';";
var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar());
Assert.Equal(1, themeHistoryCount);
} }
} }

View File

@@ -1,4 +1,4 @@
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class ServiceAuthTests public sealed class ServiceAuthTests
{ {
@@ -74,4 +74,26 @@ public sealed class ServiceAuthTests
var usernames = ServiceTestSupport.GetValue(service.GetUsernames(session)); var usernames = ServiceTestSupport.GetValue(service.GetUsernames(session));
Assert.Equal(["amy", "bob", "zoe"], usernames); Assert.Equal(["amy", "bob", "zoe"], usernames);
} }
[Fact]
public void UpdateThemePreference_RequiresAuthAndPersistsSupportedTheme()
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
service.Register("theme-user", "Password123", "Theme User");
var session = ServiceTestSupport.GetValue(service.Login("theme-user", "Password123")).SessionToken;
var unauthorized = service.UpdateThemePreference(string.Empty, "dark");
var invalid = service.UpdateThemePreference(session, "sepia");
var updated = service.UpdateThemePreference(session, "DARK");
Assert.False(unauthorized.Succeeded);
Assert.False(invalid.Succeeded);
Assert.True(updated.Succeeded);
Assert.Equal("dark", ServiceTestSupport.GetValue(updated).ThemePreference);
var me = ServiceTestSupport.GetValue(service.GetMe(session));
Assert.Equal("dark", me.User.ThemePreference);
}
} }

View File

@@ -1,4 +1,4 @@
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class ServicePersistenceTests public sealed class ServicePersistenceTests
{ {
@@ -22,8 +22,7 @@ public sealed class ServicePersistenceTests
var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken; var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6")); var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6"));
var ownerCharacter = var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id));
ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id));
Assert.False(service.GetMe(string.Empty).Succeeded); Assert.False(service.GetMe(string.Empty).Succeeded);
Assert.False(service.CreateCampaign(gmSession, "", "d6").Succeeded); Assert.False(service.CreateCampaign(gmSession, "", "d6").Succeeded);
@@ -80,8 +79,7 @@ public sealed class ServicePersistenceTests
Assert.NotNull(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId); Assert.NotNull(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
} }
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
1, true));
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1", 1, true).Succeeded); Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1", 1, true).Succeeded);
Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1", 1, true).Succeeded); Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1", 1, true).Succeeded);
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad", 1, true).Succeeded); Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad", 1, true).Succeeded);
@@ -111,17 +109,13 @@ public sealed class ServicePersistenceTests
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-persist", "Password123")).SessionToken; var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-persist", "Password123")).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-persist", "Password123")).SessionToken; var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-persist", "Password123")).SessionToken;
var campaign = var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster"));
ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id)); var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id));
var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", "d100!+25", 0, false, 5));
"d100!+25", 0, false, 5)); var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", "d100!+35", 0, false, group.Id, 3, true));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes",
"d100!+35", 0, false, group.Id, 3, true));
using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath); using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath);
var reloadedSheet = var reloadedSheet = ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id));
ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id));
var reloadedGroup = Assert.Single(reloadedSheet.SkillGroups, current => current.Id == group.Id); var reloadedGroup = Assert.Single(reloadedSheet.SkillGroups, current => current.Id == group.Id);
Assert.Equal(5, reloadedGroup.FumbleRange); Assert.Equal(5, reloadedGroup.FumbleRange);
@@ -130,4 +124,22 @@ public sealed class ServicePersistenceTests
Assert.Equal(3, reloadedSkill.FumbleRange); Assert.Equal(3, reloadedSkill.FumbleRange);
Assert.True(reloadedSkill.RolemasterAutoRetry); Assert.True(reloadedSkill.RolemasterAutoRetry);
} }
[Fact]
public void UserThemePreference_PersistsAcrossDatabaseReload()
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
service.Register("theme-persist", "Password123", "Theme Persist");
var session = ServiceTestSupport.GetValue(service.Login("theme-persist", "Password123")).SessionToken;
var updated = ServiceTestSupport.GetValue(service.UpdateThemePreference(session, "dark"));
Assert.Equal("dark", updated.ThemePreference);
using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath);
var me = ServiceTestSupport.GetValue(reloadedHarness.Service.GetMe(session));
Assert.Equal("dark", me.User.ThemePreference);
}
} }

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Services; using RpgRoller.Services;
@@ -14,6 +14,12 @@ internal static class MeEndpoints
return ApiResultMapper.ToApiResult(result); return ApiResultMapper.ToApiResult(result);
}); });
group.MapPut("/me/theme", Results<Ok<UserSummary>, BadRequest<ApiError>, UnauthorizedHttpResult> (UpdateThemePreferenceRequest request, HttpContext context, IGameService game) =>
{
var result = game.UpdateThemePreference(context.GetRequiredSessionToken(), request.ThemePreference);
return ApiResultMapper.ToApiResult(result);
});
return group; return group;
} }
} }

View File

@@ -1,4 +1,4 @@
@using RpgRoller.Components.Pages.HomeControls @using RpgRoller.Components.Pages.HomeControls
@attribute [ExcludeFromCodeCoverage] @attribute [ExcludeFromCodeCoverage]
<!DOCTYPE html> <!DOCTYPE html>
@@ -8,6 +8,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="@BaseHref"/> <base href="@BaseHref"/>
<title>RpgRoller</title> <title>RpgRoller</title>
<script>
document.documentElement.dataset.theme = window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
</script>
<link rel="stylesheet" href="@Assets["styles.css"]"/> <link rel="stylesheet" href="@Assets["styles.css"]"/>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View File

@@ -40,6 +40,13 @@
} }
</div> </div>
<a href="" class="logout-link" @onclick:preventDefault="true" @onclick="LogoutRequested">Logout</a> <a href="" class="logout-link" @onclick:preventDefault="true" @onclick="LogoutRequested">Logout</a>
<button type="button"
class="theme-toggle"
aria-label="@ThemeToggleAriaLabel"
title="@ThemeToggleAriaLabel"
@onclick="ThemeToggleRequested">
<span aria-hidden="true">@ThemeToggleLabel</span>
</button>
@if (MenuItems.Count > 0) @if (MenuItems.Count > 0)
{ {
<div class="header-menu-wrap"> <div class="header-menu-wrap">

View File

@@ -12,37 +12,64 @@ public partial class AppHeader
return item.OnSelected?.Invoke() ?? Task.CompletedTask; return item.OnSelected?.Invoke() ?? Task.CompletedTask;
} }
[Parameter] public string Title { get; set; } = "RpgRoller"; private string ThemeToggleAriaLabel => string.Equals(Theme, "dark", StringComparison.OrdinalIgnoreCase) ? "Switch to light theme" : "Switch to dark theme";
[Parameter] public UserSummary? User { get; set; } [Parameter]
public string Title { get; set; } = "RpgRoller";
[Parameter] public bool ShowCampaign { get; set; } [Parameter]
public UserSummary? User { get; set; }
[Parameter] public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = []; [Parameter]
public bool ShowCampaign { get; set; }
[Parameter] public Guid? SelectedCampaignId { get; set; } [Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter] public string CampaignSelectId { get; set; } = "header-campaign-select"; [Parameter]
public Guid? SelectedCampaignId { get; set; }
[Parameter] public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; } [Parameter]
public string CampaignSelectId { get; set; } = "header-campaign-select";
[Parameter] public bool ShowConnectionState { get; set; } = true; [Parameter]
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter] public string ConnectionStateLabel { get; set; } = "Offline fallback"; [Parameter]
public bool ShowConnectionState { get; set; } = true;
[Parameter] public string ConnectionStateCssClass { get; set; } = "offline"; [Parameter]
public string ConnectionStateLabel { get; set; } = "Offline fallback";
[Parameter] public bool IsMenuOpen { get; set; } [Parameter]
public string ConnectionStateCssClass { get; set; } = "offline";
[Parameter] public string MenuButtonId { get; set; } = "screen-menu-button"; [Parameter]
public bool IsMenuOpen { get; set; }
[Parameter] public string MenuId { get; set; } = "screen-menu"; [Parameter]
public string MenuButtonId { get; set; } = "screen-menu-button";
[Parameter] public IReadOnlyList<AppHeaderMenuItem> MenuItems { get; set; } = []; [Parameter]
public string MenuId { get; set; } = "screen-menu";
[Parameter] public EventCallback ToggleMenuRequested { get; set; } [Parameter]
public IReadOnlyList<AppHeaderMenuItem> MenuItems { get; set; } = [];
[Parameter] public EventCallback LogoutRequested { get; set; } [Parameter]
public EventCallback ToggleMenuRequested { get; set; }
[Parameter]
public string Theme { get; set; } = "light";
[Parameter]
public string ThemeToggleLabel { get; set; } = "☀️";
[Parameter]
public EventCallback ThemeToggleRequested { get; set; }
[Parameter]
public EventCallback LogoutRequested { get; set; }
} }
public sealed class AppHeaderMenuItem public sealed class AppHeaderMenuItem

View File

@@ -28,6 +28,9 @@
MenuId="workspace-screen-menu" MenuId="workspace-screen-menu"
MenuItems="HeaderMenuItems" MenuItems="HeaderMenuItems"
ToggleMenuRequested="ToggleScreenMenu" ToggleMenuRequested="ToggleScreenMenu"
Theme="@State.ThemePreference"
ThemeToggleLabel="@State.ThemeToggleLabel"
ThemeToggleRequested="Session.ToggleThemePreferenceAsync"
LogoutRequested="Session.LogoutAsync"/> LogoutRequested="Session.LogoutAsync"/>
@if (ChildContent is not null) @if (ChildContent is not null)

View File

@@ -1,25 +1,10 @@
using Microsoft.JSInterop; using Microsoft.JSInterop;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Components.Pages; namespace RpgRoller.Components.Pages;
public sealed class WorkspaceSessionCoordinator( public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<bool> isAdminRoute, Func<Task> redirectToPlayAsync, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> requestRefreshAsync, Func<Task> syncStateEventsAsync, Func<Task> stopStateEventsAsync, Func<Task> ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, Func<string?, Task> onLoggedOutAsync)
WorkspaceState state,
WorkspaceFeedbackService feedback,
IJSRuntime js,
RpgRollerApiClient apiClient,
WorkspaceQueryService workspaceQuery,
Func<bool> isAdminRoute,
Func<Task> redirectToPlayAsync,
Func<Guid?, Task> reloadCampaignsAsync,
Func<Task> reloadCharacterCampaignOptionsAsync,
Func<Task> refreshCampaignScopeAsync,
Func<Task> requestRefreshAsync,
Func<Task> syncStateEventsAsync,
Func<Task> stopStateEventsAsync,
Func<Task> ensureAdminUsersLoadedAsync,
Action resetCampaignLogDetailState,
Func<string?, Task> onLoggedOutAsync)
{ {
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
@@ -27,8 +12,7 @@ public sealed class WorkspaceSessionCoordinator(
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase)) if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
state.MobilePanel = "log"; state.MobilePanel = "log";
var storedRollVisibility = var storedRollVisibility = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
state.RollVisibility = NormalizeRollVisibility(storedRollVisibility); state.RollVisibility = NormalizeRollVisibility(storedRollVisibility);
Guid? preferredCampaignId = null; Guid? preferredCampaignId = null;
@@ -101,6 +85,33 @@ public sealed class WorkspaceSessionCoordinator(
await requestRefreshAsync(); await requestRefreshAsync();
} }
public async Task ToggleThemePreferenceAsync()
{
if (state.User is null || state.IsMutating)
return;
var previousTheme = state.ThemePreference;
var nextTheme = state.NextThemePreference;
state.ThemePreference = nextTheme;
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", nextTheme);
await requestRefreshAsync();
try
{
state.User = await apiClient.RequestAsync<UserSummary>("PUT", "/api/me/theme", new UpdateThemePreferenceRequest(nextTheme));
state.ThemePreference = NormalizeThemePreference(state.User.ThemePreference);
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference);
}
catch (ApiRequestException ex)
{
state.ThemePreference = previousTheme;
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", previousTheme);
feedback.SetStatus(ex.Message, true);
}
await requestRefreshAsync();
}
public void ClearAuthenticatedState() public void ClearAuthenticatedState()
{ {
state.User = null; state.User = null;
@@ -117,6 +128,7 @@ public sealed class WorkspaceSessionCoordinator(
state.SelectedCharacterId = null; state.SelectedCharacterId = null;
state.LastRoll = null; state.LastRoll = null;
state.KnownUsernames = []; state.KnownUsernames = [];
state.ThemePreference = ThemePreferences.Light;
state.ShowCreateCharacterModal = false; state.ShowCreateCharacterModal = false;
state.ShowEditCharacterModal = false; state.ShowEditCharacterModal = false;
state.CanEditCharacterOwner = false; state.CanEditCharacterOwner = false;
@@ -161,6 +173,7 @@ public sealed class WorkspaceSessionCoordinator(
state.User = me.User; state.User = me.User;
state.ActiveCharacterId = me.ActiveCharacterId; state.ActiveCharacterId = me.ActiveCharacterId;
await EnsureThemePreferenceAsync();
if (!await EnsureRouteAccessAsync()) if (!await EnsureRouteAccessAsync())
return true; return true;
@@ -211,6 +224,38 @@ public sealed class WorkspaceSessionCoordinator(
return string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; return string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
} }
private async Task EnsureThemePreferenceAsync()
{
if (state.User is null)
return;
var themePreference = state.User.ThemePreference;
if (ThemePreferences.IsSupported(themePreference))
{
state.ThemePreference = ThemePreferences.Normalize(themePreference!);
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference);
return;
}
var systemThemePreference = await js.InvokeAsync<string>("rpgRollerApi.getSystemTheme");
state.ThemePreference = NormalizeThemePreference(systemThemePreference);
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference);
try
{
state.User = await apiClient.RequestAsync<UserSummary>("PUT", "/api/me/theme", new UpdateThemePreferenceRequest(state.ThemePreference));
}
catch (ApiRequestException ex)
{
feedback.SetStatus(ex.Message, true);
}
}
private static string NormalizeThemePreference(string? themePreference)
{
return ThemePreferences.IsSupported(themePreference) ? ThemePreferences.Normalize(themePreference!) : ThemePreferences.Light;
}
private const string CampaignSessionKey = "campaign"; private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel"; private const string MobilePanelSessionKey = "play-panel";
private const string RollVisibilitySessionKey = "roll-visibility"; private const string RollVisibilitySessionKey = "roll-visibility";

View File

@@ -17,9 +17,7 @@ public sealed class WorkspaceState
if (ownerUserId == SelectedCampaign.Gm.Id) if (ownerUserId == SelectedCampaign.Gm.Id)
return $"{SelectedCampaign.Gm.DisplayName} (GM)"; return $"{SelectedCampaign.Gm.DisplayName} (GM)";
var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId) var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId).Select(character => character.OwnerDisplayName).FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
.Select(character => character.OwnerDisplayName)
.FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName; return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
} }
@@ -28,10 +26,8 @@ public sealed class WorkspaceState
{ {
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{ {
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
StringComparison.OrdinalIgnoreCase)) return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange, skill.RolemasterAutoRetry);
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange,
skill.RolemasterAutoRetry);
return skill.DiceRollDefinition; return skill.DiceRollDefinition;
} }
@@ -55,6 +51,7 @@ public sealed class WorkspaceState
public RollResult? LastRoll { get; set; } public RollResult? LastRoll { get; set; }
public List<string> KnownUsernames { get; set; } = []; public List<string> KnownUsernames { get; set; } = [];
public string RollVisibility { get; set; } = "public"; public string RollVisibility { get; set; } = "public";
public string ThemePreference { get; set; } = ThemePreferences.Light;
public bool IsMutating { get; set; } public bool IsMutating { get; set; }
public bool IsCampaignDataLoading { get; set; } public bool IsCampaignDataLoading { get; set; }
@@ -102,17 +99,14 @@ public sealed class WorkspaceState
return null; return null;
if (User is null) if (User is null)
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []);
[]);
if (IsCurrentUserGm) if (IsCurrentUserGm)
return SelectedCampaign; return SelectedCampaign;
var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id) var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id).ToArray();
.ToArray();
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, ownedCharacters);
ownedCharacters);
} }
} }
@@ -126,18 +120,14 @@ public sealed class WorkspaceState
if (SelectedCharacterId.HasValue) if (SelectedCharacterId.HasValue)
{ {
var selectedCharacter = var selectedCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId.Value);
playSelectedCampaign.Characters.FirstOrDefault(character =>
character.Id == SelectedCharacterId.Value);
if (selectedCharacter is not null) if (selectedCharacter is not null)
return selectedCharacter; return selectedCharacter;
} }
if (ActiveCharacterId.HasValue) if (ActiveCharacterId.HasValue)
{ {
var activeCharacter = var activeCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == ActiveCharacterId.Value);
playSelectedCampaign.Characters.FirstOrDefault(character =>
character.Id == ActiveCharacterId.Value);
if (activeCharacter is not null) if (activeCharacter is not null)
return activeCharacter; return activeCharacter;
} }
@@ -181,4 +171,9 @@ public sealed class WorkspaceState
"reconnecting" => "warn", "reconnecting" => "warn",
_ => "offline" _ => "offline"
}; };
public string ThemeToggleLabel => ThemePreference == ThemePreferences.Dark ? "⏾" : "☀️";
public string NextThemePreference =>
ThemePreference == ThemePreferences.Dark ? ThemePreferences.Light : ThemePreferences.Dark;
} }

View File

@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace RpgRoller.Contracts; namespace RpgRoller.Contracts;
@@ -10,10 +10,12 @@ public sealed record RegisterRequest(string Username, string Password, string Di
public sealed record LoginRequest(string Username, string Password); public sealed record LoginRequest(string Username, string Password);
public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles); public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles, string? ThemePreference = null);
public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId); public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId);
public sealed record UpdateThemePreferenceRequest(string ThemePreference);
public sealed record AdminUserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles); public sealed record AdminUserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles);
public sealed record UpdateUserRolesRequest(IReadOnlyList<string> Roles); public sealed record UpdateUserRolesRequest(IReadOnlyList<string> Roles);

View File

@@ -1,4 +1,4 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace RpgRoller.Contracts; namespace RpgRoller.Contracts;
@@ -52,6 +52,7 @@ namespace RpgRoller.Contracts;
[JsonSerializable(typeof(UpdateCharacterRequest))] [JsonSerializable(typeof(UpdateCharacterRequest))]
[JsonSerializable(typeof(UpdateSkillGroupRequest))] [JsonSerializable(typeof(UpdateSkillGroupRequest))]
[JsonSerializable(typeof(UpdateSkillRequest))] [JsonSerializable(typeof(UpdateSkillRequest))]
[JsonSerializable(typeof(UpdateThemePreferenceRequest))]
[JsonSerializable(typeof(UpdateUserRolesRequest))] [JsonSerializable(typeof(UpdateUserRolesRequest))]
[JsonSerializable(typeof(UserSummary))] [JsonSerializable(typeof(UserSummary))]
public partial class RpgRollerJsonSerializerContext : JsonSerializerContext public partial class RpgRollerJsonSerializerContext : JsonSerializerContext

View File

@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RpgRoller.Domain; using RpgRoller.Domain;
namespace RpgRoller.Data; namespace RpgRoller.Data;
@@ -15,6 +15,7 @@ public sealed class RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> opti
entity.Property(x => x.PasswordHash).IsRequired(); entity.Property(x => x.PasswordHash).IsRequired();
entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128); entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128);
entity.Property(x => x.Roles).IsRequired().HasMaxLength(256); entity.Property(x => x.Roles).IsRequired().HasMaxLength(256);
entity.Property(x => x.ThemePreference).IsRequired(false).HasMaxLength(16);
entity.HasIndex(x => x.UsernameNormalized).IsUnique(); entity.HasIndex(x => x.UsernameNormalized).IsUnique();
}); });

View File

@@ -1,4 +1,4 @@
namespace RpgRoller.Domain; namespace RpgRoller.Domain;
public enum RulesetKind public enum RulesetKind
{ {
@@ -22,6 +22,7 @@ public sealed class UserAccount
public required string DisplayName { get; set; } public required string DisplayName { get; set; }
public required string Roles { get; set; } public required string Roles { get; set; }
public Guid? ActiveCharacterId { get; set; } public Guid? ActiveCharacterId { get; set; }
public string? ThemePreference { get; set; }
} }
public static class UserRoles public static class UserRoles

View File

@@ -0,0 +1,17 @@
namespace RpgRoller.Domain;
public static class ThemePreferences
{
public const string Light = "light";
public const string Dark = "dark";
public static bool IsSupported(string? value)
{
return string.Equals(value, Light, StringComparison.OrdinalIgnoreCase) || string.Equals(value, Dark, StringComparison.OrdinalIgnoreCase);
}
public static string Normalize(string value)
{
return string.Equals(value, Dark, StringComparison.OrdinalIgnoreCase) ? Dark : Light;
}
}

View File

@@ -0,0 +1,273 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RpgRoller.Data;
#nullable disable
namespace RpgRoller.Migrations
{
[DbContext(typeof(RpgRollerDbContext))]
[Migration("20260518183838_AddUserThemePreference")]
partial class AddUserThemePreference
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GmUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Ruleset")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GmUserId");
b.ToTable("Campaigns");
});
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("CampaignId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("OwnerUserId");
b.ToTable("Characters");
});
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Breakdown")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Dice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");
b.Property<Guid>("RollerUserId")
.HasColumnType("TEXT");
b.Property<Guid>("SkillId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("Visibility")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("CharacterId");
b.HasIndex("RollerUserId");
b.HasIndex("SkillId");
b.ToTable("RollLogEntries");
});
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("RolemasterAutoRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<Guid?>("SkillGroupId")
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SkillGroupId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("SkillGroups");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ActiveCharacterId")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Roles")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("ThemePreference")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("UsernameNormalized")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UsernameNormalized")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
{
b.Property<string>("Token")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Token");
b.HasIndex("UserId");
b.ToTable("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RpgRoller.Migrations
{
/// <inheritdoc />
public partial class AddUserThemePreference : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(name: "ThemePreference", table: "Users", type: "TEXT", maxLength: 16, nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "ThemePreference", table: "Users");
}
}
}

View File

@@ -224,6 +224,10 @@ namespace RpgRoller.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ThemePreference")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Username") b.Property<string>("Username")
.IsRequired() .IsRequired()
.HasMaxLength(64) .HasMaxLength(64)

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Domain; using RpgRoller.Domain;
@@ -32,7 +32,8 @@ public sealed class GameAuthService(GameStateStore stateStore, IPasswordHasher<U
DisplayName = displayName.Trim(), DisplayName = displayName.Trim(),
PasswordHash = string.Empty, PasswordHash = string.Empty,
Roles = stateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty, Roles = stateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty,
ActiveCharacterId = null ActiveCharacterId = null,
ThemePreference = null
}; };
user.PasswordHash = passwordHasher.HashPassword(user, password); user.PasswordHash = passwordHasher.HashPassword(user, password);
@@ -112,6 +113,23 @@ public sealed class GameAuthService(GameStateStore stateStore, IPasswordHasher<U
} }
} }
public ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference)
{
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<UserSummary>.Failure("unauthorized", "You must be logged in.");
if (!ThemePreferences.IsSupported(themePreference))
return ServiceResult<UserSummary>.Failure("invalid_theme_preference", "Theme preference must be light or dark.");
user.ThemePreference = ThemePreferences.Normalize(themePreference);
persistenceService.PersistStateLocked();
return ServiceResult<UserSummary>.Success(GameDtoMapper.ToUserSummary(user));
}
}
private UserSession CreateSession(Guid userId) private UserSession CreateSession(Guid userId)
{ {
var token = Guid.NewGuid().ToString("N"); var token = Guid.NewGuid().ToString("N");

View File

@@ -1,4 +1,4 @@
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Domain; using RpgRoller.Domain;
namespace RpgRoller.Services; namespace RpgRoller.Services;
@@ -7,7 +7,7 @@ public static class GameDtoMapper
{ {
public static UserSummary ToUserSummary(UserAccount user) public static UserSummary ToUserSummary(UserAccount user)
{ {
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles), user.ThemePreference);
} }
public static AdminUserSummary ToAdminUserSummary(UserAccount user) public static AdminUserSummary ToAdminUserSummary(UserAccount user)

View File

@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Domain; using RpgRoller.Domain;
@@ -40,7 +40,8 @@ public sealed class GamePersistenceService(IDbContextFactory<RpgRollerDbContext>
PasswordHash = user.PasswordHash, PasswordHash = user.PasswordHash,
DisplayName = user.DisplayName, DisplayName = user.DisplayName,
Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)), Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)),
ActiveCharacterId = user.ActiveCharacterId ActiveCharacterId = user.ActiveCharacterId,
ThemePreference = string.IsNullOrWhiteSpace(user.ThemePreference) ? null : ThemePreferences.Normalize(user.ThemePreference)
}; };
stateStore.UsersById[storedUser.Id] = storedUser; stateStore.UsersById[storedUser.Id] = storedUser;
stateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id; stateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id;

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Data; using RpgRoller.Data;
@@ -55,6 +55,11 @@ public sealed class GameService : IGameService
return m_AuthService.GetMe(sessionToken); return m_AuthService.GetMe(sessionToken);
} }
public ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference)
{
return m_AuthService.UpdateThemePreference(sessionToken, themePreference);
}
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId) public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{ {
return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId); return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId);

View File

@@ -1,4 +1,4 @@
using RpgRoller.Domain; using RpgRoller.Domain;
namespace RpgRoller.Services; namespace RpgRoller.Services;
@@ -14,7 +14,8 @@ public static class GameStateCloneFactory
PasswordHash = user.PasswordHash, PasswordHash = user.PasswordHash,
DisplayName = user.DisplayName, DisplayName = user.DisplayName,
Roles = user.Roles, Roles = user.Roles,
ActiveCharacterId = user.ActiveCharacterId ActiveCharacterId = user.ActiveCharacterId,
ThemePreference = user.ThemePreference
}; };
} }

View File

@@ -1,4 +1,4 @@
using RpgRoller.Contracts; using RpgRoller.Contracts;
namespace RpgRoller.Services; namespace RpgRoller.Services;
@@ -11,6 +11,7 @@ public interface IGameService
void Logout(string sessionToken); void Logout(string sessionToken);
UserSummary? GetUserBySession(string sessionToken); UserSummary? GetUserBySession(string sessionToken);
ServiceResult<MeResponse> GetMe(string sessionToken); ServiceResult<MeResponse> GetMe(string sessionToken);
ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference);
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId); ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken); ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 3.2 MiB

View File

@@ -1,4 +1,4 @@
window.rpgRollerApi = (() => { window.rpgRollerApi = (() => {
const sessionPrefix = "rpgroller."; const sessionPrefix = "rpgroller.";
const stateStream = { const stateStream = {
source: null, source: null,
@@ -22,6 +22,18 @@ window.rpgRollerApi = (() => {
return new URL(relativeUrl, document.baseURI).toString(); return new URL(relativeUrl, document.baseURI).toString();
} }
function normalizeTheme(theme) {
return theme === "dark" ? "dark" : "light";
}
function getSystemTheme() {
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme) {
document.documentElement.dataset.theme = normalizeTheme(theme);
}
function clearReconnectTimer() { function clearReconnectTimer() {
if (stateStream.reconnectTimer) { if (stateStream.reconnectTimer) {
clearTimeout(stateStream.reconnectTimer); clearTimeout(stateStream.reconnectTimer);
@@ -385,6 +397,8 @@ window.rpgRollerApi = (() => {
return { return {
request, request,
applyTheme,
getSystemTheme,
getSessionValue, getSessionValue,
setSessionValue, setSessionValue,
startStateEvents, startStateEvents,

View File

@@ -1,4 +1,5 @@
:root { :root {
color-scheme: light;
--bg-top: #f7f0d8; --bg-top: #f7f0d8;
--bg-bottom: #ecdfc4; --bg-bottom: #ecdfc4;
--button-hover: #dccfb4; --button-hover: #dccfb4;
@@ -14,6 +15,147 @@
--public: #2d6645; --public: #2d6645;
--private-self: #4f3a8f; --private-self: #4f3a8f;
--private-gm: #915119; --private-gm: #915119;
--page-background: url("/images/light.png");
--card-strong: #fff8ea;
--accent-dark: #2f4f34;
--accent-2-hover: #6b2419;
--header-bg: linear-gradient(120deg, #f1e4c9, #efe0bf);
--input-bg: #fffdf5;
--input-border: #8e7b57;
--button-text: #f8f7ef;
--switch-active-text: #fff9ef;
--tab-active-bg: linear-gradient(145deg, #e9d4a4, #d7b672);
--tab-active-border: #9e7328;
--section-border: #a89066;
--skill-group-bg: #f8f0de;
--chip-border: #decbb7;
--menu-shadow: rgba(34, 24, 9, 0.2);
--die-border: #2a2418;
--die-bg: #ffffff;
--die-text: #1f1a13;
--die-wild: #c79913;
--die-crit-bg: #d8ffc2;
--die-crit-text: #18490f;
--die-fumble-bg: #ffb5a8;
--die-fumble-text: #661110;
--die-added-bg: #dbffdf;
--die-added-text: #206029;
--die-removed-bg: #fde0dd;
--die-removed-text: #7f5f55;
--die-neutral-bg: #f8f1df;
--die-neutral-text: #3f2f12;
--die-open-high-bg: #dff6df;
--die-open-high-text: #1d5b26;
--die-open-high-border: #2a7c39;
--die-open-low-bg: #ffe1dc;
--die-open-low-text: #8a2217;
--die-open-low-border: #b74334;
--success-bg: #e8f7e8;
--success-border: #78a978;
--success-text: #1f5425;
--error-bg: #ffe9e5;
--error-border: #bb6e62;
--error-text: #7f2015;
--rare-bg: #fff1c7;
--rare-border: #b48b34;
--rare-text: #6d4c05;
--active-bg: #f6d28d;
--active-border: #8f5f12;
--active-text: #5d3808;
--skeleton-bg: linear-gradient(90deg, #dfd2b7, #efe3c9, #dfd2b7);
--health-bg: #fff2db;
--health-border: #b77a29;
--modal-overlay: rgba(35, 25, 9, 0.55);
--mobile-nav-bg: rgba(241, 228, 201, 0.96);
--toast-shadow: rgba(34, 24, 9, 0.22);
--surface-mix: #ffffff;
--transparent-mix: transparent;
--custom-roll-error-bg: #fff0ee;
--custom-roll-error-border: #6b2015;
--custom-roll-error-shadow: rgba(181, 58, 35, 0.12);
--entry-shadow: rgba(60, 41, 12, 0.07);
--entry-shadow-hover: rgba(60, 41, 12, 0.11);
--fresh-shadow: rgba(199, 153, 19, 0.16);
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg-top: #060b13;
--bg-bottom: #0c1726;
--button-hover: rgba(62, 89, 123, 0.72);
--card: rgba(8, 15, 26, 0.84);
--card-border: #37516c;
--text: #edf5ff;
--muted: #adc1d6;
--accent: #5aa0cf;
--accent-2: #f0b35a;
--warn: #f4c35f;
--danger: #ff8b7a;
--focus: #91d5ff;
--public: #78d08f;
--private-self: #b9a0ff;
--private-gm: #f0b16c;
--page-background: url("/images/dark.png");
--card-strong: rgba(13, 24, 39, 0.96);
--accent-dark: #2d638f;
--accent-2-hover: #ffd085;
--header-bg: linear-gradient(120deg, rgba(10, 18, 31, 0.94), rgba(17, 31, 48, 0.9));
--input-bg: rgba(7, 14, 24, 0.92);
--input-border: #52708f;
--button-text: #f3f8ff;
--switch-active-text: #0b1420;
--tab-active-bg: linear-gradient(145deg, #244967, #17324d);
--tab-active-border: #6ca6d0;
--section-border: #486986;
--skill-group-bg: rgba(19, 34, 52, 0.72);
--chip-border: #415f7b;
--menu-shadow: rgba(0, 0, 0, 0.42);
--die-border: #9cb8d3;
--die-bg: #0f1c2d;
--die-text: #edf5ff;
--die-wild: #ffd770;
--die-crit-bg: #163f2a;
--die-crit-text: #a5f0b5;
--die-fumble-bg: #4b1c20;
--die-fumble-text: #ffc0b8;
--die-added-bg: #163f2a;
--die-added-text: #a5f0b5;
--die-removed-bg: #3b2630;
--die-removed-text: #e0abb7;
--die-neutral-bg: #14283e;
--die-neutral-text: #e5f1ff;
--die-open-high-bg: #133b2b;
--die-open-high-text: #a5f0b5;
--die-open-high-border: #64c783;
--die-open-low-bg: #482025;
--die-open-low-text: #ffc0b8;
--die-open-low-border: #ff8b7a;
--success-bg: #173b29;
--success-border: #66bd7f;
--success-text: #b6f1c3;
--error-bg: #452126;
--error-border: #d46b62;
--error-text: #ffc2ba;
--rare-bg: #3d3218;
--rare-border: #cfae52;
--rare-text: #ffe09a;
--active-bg: #4a3514;
--active-border: #e0b35d;
--active-text: #ffe1a3;
--skeleton-bg: linear-gradient(90deg, #172438, #263b54, #172438);
--health-bg: #3a2d19;
--health-border: #d09b4c;
--modal-overlay: rgba(1, 6, 13, 0.74);
--mobile-nav-bg: rgba(10, 18, 31, 0.96);
--toast-shadow: rgba(0, 0, 0, 0.42);
--surface-mix: #000000;
--transparent-mix: transparent;
--custom-roll-error-bg: #371c21;
--custom-roll-error-border: #ffc0b8;
--custom-roll-error-shadow: rgba(255, 139, 122, 0.18);
--entry-shadow: rgba(0, 0, 0, 0.24);
--entry-shadow-hover: rgba(0, 0, 0, 0.34);
--fresh-shadow: rgba(255, 215, 112, 0.2);
} }
* { * {
@@ -28,7 +170,7 @@ body {
} }
html { html {
background-image: url("/images/rpg.png"); background-image: var(--page-background);
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
@@ -100,7 +242,7 @@ h3 {
top: 0; top: 0;
z-index: 10; z-index: 10;
display: flex; display: flex;
background: linear-gradient(120deg, #f1e4c9, #efe0bf); background: var(--header-bg);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 0.8rem; border-radius: 0.8rem;
padding: 0.5rem 0.7rem; padding: 0.5rem 0.7rem;
@@ -209,19 +351,19 @@ select,
button { button {
font: inherit; font: inherit;
border-radius: 0.45rem; border-radius: 0.45rem;
border: 1px solid #8e7b57; border: 1px solid var(--input-border);
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
} }
input, input,
select { select {
background: #fffdf5; background: var(--input-bg);
color: var(--text); color: var(--text);
} }
button { button {
background: linear-gradient(180deg, var(--accent), #2f4f34); background: linear-gradient(180deg, var(--accent), var(--accent-dark));
color: #f8f7ef; color: var(--button-text);
border-color: transparent; border-color: transparent;
cursor: pointer; cursor: pointer;
} }
@@ -229,19 +371,19 @@ button {
button.ghost { button.ghost {
background: transparent; background: transparent;
color: var(--text); color: var(--text);
border-color: #8e7b57; border-color: var(--input-border);
} }
button.switch { button.switch {
background: transparent; background: transparent;
color: var(--text); color: var(--text);
border-color: #8e7b57; border-color: var(--input-border);
} }
button.switch.active { button.switch.active {
background: var(--accent-2); background: var(--accent-2);
border-color: var(--accent-2); border-color: var(--accent-2);
color: #fff9ef; color: var(--switch-active-text);
} }
button:disabled { button:disabled {
@@ -327,12 +469,12 @@ select:focus-visible {
white-space: nowrap; white-space: nowrap;
background: transparent; background: transparent;
color: var(--text); color: var(--text);
border-color: #8e7b57; border-color: var(--input-border);
} }
.icon-tab.active { .icon-tab.active {
background: linear-gradient(145deg, #e9d4a4, #d7b672); background: var(--tab-active-bg);
border-color: #9e7328; border-color: var(--tab-active-border);
} }
.icon-tab-glyph { .icon-tab-glyph {
@@ -342,7 +484,7 @@ select:focus-visible {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 50%; border-radius: 50%;
border: 1px solid #8e7b57; border: 1px solid var(--input-border);
font-weight: 700; font-weight: 700;
font-size: 0.72rem; font-size: 0.72rem;
} }
@@ -353,7 +495,7 @@ select:focus-visible {
} }
.skills-section { .skills-section {
border: 1px dashed #a89066; border: 1px dashed var(--section-border);
border-radius: 0.65rem; border-radius: 0.65rem;
padding: 0.55rem; padding: 0.55rem;
display: flex; display: flex;
@@ -414,7 +556,7 @@ select:focus-visible {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background-color: #f8f0de; background-color: var(--skill-group-bg);
padding: 0.1rem; padding: 0.1rem;
border-top: 1px solid var(--card-border); border-top: 1px solid var(--card-border);
gap: 0.5rem; gap: 0.5rem;
@@ -457,11 +599,11 @@ select:focus-visible {
border-radius: 999px; border-radius: 999px;
background: transparent; background: transparent;
color: var(--text); color: var(--text);
border-color: #decbb7; border-color: var(--chip-border);
} }
.chip-button:hover { .chip-button:hover {
border-color: #8e7b57; border-color: var(--input-border);
background: var(--button-hover); background: var(--button-hover);
} }
@@ -469,13 +611,13 @@ select:focus-visible {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
align-items: center; align-items: center;
justify-items: start; justify-items: start;
background: #00000000; background: transparent;
} }
.skill-create-icon { .skill-create-icon {
width: 1.45rem; width: 1.45rem;
height: 1.45rem; height: 1.45rem;
border: 1px solid #8e7b57; border: 1px solid var(--input-border);
border-radius: 999px; border-radius: 999px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -520,7 +662,7 @@ select:focus-visible {
.menu-toggle { .menu-toggle {
background: transparent; background: transparent;
color: var(--text); color: var(--text);
border-color: #8e7b57; border-color: var(--input-border);
display: inline-flex; display: inline-flex;
gap: 0.4rem; gap: 0.4rem;
align-items: center; align-items: center;
@@ -537,12 +679,12 @@ select:focus-visible {
z-index: 40; z-index: 40;
min-width: 14.5rem; min-width: 14.5rem;
padding: 0.35rem; padding: 0.35rem;
background: #fff8ea; background: var(--card-strong);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 0.55rem; border-radius: 0.55rem;
display: grid; display: grid;
gap: 0.3rem; gap: 0.3rem;
box-shadow: 0 8px 16px rgba(34, 24, 9, 0.2); box-shadow: 0 8px 16px var(--menu-shadow);
} }
.menu-item { .menu-item {
@@ -550,12 +692,12 @@ select:focus-visible {
text-align: left; text-align: left;
background: transparent; background: transparent;
color: var(--text); color: var(--text);
border-color: #8e7b57; border-color: var(--input-border);
} }
.menu-item.active { .menu-item.active {
background: #ecd8ae; background: var(--button-hover);
border-color: #9a7f43; border-color: var(--tab-active-border);
} }
.logout-link { .logout-link {
@@ -566,7 +708,7 @@ select:focus-visible {
} }
.logout-link:hover { .logout-link:hover {
color: #6b2419; color: var(--accent-2-hover);
} }
.logout-link:focus-visible { .logout-link:focus-visible {
@@ -574,6 +716,22 @@ select:focus-visible {
outline-offset: 2px; outline-offset: 2px;
} }
.theme-toggle {
width: 2.25rem;
height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
color: var(--text);
border-color: var(--input-border);
}
.theme-toggle:hover {
background: var(--button-hover);
}
.roll-total { .roll-total {
font-size: 1.8rem; font-size: 1.8rem;
font-weight: 800; font-weight: 800;
@@ -597,10 +755,10 @@ select:focus-visible {
min-width: 2.1rem; min-width: 2.1rem;
height: 2.1rem; height: 2.1rem;
padding: 0.2rem 0.45rem 0; padding: 0.2rem 0.45rem 0;
border: 2px solid #2a2418; border: 2px solid var(--die-border);
border-radius: 0.45rem; border-radius: 0.45rem;
background: #ffffff; background: var(--die-bg);
color: #1f1a13; color: var(--die-text);
font-size: 2rem; font-size: 2rem;
line-height: 1; line-height: 1;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -608,27 +766,27 @@ select:focus-visible {
.die-chip.wild { .die-chip.wild {
border-width: 3px; border-width: 3px;
border-color: #c79913; border-color: var(--die-wild);
} }
.die-chip.crit { .die-chip.crit {
background: #d8ffc2; background: var(--die-crit-bg);
color: #18490f; color: var(--die-crit-text);
} }
.die-chip.fumble { .die-chip.fumble {
background: #ffb5a8; background: var(--die-fumble-bg);
color: #661110; color: var(--die-fumble-text);
} }
.die-chip.added { .die-chip.added {
background: #dbffdf; background: var(--die-added-bg);
color: #206029; color: var(--die-added-text);
} }
.die-chip.removed { .die-chip.removed {
background: #fde0dd; background: var(--die-removed-bg);
color: #7f5f55; color: var(--die-removed-text);
border-style: dashed; border-style: dashed;
} }
@@ -646,20 +804,20 @@ select:focus-visible {
.die-chip.rolemaster-initiative, .die-chip.rolemaster-initiative,
.die-chip.rolemaster-percentile, .die-chip.rolemaster-percentile,
.die-chip.rolemaster-open-ended-initial { .die-chip.rolemaster-open-ended-initial {
background: #f8f1df; background: var(--die-neutral-bg);
color: #3f2f12; color: var(--die-neutral-text);
} }
.die-chip.rolemaster-open-ended-high { .die-chip.rolemaster-open-ended-high {
background: #dff6df; background: var(--die-open-high-bg);
color: #1d5b26; color: var(--die-open-high-text);
border-color: #2a7c39; border-color: var(--die-open-high-border);
} }
.die-chip.rolemaster-open-ended-low-subtract { .die-chip.rolemaster-open-ended-low-subtract {
background: #ffe1dc; background: var(--die-open-low-bg);
color: #8a2217; color: var(--die-open-low-text);
border-color: #b74334; border-color: var(--die-open-low-border);
} }
.empty, .empty,
@@ -676,11 +834,11 @@ select:focus-visible {
} }
.custom-roll-panel { .custom-roll-panel {
border-top: 1px solid color-mix(in srgb, var(--card-border) 72%, #ffffff 28%); border-top: 1px solid color-mix(in srgb, var(--card-border) 72%, var(--surface-mix) 28%);
background: color-mix(in srgb, var(--card) 88%, #ffffff 12%); background: color-mix(in srgb, var(--card) 88%, var(--surface-mix) 12%);
border-radius: 0.95rem; border-radius: 0.95rem;
padding: 0.85rem 0.9rem 0.9rem; padding: 0.85rem 0.9rem 0.9rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45); box-shadow: inset 0 1px 0 color-mix(in srgb, var(--surface-mix) 45%, transparent 55%);
} }
.custom-roll-composer { .custom-roll-composer {
@@ -712,14 +870,14 @@ select:focus-visible {
min-width: 0; min-width: 0;
padding: 0.72rem 0.9rem; padding: 0.72rem 0.9rem;
border-radius: 999px; border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--card-border) 78%, #ffffff 22%); border: 1px solid color-mix(in srgb, var(--card-border) 78%, var(--surface-mix) 22%);
background: color-mix(in srgb, var(--card) 90%, #ffffff 10%); background: color-mix(in srgb, var(--card) 90%, var(--surface-mix) 10%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); box-shadow: inset 0 1px 0 color-mix(in srgb, var(--surface-mix) 60%, transparent 40%);
transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease; transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
} }
.custom-roll-input::placeholder { .custom-roll-input::placeholder {
color: color-mix(in srgb, var(--muted) 80%, #ffffff 20%); color: color-mix(in srgb, var(--muted) 80%, var(--surface-mix) 20%);
} }
.custom-roll-input:hover:not(:disabled) { .custom-roll-input:hover:not(:disabled) {
@@ -727,9 +885,9 @@ select:focus-visible {
} }
.custom-roll-input.error { .custom-roll-input.error {
border-color: color-mix(in srgb, var(--danger) 74%, #6b2015 26%); border-color: color-mix(in srgb, var(--danger) 74%, var(--custom-roll-error-border) 26%);
background: color-mix(in srgb, #fff0ee 84%, var(--card) 16%); background: color-mix(in srgb, var(--custom-roll-error-bg) 84%, var(--card) 16%);
box-shadow: 0 0 0 3px rgba(181, 58, 35, 0.12); box-shadow: 0 0 0 3px var(--custom-roll-error-shadow);
} }
.custom-roll-composer-row button { .custom-roll-composer-row button {
@@ -739,11 +897,11 @@ select:focus-visible {
} }
.log-entry { .log-entry {
border: 1px solid color-mix(in srgb, var(--card-border) 84%, #ffffff 16%); border: 1px solid color-mix(in srgb, var(--card-border) 84%, var(--surface-mix) 16%);
border-radius: 0.85rem; border-radius: 0.85rem;
background: color-mix(in srgb, var(--card) 96%, #ffffff 4%); background: color-mix(in srgb, var(--card) 96%, var(--surface-mix) 4%);
overflow: hidden; overflow: hidden;
box-shadow: 0 0.45rem 1.2rem rgba(60, 41, 12, 0.07); box-shadow: 0 0.45rem 1.2rem var(--entry-shadow);
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
} }
@@ -762,23 +920,23 @@ select:focus-visible {
.log-entry:hover { .log-entry:hover {
border-color: color-mix(in srgb, var(--accent) 28%, var(--card-border) 72%); border-color: color-mix(in srgb, var(--accent) 28%, var(--card-border) 72%);
box-shadow: 0 0.7rem 1.55rem rgba(60, 41, 12, 0.11); box-shadow: 0 0.7rem 1.55rem var(--entry-shadow-hover);
} }
.log-entry.private-self { .log-entry.private-self {
border-left: 0.35rem solid color-mix(in srgb, var(--private-self) 78%, #ffffff 22%); border-left: 0.35rem solid color-mix(in srgb, var(--private-self) 78%, var(--surface-mix) 22%);
} }
.log-entry.private-gm { .log-entry.private-gm {
border-left: 0.35rem solid color-mix(in srgb, var(--private-gm) 78%, #ffffff 22%); border-left: 0.35rem solid color-mix(in srgb, var(--private-gm) 78%, var(--surface-mix) 22%);
} }
.log-entry.public { .log-entry.public {
border-left: 0.35rem solid color-mix(in srgb, var(--public) 70%, #ffffff 30%); border-left: 0.35rem solid color-mix(in srgb, var(--public) 70%, var(--surface-mix) 30%);
} }
.log-entry.private-generic { .log-entry.private-generic {
border-left: 0.35rem solid color-mix(in srgb, var(--muted) 52%, #ffffff 48%); border-left: 0.35rem solid color-mix(in srgb, var(--muted) 52%, var(--surface-mix) 48%);
} }
.log-entry.expanded { .log-entry.expanded {
@@ -786,12 +944,12 @@ select:focus-visible {
} }
.log-entry.fresh { .log-entry.fresh {
border-color: color-mix(in srgb, #c79913 52%, var(--card-border) 48%); border-color: color-mix(in srgb, var(--die-wild) 52%, var(--card-border) 48%);
box-shadow: 0 0.9rem 1.8rem rgba(199, 153, 19, 0.16); box-shadow: 0 0.9rem 1.8rem var(--fresh-shadow);
} }
.log-entry-toggle:hover { .log-entry-toggle:hover {
background: color-mix(in srgb, var(--card) 84%, #ffffff 16%); background: color-mix(in srgb, var(--card) 84%, var(--surface-mix) 16%);
} }
.log-entry-toggle:focus-visible { .log-entry-toggle:focus-visible {
@@ -855,21 +1013,21 @@ select:focus-visible {
} }
.log-event-badge.positive { .log-event-badge.positive {
border-color: #79a85d; border-color: var(--success-border);
background: #e7f6da; background: var(--success-bg);
color: #235217; color: var(--success-text);
} }
.log-event-badge.danger { .log-event-badge.danger {
border-color: #c56b5a; border-color: var(--error-border);
background: #ffe3dc; background: var(--error-bg);
color: #7d1f17; color: var(--error-text);
} }
.log-event-badge.rare { .log-event-badge.rare {
border-color: #b48b34; border-color: var(--rare-border);
background: #fff1c7; background: var(--rare-bg);
color: #6d4c05; color: var(--rare-text);
} }
.log-meta { .log-meta {
@@ -885,7 +1043,7 @@ select:focus-visible {
margin: 0 0.65rem 0.65rem; margin: 0 0.65rem 0.65rem;
padding: 0.7rem 0.8rem 0.75rem; padding: 0.7rem 0.8rem 0.75rem;
border-top: 1px solid color-mix(in srgb, var(--card-border) 38%, transparent 62%); border-top: 1px solid color-mix(in srgb, var(--card-border) 38%, transparent 62%);
background: color-mix(in srgb, #ffffff 42%, var(--card) 58%); background: color-mix(in srgb, var(--surface-mix) 42%, var(--card) 58%);
border-radius: 0.7rem; border-radius: 0.7rem;
} }
@@ -904,31 +1062,31 @@ select:focus-visible {
} }
.badge.active { .badge.active {
border-color: #8f5f12; border-color: var(--active-border);
background: #f6d28d; background: var(--active-bg);
color: #5d3808; color: var(--active-text);
} }
.badge.public { .badge.public {
background: color-mix(in srgb, var(--public) 14%, #ffffff 86%); background: color-mix(in srgb, var(--public) 14%, var(--surface-mix) 86%);
color: var(--public); color: var(--public);
border-color: color-mix(in srgb, var(--public) 34%, transparent 66%); border-color: color-mix(in srgb, var(--public) 34%, transparent 66%);
} }
.badge.private-self { .badge.private-self {
background: color-mix(in srgb, var(--private-self) 12%, #ffffff 88%); background: color-mix(in srgb, var(--private-self) 12%, var(--surface-mix) 88%);
color: var(--private-self); color: var(--private-self);
border-color: color-mix(in srgb, var(--private-self) 30%, transparent 70%); border-color: color-mix(in srgb, var(--private-self) 30%, transparent 70%);
} }
.badge.private-gm { .badge.private-gm {
background: color-mix(in srgb, var(--private-gm) 12%, #ffffff 88%); background: color-mix(in srgb, var(--private-gm) 12%, var(--surface-mix) 88%);
color: var(--private-gm); color: var(--private-gm);
border-color: color-mix(in srgb, var(--private-gm) 30%, transparent 70%); border-color: color-mix(in srgb, var(--private-gm) 30%, transparent 70%);
} }
.badge.private-generic { .badge.private-generic {
background: color-mix(in srgb, var(--muted) 12%, #ffffff 88%); background: color-mix(in srgb, var(--muted) 12%, var(--surface-mix) 88%);
color: var(--muted); color: var(--muted);
border-color: color-mix(in srgb, var(--muted) 28%, transparent 72%); border-color: color-mix(in srgb, var(--muted) 28%, transparent 72%);
} }
@@ -993,7 +1151,7 @@ select:focus-visible {
.skeleton-line { .skeleton-line {
height: 0.85rem; height: 0.85rem;
border-radius: 0.4rem; border-radius: 0.4rem;
background: linear-gradient(90deg, #dfd2b7, #efe3c9, #dfd2b7); background: var(--skeleton-bg);
background-size: 220% 100%; background-size: 220% 100%;
animation: shimmer 1.1s linear infinite; animation: shimmer 1.1s linear infinite;
} }
@@ -1003,8 +1161,8 @@ select:focus-visible {
} }
.health-banner { .health-banner {
border: 1px solid #b77a29; border: 1px solid var(--health-border);
background: #fff2db; background: var(--health-bg);
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 0.75rem; padding: 0.75rem;
display: flex; display: flex;
@@ -1026,7 +1184,7 @@ select:focus-visible {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border: 1px solid #b8a37b; border: 1px solid var(--card-border);
border-radius: 0.55rem; border-radius: 0.55rem;
padding: 0.5rem; padding: 0.5rem;
} }
@@ -1047,9 +1205,9 @@ select:focus-visible {
gap: 0.45rem; gap: 0.45rem;
align-self: flex-start; align-self: flex-start;
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
border: 1px solid #b39f79; border: 1px solid var(--card-border);
border-radius: 0.45rem; border-radius: 0.45rem;
background: #f9f2e2; background: var(--input-bg);
color: var(--text); color: var(--text);
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;
@@ -1069,15 +1227,15 @@ select:focus-visible {
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.45rem;
align-self: flex-start; align-self: flex-start;
background: #f9f2e2; background: var(--input-bg);
color: var(--text); color: var(--text);
border: 1px solid #b39f79; border: 1px solid var(--card-border);
} }
.add-row-icon { .add-row-icon {
width: 1.2rem; width: 1.2rem;
height: 1.2rem; height: 1.2rem;
border: 1px solid #8e7b57; border: 1px solid var(--input-border);
border-radius: 999px; border-radius: 999px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1088,7 +1246,7 @@ select:focus-visible {
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(35, 25, 9, 0.55); background: var(--modal-overlay);
display: grid; display: grid;
place-items: center; place-items: center;
z-index: 20; z-index: 20;
@@ -1118,7 +1276,7 @@ select:focus-visible {
padding: 0.55rem; padding: 0.55rem;
display: none; display: none;
gap: 0.45rem; gap: 0.45rem;
background: rgba(241, 228, 201, 0.96); background: var(--mobile-nav-bg);
border-top: 1px solid var(--card-border); border-top: 1px solid var(--card-border);
} }
@@ -1136,7 +1294,7 @@ select:focus-visible {
border-radius: 0.6rem; border-radius: 0.6rem;
border: 1px solid; border: 1px solid;
padding: 0.55rem 0.7rem; padding: 0.55rem 0.7rem;
box-shadow: 0 6px 14px rgba(34, 24, 9, 0.22); box-shadow: 0 6px 14px var(--toast-shadow);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
@@ -1146,15 +1304,15 @@ select:focus-visible {
} }
.toast.success { .toast.success {
background: #e8f7e8; background: var(--success-bg);
border-color: #78a978; border-color: var(--success-border);
color: #1f5425; color: var(--success-text);
} }
.toast.error { .toast.error {
background: #ffe9e5; background: var(--error-bg);
border-color: #bb6e62; border-color: var(--error-border);
color: #7f2015; color: var(--error-text);
} }
.sr-only { .sr-only {

View File

@@ -1,4 +1,4 @@
{ {
"openapi": "3.0.1", "openapi": "3.0.1",
"info": { "info": {
"title": "RpgRoller API", "title": "RpgRoller API",
@@ -156,6 +156,46 @@
} }
} }
}, },
"/api/me/theme": {
"put": {
"operationId": "updateThemePreference",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateThemePreferenceRequest"
}
}
}
},
"responses": {
"200": {
"description": "Updated current user.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSummary"
}
}
}
},
"400": {
"description": "Validation error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Unauthorized."
}
}
}
},
"/api/campaigns": { "/api/campaigns": {
"get": { "get": {
"operationId": "getCampaigns", "operationId": "getCampaigns",
@@ -701,12 +741,27 @@
}, },
"displayName": { "displayName": {
"type": "string" "type": "string"
},
"roles": {
"type": "array",
"items": {
"type": "string"
}
},
"themePreference": {
"type": "string",
"nullable": true,
"enum": [
"light",
"dark"
]
} }
}, },
"required": [ "required": [
"id", "id",
"username", "username",
"displayName" "displayName",
"roles"
] ]
}, },
"MeResponse": { "MeResponse": {
@@ -730,6 +785,21 @@
"user" "user"
] ]
}, },
"UpdateThemePreferenceRequest": {
"type": "object",
"properties": {
"themePreference": {
"type": "string",
"enum": [
"light",
"dark"
]
}
},
"required": [
"themePreference"
]
},
"RulesetDefinition": { "RulesetDefinition": {
"type": "object", "type": "object",
"properties": { "properties": {