Add dark mode with toggle and localStorage persistence

Uses Tailwind v4 class-based dark mode with a Stimulus controller for
toggling. Respects prefers-color-scheme as default, prevents FOUC with
an inline script, and persists the user's choice in localStorage. All
views updated with dark: variants for backgrounds, text, borders,
badges, buttons, and form inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-03-22 00:37:58 +11:00
parent 43958f50ce
commit 3d98261a51
38 changed files with 744 additions and 636 deletions

View File

@@ -9,6 +9,15 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<script>
(function() {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
<%= yield :head %>
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
@@ -23,15 +32,15 @@
<%= javascript_importmap_tags %>
</head>
<body>
<body class="dark:bg-gray-900 dark:text-gray-100">
<% if authenticated? %>
<div data-controller="mobile-sidebar">
<%= render "shared/sidebar" %>
<div class="lg:pl-64">
<!-- Mobile menu button -->
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
<button type="button"
class="-m-2.5 p-2.5 text-gray-700"
class="-m-2.5 p-2.5 text-gray-700 dark:text-gray-300"
id="mobile-menu-button"
data-action="click->mobile-sidebar#openSidebar">
<span class="sr-only">Open sidebar</span>
@@ -51,6 +60,16 @@
</div>
<% else %>
<!-- Public layout (signup/signin) -->
<div class="absolute top-4 right-4" data-controller="dark-mode">
<button type="button" data-action="click->dark-mode#toggle" class="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800">
<svg data-dark-mode-target="icon" data-mode="light" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg data-dark-mode-target="icon" data-mode="dark" class="hidden h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
</div>
<main class="container mx-auto mt-28 px-5">
<%= render "shared/flash" %>
<%= yield %>