From 3c8fc91b3ed9a7a6f98a8b79ed02646f90d67b87 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Tue, 7 Jul 2026 18:28:19 +0200 Subject: [PATCH] feat: add complete dark mode support with settings toggle - Set up class-based dark mode in Tailwind config - Add pre-hydration dark mode application to avoid theme flash on initial load - Implement accessible dark mode toggle in settings page - Update all UI surfaces (headers, bottom nav, maps) with dark mode color schemes - Add comprehensive global CSS overrides for arbitrary Tailwind classes - Add brand accent strips to header and bottom nav for consistent visual framing - Refine map waypoint styling: reduce dot size, update label positioning and styling --- app/globals.css | 166 ++++++++++++++++++++++++++++++++--- app/layout.tsx | 6 ++ app/map/page.tsx | 1 + app/page.tsx | 1 + app/settings/page.tsx | 52 +++++++++++ components/BottomNav.tsx | 3 +- components/CollectionMap.tsx | 57 +++++++----- lib/theme.ts | 54 ++++++++++++ tailwind.config.js | 1 + tsconfig.tsbuildinfo | 2 +- 10 files changed, 310 insertions(+), 33 deletions(-) create mode 100644 lib/theme.ts diff --git a/app/globals.css b/app/globals.css index b9a7efd..bb9a81f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -80,6 +80,113 @@ --gray-900: #26251e; } +/* ============================================================= + Dark mode — same brand, inverted surfaces. + Triggered by `` (set from AppSettings.darkMode + in app/layout.tsx). + + Surface scale inverts: page bg becomes deep ink, cards become + slightly lighter ink, and the brand orange stays exactly the + same so CTAs, active states, and waypoints keep their identity. + ============================================================= */ +.dark { + --foreground-rgb: 242, 241, 237; /* cream text */ + --background-rgb: 18, 17, 14; /* near-black ink */ + --surface-100: #1f1e19; + --surface-200: #181710; /* page bg */ + --surface-300: #24231d; /* button default */ + --surface-400: #2d2b24; /* card */ + --surface-500: #38362e; + + /* Greys remap so any `text-gray-*` etc. that survives the + arbitrary-value re-skin reads correctly on dark. */ + --gray-50: #1f1e19; + --gray-100: #24231d; + --gray-200: #2d2b24; + --gray-300: #38362e; + --gray-400: #5a574d; + --gray-500: #8a857b; + --gray-600: #b8b5ac; + --gray-700: #d6d4ce; + --gray-800: #ebeae5; + --gray-900: #f2f1ed; +} + +/* Dark-mode override for the major surfaces. The component code + uses arbitrary values like `bg-[#f2f1ed]` and `bg-[#e6e5e0]` — + those don't read Tailwind dark: variants. Instead we override + via descendant selectors so the same JSX lights up without + rewriting every Tailwind class. + + Scope: page background, card surfaces, modals, header, bottom + nav, search input, and the sticky surfaces. We intentionally + do NOT override text-foreground colors per-component — those + use rgb(var(--foreground-rgb)) via the body class and just + flip automatically. */ +.dark body { color: rgb(var(--foreground-rgb)); background: rgb(var(--background-rgb)); } +.dark .bg-\[\#f2f1ed\] { background-color: var(--surface-200); } +.dark .bg-\[\#e6e5e0\] { background-color: var(--surface-400); } +.dark .bg-\[\#ebeae5\] { background-color: var(--surface-300); } +.dark .bg-\[\#f7f7f4\] { background-color: var(--surface-100); } +.dark .bg-\[\#e1e0db\] { background-color: var(--surface-500); } +/* Header / nav glass surfaces */ +.dark header.bg-\[\#e6e5e0\], +.dark nav.bg-\[\#e6e5e0\]\/95 { + background-color: rgba(24, 23, 16, 0.92); +} +/* Hairline borders — keep them visible but invert */ +.dark .border-\[\rgba\(38\2c 37\2c 30\2c 0\.1\)\] { + border-color: rgba(242, 241, 237, 0.08); +} +.dark .border-\[\rgba\(38\2c 37\2c 30\2c 0\.08\)\] { + border-color: rgba(242, 241, 237, 0.06); +} +.dark .border-\[\rgba\(38\2c 37\2c 30\2c 0\.05\)\] { + border-color: rgba(242, 241, 237, 0.04); +} +.dark .border-\[\rgba\(38\2c 37\2c 30\2c 0\.15\)\] { + border-color: rgba(242, 241, 237, 0.15); +} +/* Translucent text — the 55% / 40% / 60% / 70% inks used across + the app. In dark mode they need to flip to translucent cream. */ +.dark .text-\[\#26251e\] { color: rgb(var(--foreground-rgb)); } +.dark .text-\[\rgba\(38\2c 37\2c 30\2c 0\.55\)\] { color: rgba(242, 241, 237, 0.6); } +.dark .text-\[\rgba\(38\2c 37\2c 30\2c 0\.7\)\] { color: rgba(242, 241, 237, 0.75); } +.dark .text-\[\rgba\(38\2c 37\2c 30\2c 0\.6\)\] { color: rgba(242, 241, 237, 0.65); } +.dark .text-\[\rgba\(38\2c 37\2c 30\2c 0\.4\)\] { color: rgba(242, 241, 237, 0.4); } +.dark .text-\[\rgba\(38\2c 37\2c 30\2c 0\.45\)\] { color: rgba(242, 241, 237, 0.5); } +/* Active-tab "dark pill" (`bg-[#26251e] text-[#f2f1ed]`) — in dark + mode the original colors ARE the dark surface, so the pill + visually disappears. Invert by making it the lighter card + surface with dark ink text. */ +.dark .bg-\[\#26251e\]\/95 { background-color: rgba(242, 241, 237, 0.08); } +/* The active filter chip / toggle button uses solid `bg-[#26251e]` + with cream text. In dark mode we invert the whole chip — cream + pill, dark ink text — so it reads as the selected state on the + dark header. We do this only when paired with `text-[#f2f1ed]` + (the active-state pattern); the same `bg-[#26251e]` on its own + is used for the settings icon tile which stays dark on purpose. */ +.dark .bg-\[\#26251e\].text-\[\#f2f1ed\], +.dark .bg-\[\#26251e\].text-white { + background-color: #ebeae5; + color: #181710; +} +/* Inputs / form controls */ +.dark input.bg-\[\#f2f1ed\] { + background-color: var(--surface-300); + color: rgb(var(--foreground-rgb)); +} +/* Map markers and map UI keep brand orange but the waypoint dot's + cream halo (`#f2f1ed`) needs to flip to dark to stay visible. */ +.dark .waypoint-div-icon .waypoint-dot { + border-color: var(--surface-200); +} +.dark .waypoint-div-icon .waypoint-dot-badge { + background: var(--foreground-rgb); + color: var(--background-rgb); + border-color: var(--surface-200); +} + html, body { max-width: 100vw; @@ -125,6 +232,37 @@ body { padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom, 0px) + 1rem); } +/* ============================================================= + Brand accent strip — sits at the very top of the sticky header + (and the very top of the bottom nav, flipped direction). + A 3px brand-orange line gives both surfaces a clear visual edge + against the cream page bg, so they read as a frame rather than + blending into the content. In dark mode the strip stays the + same orange so the brand mark keeps anchoring the chrome. + ============================================================= */ +.brand-accent-top { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: #f54e00; + box-shadow: 0 1px 0 rgba(245, 78, 0, 0.3); + z-index: 1; + pointer-events: none; +} +.brand-accent-bottom { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: #f54e00; + box-shadow: 0 -1px 0 rgba(245, 78, 0, 0.3); + z-index: 1; + pointer-events: none; +} + /* Hide scrollbar but keep scroll functionality */ .no-scrollbar::-webkit-scrollbar { display: none; @@ -272,20 +410,22 @@ button, a, nav { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); pointer-events: none; /* clicks pass through to the dot */ } -/* Singleton name label — sits below the dot, only when there's exactly - one stop here. Hidden when a count badge is present so the dot's - footprint stays small in dense regions. */ +/* Singleton name label — sits below the dot, always visible (was + hover-only; changed so drivers can read stop names at a glance + without tapping). The cream-on-dark chip keeps the label + readable over busy map tiles, and the same chip is used by + cluster labels so the visual grammar stays consistent. */ .waypoint-div-icon .waypoint-dot-name { position: absolute; top: calc(50% + 16px); left: 50%; transform: translateX(-50%); - max-width: 140px; - padding: 2px 6px; - background: rgba(38, 37, 30, 0.78); + max-width: 160px; + padding: 3px 8px; + background: rgba(38, 37, 30, 0.92); color: #f2f1ed; font-family: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; - font-size: 10px; + font-size: 11px; font-weight: 600; letter-spacing: -0.005em; border-radius: 4px; @@ -294,11 +434,17 @@ button, a, nav { text-overflow: ellipsis; text-align: center; pointer-events: none; - opacity: 0; - transition: opacity 150ms ease; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); } +.dark .waypoint-div-icon .waypoint-dot-name { + background: rgba(242, 241, 237, 0.94); + color: #181710; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5); +} +/* Hover still highlights — small lift on the chip, not the + show/hide animation we used before. */ .waypoint-div-icon:hover .waypoint-dot-name { - opacity: 1; + transform: translateX(-50%) translateY(-1px); } /* Hover state — dot scales up 1.15× and lifts, badge pops slightly. */ .waypoint-div-icon:hover .waypoint-dot, diff --git a/app/layout.tsx b/app/layout.tsx index 286de28..70f17fd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,12 @@ import { AuthProvider } from "@/components/AuthProvider"; import OfflineIndicator from "@/components/OfflineIndicator"; import InstallPrompt from "@/components/InstallPrompt"; import { PWABridge } from "@/components/PWABridge"; +import { applyDarkMode } from "@/lib/theme"; + +// Apply dark mode BEFORE React hydrates so we don't get a flash +// of light theme on first paint. Reads the same `1driver_settings` +// key the settings page writes to. +applyDarkMode(); export const metadata: Metadata = { title: "1Driver - Collections", diff --git a/app/map/page.tsx b/app/map/page.tsx index cf9653e..e8309ab 100644 --- a/app/map/page.tsx +++ b/app/map/page.tsx @@ -131,6 +131,7 @@ export default function MapPage() {
+