Skip to content

Dark mode for Admin Panel#787

Open
nolt wants to merge 5 commits into
MUnique:masterfrom
nolt:dark-mode
Open

Dark mode for Admin Panel#787
nolt wants to merge 5 commits into
MUnique:masterfrom
nolt:dark-mode

Conversation

@nolt
Copy link
Copy Markdown
Contributor

@nolt nolt commented May 29, 2026

Dark mode for Admin Panel


Summary

Adds a dark-mode toggle to the admin panel with persistence across sessions and page reloads. Implementation mirrors the existing CultureSelector pattern (cookie-based, no SignalR state), so the architectural footprint is minimal and the mental model is consistent with what already exists in the codebase.

The toggle lives in the panel header next to the "About" link. State is stored in an OpenMU.Theme cookie. A [data-theme] attribute on <html> drives all styling via CSS custom properties.

What's in

New files

  • src/Web/Shared/Services/ThemeController.cs — MVC controller endpoint Theme/Set?theme=…&redirectUri=… (1:1 with CultureController)
  • src/Web/Shared/Components/ThemeSelector.razor + .razor.cs + .razor.css — toggle button with moon/sun icons from open-iconic
  • src/Web/Shared/wwwroot/themeSelector.js — tiny export function current() reading document.documentElement.dataset.theme for hydration after the Blazor interactive circuit takes over
  • src/Web/Shared/wwwroot/css/theme.css — theme tokens (:root/[data-theme="dark"]) and Bootstrap 4 surface overrides (.bg-light, tables, forms, dropdowns, modals, sidebar dropdown submenu)

Modified files

  • src/Web/AdminPanel/Components/App.razor — sets <html data-theme="…"> from the cookie via [CascadingParameter] HttpContext
  • src/Web/AdminPanel/Components/Layout/MainLayout.razor + .razor.css — adds the <ThemeSelector> to the header, recolors sidebar gradient/breadcrumb/#blazor-error-ui via CSS variables
  • src/Web/AdminPanel/Components/Layout/CreationPanel.razor.css, ConfigurationSearch.razor.css — surface colors switched to CSS variables
  • src/Web/Shared/Components/Form/AutoForm.razor.css — Save/Refresh action bar background switched to CSS variables (fixes light panel under buttons on every config edit page)
  • src/Web/Shared/Exports.cstheme.css added to Stylesheets
  • src/Web/Shared/Properties/Resources.resx + Resources.Designer.cs — three new strings: ToggleTheme, SwitchToDarkMode, SwitchToLightMode
  • src/Web/AdminPanel/_Imports.razor@using Microsoft.AspNetCore.Http so the cascading HttpContext resolves

Design decisions

Cookie + reload (not localStorage + JS swap). Identical pattern to CultureSelector. Keeps SSR honest — the first paint matches the active theme, no flash-of-light-content. Cost: one full reload per toggle, accepted in admin tooling (low traffic, infrequent action).

CSS variables in a separate theme.css, not in the SCSS pipeline. The Dockerfile runs dotnet build -p:ci=true, which skips the CompileSass MSBuild target. SCSS edits never reach the published shared.css in containerized builds. theme.css is a plain static asset loaded via Exports.Stylesheets and works on every host.

Bootstrap 4 surface overrides (not an upgrade to BS5). Bootstrap 5 ships native dark-mode via data-bs-theme, but the migration is a separate, much larger effort. Overriding the specific BS4 classes the panel uses (.bg-light, .table-striped, .dropdown-menu, .form-control, .modal-content, …) is the smallest viable change.

JS hydration via wwwroot/themeSelector.js as an ES module, not colocated .razor.js. Colocated *.razor.js files are 404 in this project's build pipeline (verified — ReconnectModal.razor.js is also 404; see related issues). wwwroot/ is the reliable static-asset path, same as shared.css and our new theme.css. The hydration reads data-theme from <html> after the interactive circuit connects, because [CascadingParameter] HttpContext is null in interactive renders — without this, the toggle would get stuck in dark mode (the icon would flip back to "moon" while the cookie stayed dark).

Icon convention: action-affordance. Moon icon shown in light mode (click → go dark), sun icon shown in dark mode (click → go light). Same convention as iOS, GitHub, Stripe Dashboard, Material Design. Tooltip ("Switch to dark mode" / "Switch to light mode") reinforces the affordance.

Known limitations / out-of-scope

  • Brief icon flicker on the first SSR→interactive transition (~50–200 ms) when reload happens while already in dark mode. The SSR renders the correct icon, the first interactive re-render briefly shows the wrong one (cascading HttpContext is null in interactive), then JS hydration corrects it. Fixable with PersistentComponentState; not worth the boilerplate for an admin tool.
  • Per-culture translations of the three new resource strings — only English defaults added. Project doesn't have per-culture .resx files yet, so consistent with existing convention.
  • MUnique.OpenMU.Web.AdminPanel.csproj SDK reference chain — not touched. Pre-existing static-web-asset path-doubling issue (_content/AdminPanel/_content/<pkg>/… 404s) and the ReconnectModal.razor.js 404 are pre-existing bugs unrelated to dark mode, surfaced in the same DevTools sweep. Tracked separately (see related issues).

Test plan

  • Build the all-in-one Docker container, hit localhost:8082, confirm panel loads in light mode by default
  • Click the moon icon in the header → page reloads → background turns dark, icon becomes sun
  • Click the sun icon → page reloads → back to light mode, icon becomes moon
  • Navigate to Game Configuration → GeneralAutoForm panel and the sticky Save/Refresh bar are dark
  • Navigate to Game Configuration → Monsters — Edit button (rendered as <a class="btn btn-info">) has white text on teal background, readable
  • Navigate to Game Configuration → Merchant Stores — Edit button (rendered as <button class="btn-info">) is consistent
  • Open the Game Configuration dropdown in the sidebar — submenu has dark surface, links contrast cleanly
  • Open any modal (e.g. ItemEdit) — dark modal background, dark form fields
  • Open a table-based view (Accounts, Servers) — striped rows and hover state visible in dark mode
  • Verify cookie OpenMU.Theme = dark is set in DevTools → Application → Cookies after toggling
  • Verify <html data-theme="dark"> is set in DevTools → Elements after toggling
  • Reload the page in dark mode → state persists, icon shows sun on first paint (no flicker on cold load)

Preview:
1
2
3

@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request adds a dark mode feature to the admin panel, allowing users to toggle between light and dark themes with persistence across sessions. The implementation uses a cookie-based state management system to ensure the correct theme is applied during server-side rendering, preventing visual flashes. It leverages CSS variables for theme-specific styling, providing a robust and maintainable way to override Bootstrap 4 components without requiring a full framework upgrade.

Highlights

  • Dark Mode Implementation: Introduced a persistent dark mode toggle for the admin panel, utilizing a cookie-based approach consistent with existing culture selection patterns.
  • Styling Architecture: Implemented CSS custom properties in a new 'theme.css' file to handle surface overrides and theme-specific styling, ensuring compatibility with containerized builds.
  • Component Integration: Added a new 'ThemeSelector' component to the header and updated the main layout to support dynamic theme switching and state hydration.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a theme selection feature (light and dark modes) for the admin panel, adding a ThemeSelector component, a ThemeController to persist the selection in a cookie, and a theme.css stylesheet with CSS variables. Feedback on the changes highlights three critical issues: first, ThemeSelector incorrectly uses constructor injection which is unsupported in Blazor and will cause a runtime exception; second, the redirect URI in ThemeSelector lacks a leading slash, causing 404 errors on nested routes; and third, ThemeController needs to validate the redirectUri parameter to prevent unhandled exceptions from invalid or external URLs.

Comment thread src/Web/Shared/Components/ThemeSelector.razor.cs Outdated
Comment thread src/Web/Shared/Components/ThemeSelector.razor.cs Outdated
Comment thread src/Web/Shared/Services/ThemeController.cs Outdated
nolt added 2 commits May 31, 2026 17:42
Cookie-persisted dark/light theme toggle in the panel header, mirroring
the CultureSelector pattern. ThemeController writes an OpenMU.Theme
cookie; App.razor reads it on SSR to set <html data-theme="…">;
theme.css and CSS-isolation files drive the colors via CSS variables.
ThemeSelector hydrates from the live DOM via a small JS module after
the interactive Blazor circuit takes over (cascading HttpContext is
null in interactive renders).

theme.css is shipped as a plain static asset rather than going through
the SCSS pipeline because the Docker build skips Sass with ci=true.
drop the "Pre-existing regression" section (Eduardo's MUnique#788 now owns that fix)
Review feedback (3 items):

- ThemeSelector: drop the constructor, inject NavigationManager as a
  property to match the [Inject] pattern used by the rest of the
  project (MapEditor, ConfigurationSearch, NavMenu, ...).
- ThemeSelector: prepend a leading slash to the redirect target
  ("/Theme/Set?...") so it always resolves against the application
  root, independently of the current route depth.
- ThemeController: accept a nullable redirectUri and fall back to
  LocalRedirect("/") when it is missing or not a local URL, so a
  malformed request can no longer crash LocalRedirect into a 500.

Dark-mode coverage gaps:

- theme.css: set `color-scheme: dark` under [data-theme="dark"] so
  native form controls (unchecked checkboxes/radios, scrollbars,
  date pickers) render in their dark variant instead of bright white.
- Typeahead.razor.css: replace hardcoded #fff/#ced4da/#6c757d/...
  with the --omu-* variables. Covers every LookupField /
  MultiLookupField / Typeahead in the panel (auto-form lookups on
  ItemDefinition, Skill, MonsterDefinition, ..., plus the Connect
  Server config, item-slot pickers, modal selectors).
- theme.css: shift --omu-sidebar-gradient stops from % to vh and add
  an override for `.sidebar > nav` in dark mode (Navigation.scss
  hardcodes the light gradient there, would otherwise leak into dark
  mode).

- theme.css: add an override for `.sidebar > nav` in dark mode
  (Navigation.scss hardcodes the light gradient there, would
  otherwise leak into dark mode); add an override for
  `.creation-panel-body .form-actions` introduced by MUnique#788 so the
  new sticky footer doesn't stay bright white in dark mode.
@nolt
Copy link
Copy Markdown
Contributor Author

nolt commented May 31, 2026

Thanks for the careful review. Rebased onto current master (which
picked up #788) and pushed a follow-up addressing all three comments
plus a couple of dark-mode coverage gaps I hit while testing.

1. Constructor injection on ThemeSelector
Switched to an [Inject] property. Worth noting that constructor
injection does in fact work on Blazor components since .NET 5+ —
CultureSelector.razor.cs in this repo uses the exact same pattern
and ships in production — but the [Inject] property style is by far
the majority pattern in the codebase (MapEditor, ConfigurationSearch,
NavMenu, etc.), so consistency wins. Renamed to NavigationManager
(PascalCase) per .NET naming conventions for properties.

2. Relative Theme/Set?… URI
Added the leading slash. With <base href="/" /> and
NavigationManager.NavigateTo(uri, forceLoad: true) the relative
form does resolve against the app root regardless of current route
(again confirmed by CultureSelector working in production with the
same pattern), but explicit-is-better-than-implicit here, the
absolute form removes a whole class of edge cases for zero cost.

3. redirectUri validation in ThemeController
Good catch — applied exactly as suggested: nullable parameter,
IsNullOrEmpty || !Url.IsLocalUrl guard, fallback to
LocalRedirect("/"). LocalRedirect does throw
InvalidOperationException on missing/external URIs, which would
have been a 500 on a malformed request.

Heads-up on follow-ups

Comments #2 and #3 also apply verbatim to the existing
CultureSelector.razor.cs / CultureController.cs — I cloned the
Theme code from them so the same hardenings would be useful there.
Happy to send a separate small PR for that if you'd like, didn't
want to expand the scope of this one.

Other changes in this push (not from review)

While testing the final build I noticed three things worth fixing:

  • Unchecked checkboxes and radios in dark mode rendered bright
    white. Added color-scheme: dark to [data-theme="dark"] so
    the browser renders native form controls (and scrollbars) in
    their dark variant.
  • Search fields (LookupField, MultiLookupField, Typeahead)
    ignored the theme because Typeahead.razor.css hardcoded
    Bootstrap defaults. Converted to the --omu-* tokens — fixes
    Item Slot / Skill / Consume Effect on ItemDefinition edit and
    every other auto-form lookup in the panel.
  • After rebasing onto Add sticky position on footer buttons #788 (admin-layout-fixes), the new sticky
    .form-actions footer in CreationPanel.razor.css hardcodes
    background-color: #fff and border-top: 1px solid #dee2e6,
    which stay bright on a dark surface. Added a [data-theme="dark"] .creation-panel-body .form-actions override in theme.css so
    the footer matches the panel in dark mode. (Could alternatively
    be done by switching those two values to --omu-* tokens in
    CreationPanel.razor.css itself — happy to send that as a
    separate cleanup if you'd prefer the source-of-truth fix.)

QuickGrid header/sort/paginator, alert variants, sticky bars

QuickGrid:

- button.col-title + .col-title-text: shared.css globally sets
  `button { color: #212529 }` which leaks to the bare <button> used by
  the QuickGrid sort header. Narrow override so `.btn-*` variants
  (which have their own explicit color) are not affected.
- .sort-indicator and .go-first/previous/next/last: QuickGrid renders
  these arrows as SVG data-URIs in `background-image` with no `fill`
  attribute, defaulting to black. Can't override SVG fill from outside;
  use `filter: invert(1)` so black -> white in dark mode.

Sticky bars added by MUnique#788:

- .add-new-bar (EditConfigGrid.razor.css) hardcodes background-color
  #fff and a #dee2e6 top border — same pattern we already overrode for
  `.creation-panel-body .form-actions`. Added the analogous override.

Modals:

- .blazored-modal (Blazored.Modal NuGet package, `_content/Blazored.Modal/
  blazored.modal.bundle.scp.css`) hardcodes `background-color: #fff` and
  white border. Override the panel surface; backdrop is already
  `rgba(0, 0, 0, 0.5)` so it works on both themes.
- .multi-lookup-field__* (OpenMU's own multi-select) had hardcoded
  Bootstrap defaults across container, tag, dropdown, hover and selected
  states. Mapped to --omu-* tokens — fixes the Plugins config modal
  (gear icon on a plugin) and the Excellent / Wing options pickers in
  ItemEdit.

Bootstrap alert variants (used on Updates.razor and Install.razor):

- .alert-primary, .alert-success, .alert-info, .alert-warning,
  .alert-danger, .alert-light: light backgrounds + dark text are
  illegible on dark mode. Subtle tinted backgrounds (15% accent over
  the dark surface) with bright accent text and 40% accent border,
  per Bootstrap dark-mode conventions. .alert-secondary stays as it
  was overridden in the earlier commit.
- `.alert hr`: Bootstrap sets per-variant `<hr>` border colors as bright
  tints of the alert hue (e.g. .alert-primary hr -> rgb(158, 204, 255));
  too loud over our dark alert backgrounds. One neutral 15%-white line
  for all variants.
@nolt
Copy link
Copy Markdown
Contributor Author

nolt commented May 31, 2026

Pushed one more commit with dark-mode polish I caught while visually
walking the panel. All in theme.css, no other
files touched, no history rewrite.

Coverage gaps fixed:

  • QuickGrid: the <button class="col-title"> header text inherited
    Bootstrap's global button { color: #212529 } so the "Name" column
    header was nearly invisible on dark. Sort indicator and paginator
    arrows (▲▼ ≪ ‹ › ≫) are SVG data-URIs with no fill attribute, default
    to black — used filter: invert(1) since we can't reach into the
    data-URI to recolor.
  • .add-new-bar (sticky "Add New" footer on EditConfigGrid pages
    like Monsters/Items/Skills) is the second sticky-footer Add sticky position on footer buttons #788 added
    with hardcoded #fff + #dee2e6; same override pattern as
    .form-actions from the first commit.
  • Blazored.Modal panel (.blazored-modal) hardcodes
    background-color: #fff in its NuGet bundle. The Plugins ⚙ modal and
    any multi-select modal use it.
  • .multi-lookup-field__* had a full set of hardcoded Bootstrap
    defaults — affects the Plugins modal contents and the Excellent /
    Wing option pickers in ItemEdit.
  • Bootstrap alert variants (.alert-primary / -success / -info
    / -warning / -danger / -light) used on Updates.razor (and
    alert-danger also on Install.razor) were unreadable on dark.
    Tinted-bg + bright-accent-text pattern, plus a neutral <hr> override
    for the per-variant bright hr lines.

Verified each rule lands in the served theme.css via curl and walked
each page (Monsters / Items / Plugins / Updates / Install) by hand in
the browser.

Note: this also indirectly covers a MultiLookupField cleanup that
could alternatively live in the source file itself (replacing #fff /
#ced4da with --omu-* tokens). Happy to send that as a separate small
"convert hardcoded colors to theme tokens" PR if preferred — kept the
fix here in theme.css to avoid spreading changes across more files.

Verification reference (what was checked, for reviewers)

Built locally via the project's docker compose, hit the running admin
panel at http://localhost:8082, toggled dark mode, and walked:

Monsters / Skills / Items / Character classes — Name header, sort
arrows, paginator arrows, Add New bar
Plugins ⚙ Activate — Blazored modal,
MultiLookupField inside
ItemEdit (any item with excellent/wing options) — MultiLookupField
Updates (/config-updates) — alert-success
(currently shown), alert-info / -warning / -primary / -danger /
-light would render correctly with their tinted backgrounds

@nolt nolt changed the title [WiP] Dark mode for Admin Panel Dark mode for Admin Panel May 31, 2026
@nolt
Copy link
Copy Markdown
Contributor Author

nolt commented May 31, 2026

Ready to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant