From e8d775dfe45f46e5e5bc7bcdca5689ab7dab83ae Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Mon, 16 Mar 2026 09:17:05 -0700 Subject: [PATCH] feat(UI): add Night Ops dark mode with theme toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a warm charcoal dark mode ("Night Ops") using CSS variable swapping under [data-theme="dark"]. All 23 desert palette variables are overridden with dark-mode counterparts, and ~313 generic Tailwind classes (bg-white, text-gray-*, border-gray-*) are replaced with semantic tokens. Infrastructure: - CSS variable overrides in app.css for both themes - ThemeProvider + useTheme hook (localStorage + KV store sync) - ThemeToggle component (moon/sun icons, "Night Ops"/"Day Ops" labels) - FOUC prevention script in inertia_layout.edge - Toggle placed in StyledSidebar and Footer for access on every page Color replacements across 50 files: - bg-white → bg-surface-primary - bg-gray-50/100 → bg-surface-secondary - text-gray-900/800 → text-text-primary - text-gray-600/500 → text-text-secondary/text-text-muted - border-gray-200/300 → border-border-subtle/border-border-default - text-desert-white → text-white (fixes invisible text on colored bg) - Button hover/active states use dedicated btn-green-hover/active vars Co-Authored-By: Claude Opus 4.6 --- admin/constants/kv_store.ts | 2 +- admin/inertia/app/app.tsx | 19 +-- admin/inertia/components/ActiveDownloads.tsx | 2 +- admin/inertia/components/ActiveEmbedJobs.tsx | 2 +- .../components/ActiveModelDownloads.tsx | 2 +- admin/inertia/components/Alert.tsx | 18 +-- admin/inertia/components/BouncingDots.tsx | 8 +- admin/inertia/components/DownloadURLModal.tsx | 6 +- admin/inertia/components/Footer.tsx | 8 +- .../inertia/components/HorizontalBarChart.tsx | 2 +- .../components/InstallActivityFeed.tsx | 14 +- admin/inertia/components/LoadingSpinner.tsx | 4 +- admin/inertia/components/ProgressBar.tsx | 4 +- .../components/StorageProjectionBar.tsx | 2 +- admin/inertia/components/StyledButton.tsx | 18 +-- admin/inertia/components/StyledModal.tsx | 6 +- admin/inertia/components/StyledSidebar.tsx | 8 +- admin/inertia/components/StyledTable.tsx | 18 +-- admin/inertia/components/ThemeToggle.tsx | 24 ++++ .../inertia/components/TierSelectionModal.tsx | 34 ++--- .../inertia/components/UpdateServiceModal.tsx | 18 +-- .../inertia/components/WikipediaSelector.tsx | 16 +-- .../inertia/components/chat/ChatInterface.tsx | 24 ++-- .../components/chat/ChatMessageBubble.tsx | 12 +- admin/inertia/components/chat/ChatModal.tsx | 2 +- admin/inertia/components/chat/ChatSidebar.tsx | 12 +- .../components/chat/KnowledgeBaseModal.tsx | 20 +-- admin/inertia/components/chat/index.tsx | 18 +-- admin/inertia/components/inputs/Input.tsx | 6 +- admin/inertia/components/inputs/Switch.tsx | 6 +- .../components/layout/BackToHomeHeader.tsx | 4 +- admin/inertia/components/markdoc/Table.tsx | 6 +- .../components/systeminfo/InfoCard.tsx | 4 +- admin/inertia/css/app.css | 92 ++++++++++++- admin/inertia/hooks/useTheme.ts | 47 +++++++ admin/inertia/layouts/AppLayout.tsx | 2 +- admin/inertia/layouts/SettingsLayout.tsx | 2 +- admin/inertia/pages/easy-setup/complete.tsx | 2 +- admin/inertia/pages/easy-setup/index.tsx | 128 +++++++++--------- admin/inertia/pages/home.tsx | 2 +- admin/inertia/pages/maps.tsx | 4 +- admin/inertia/pages/settings/apps.tsx | 16 +-- admin/inertia/pages/settings/legal.tsx | 34 ++--- admin/inertia/pages/settings/maps.tsx | 8 +- admin/inertia/pages/settings/models.tsx | 40 +++--- admin/inertia/pages/settings/system.tsx | 4 +- admin/inertia/pages/settings/update.tsx | 12 +- admin/inertia/pages/settings/zim/index.tsx | 6 +- .../pages/settings/zim/remote-explorer.tsx | 20 +-- .../providers/NotificationProvider.tsx | 2 +- admin/inertia/providers/ThemeProvider.tsx | 27 ++++ admin/resources/views/inertia_layout.edge | 11 ++ admin/types/kv_store.ts | 1 + 53 files changed, 503 insertions(+), 306 deletions(-) create mode 100644 admin/inertia/components/ThemeToggle.tsx create mode 100644 admin/inertia/hooks/useTheme.ts create mode 100644 admin/inertia/providers/ThemeProvider.tsx diff --git a/admin/constants/kv_store.ts b/admin/constants/kv_store.ts index 7cae751..69872ff 100644 --- a/admin/constants/kv_store.ts +++ b/admin/constants/kv_store.ts @@ -1,3 +1,3 @@ import { KVStoreKey } from "../types/kv_store.js"; -export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'system.earlyAccess', 'ai.assistantCustomName']; \ No newline at end of file +export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName']; \ No newline at end of file diff --git a/admin/inertia/app/app.tsx b/admin/inertia/app/app.tsx index 2eabe10..6026347 100644 --- a/admin/inertia/app/app.tsx +++ b/admin/inertia/app/app.tsx @@ -11,6 +11,7 @@ import { generateUUID } from '~/lib/util' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import NotificationsProvider from '~/providers/NotificationProvider' +import { ThemeProvider } from '~/providers/ThemeProvider' import { UsePageProps } from '../../types/system' const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.' @@ -38,14 +39,16 @@ createInertiaApp({ const showDevtools = ['development', 'staging'].includes(environment) createRoot(el).render( - - - - - {showDevtools && } - - - + + + + + + {showDevtools && } + + + + ) }, diff --git a/admin/inertia/components/ActiveDownloads.tsx b/admin/inertia/components/ActiveDownloads.tsx index 5eb30f4..1319aaa 100644 --- a/admin/inertia/components/ActiveDownloads.tsx +++ b/admin/inertia/components/ActiveDownloads.tsx @@ -32,7 +32,7 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) )) ) : ( -

No active downloads

+

No active downloads

)} diff --git a/admin/inertia/components/ActiveEmbedJobs.tsx b/admin/inertia/components/ActiveEmbedJobs.tsx index 5e6914e..9da78bc 100644 --- a/admin/inertia/components/ActiveEmbedJobs.tsx +++ b/admin/inertia/components/ActiveEmbedJobs.tsx @@ -35,7 +35,7 @@ const ActiveEmbedJobs = ({ withHeader = false }: ActiveEmbedJobsProps) => { )) ) : ( -

No files are currently being processed

+

No files are currently being processed

)} diff --git a/admin/inertia/components/ActiveModelDownloads.tsx b/admin/inertia/components/ActiveModelDownloads.tsx index 1727fe5..d1d0b85 100644 --- a/admin/inertia/components/ActiveModelDownloads.tsx +++ b/admin/inertia/components/ActiveModelDownloads.tsx @@ -33,7 +33,7 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps) )) ) : ( -

No active model downloads

+

No active model downloads

)} diff --git a/admin/inertia/components/Alert.tsx b/admin/inertia/components/Alert.tsx index ceff2a0..40fca57 100644 --- a/admin/inertia/components/Alert.tsx +++ b/admin/inertia/components/Alert.tsx @@ -43,7 +43,7 @@ export default function Alert({ } const getIconColor = () => { - if (variant === 'solid') return 'text-desert-white' + if (variant === 'solid') return 'text-white' switch (type) { case 'warning': return 'text-desert-orange' @@ -81,15 +81,15 @@ export default function Alert({ case 'solid': variantStyles.push( type === 'warning' - ? 'bg-desert-orange text-desert-white border border-desert-orange-dark' + ? 'bg-desert-orange text-white border border-desert-orange-dark' : type === 'error' - ? 'bg-desert-red text-desert-white border border-desert-red-dark' + ? 'bg-desert-red text-white border border-desert-red-dark' : type === 'success' - ? 'bg-desert-olive text-desert-white border border-desert-olive-dark' + ? 'bg-desert-olive text-white border border-desert-olive-dark' : type === 'info' - ? 'bg-desert-green text-desert-white border border-desert-green-dark' + ? 'bg-desert-green text-white border border-desert-green-dark' : type === 'info-inverted' - ? 'bg-desert-tan text-desert-white border border-desert-tan-dark' + ? 'bg-desert-tan text-white border border-desert-tan-dark' : '' ) return classNames(baseStyles, 'shadow-lg', ...variantStyles) @@ -112,7 +112,7 @@ export default function Alert({ } const getTitleColor = () => { - if (variant === 'solid') return 'text-desert-white' + if (variant === 'solid') return 'text-white' switch (type) { case 'warning': @@ -131,7 +131,7 @@ export default function Alert({ } const getMessageColor = () => { - if (variant === 'solid') return 'text-desert-white text-opacity-90' + if (variant === 'solid') return 'text-white text-opacity-90' switch (type) { case 'warning': @@ -149,7 +149,7 @@ export default function Alert({ const getCloseButtonStyles = () => { if (variant === 'solid') { - return 'text-desert-white hover:text-desert-white hover:bg-black hover:bg-opacity-20' + return 'text-white hover:text-white hover:bg-black hover:bg-opacity-20' } switch (type) { diff --git a/admin/inertia/components/BouncingDots.tsx b/admin/inertia/components/BouncingDots.tsx index e01c3cc..64027f0 100644 --- a/admin/inertia/components/BouncingDots.tsx +++ b/admin/inertia/components/BouncingDots.tsx @@ -9,18 +9,18 @@ interface BouncingDotsProps { export default function BouncingDots({ text, containerClassName, textClassName }: BouncingDotsProps) { return (
- {text} + {text} diff --git a/admin/inertia/components/DownloadURLModal.tsx b/admin/inertia/components/DownloadURLModal.tsx index 6209578..7298da8 100644 --- a/admin/inertia/components/DownloadURLModal.tsx +++ b/admin/inertia/components/DownloadURLModal.tsx @@ -63,7 +63,7 @@ const DownloadURLModal: React.FC = ({ large >
-

+

Enter the URL of the map region file you wish to download. The URL must be publicly reachable and end with .pmtiles. A preflight check will be run to verify the file's availability, type, and approximate size. @@ -76,11 +76,11 @@ const DownloadURLModal: React.FC = ({ value={url} onChange={(e) => setUrl(e.target.value)} /> -

+
{messages.map((message, idx) => (

{message}

diff --git a/admin/inertia/components/Footer.tsx b/admin/inertia/components/Footer.tsx index 78765f7..8c991db 100644 --- a/admin/inertia/components/Footer.tsx +++ b/admin/inertia/components/Footer.tsx @@ -1,14 +1,16 @@ import { usePage } from '@inertiajs/react' import { UsePageProps } from '../../types/system' +import ThemeToggle from '~/components/ThemeToggle' export default function Footer() { const { appVersion } = usePage().props as unknown as UsePageProps return ( -