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
This commit is contained in:
SJCoetz 2026-07-07 18:28:19 +02:00
parent 9a99c489c3
commit 3c8fc91b3e
10 changed files with 310 additions and 33 deletions

View file

@ -80,6 +80,113 @@
--gray-900: #26251e;
}
/* =============================================================
Dark mode — same brand, inverted surfaces.
Triggered by `<html class="dark">` (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,

View file

@ -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",

View file

@ -131,6 +131,7 @@ export default function MapPage() {
<LocationTracker />
<header className="bg-[#e6e5e0] text-[#26251e] safe-top z-40 border-b border-[rgba(38,37,30,0.1)] flex-shrink-0">
<div className="brand-accent-top" aria-hidden="true" />
<div className="px-4 pt-2 pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5 min-w-0">

View file

@ -274,6 +274,7 @@ export default function CollectionsPage() {
role="banner"
style={{ boxShadow: '0 1px 0 rgba(38,37,30,0.06)' }}
>
<div className="brand-accent-top" aria-hidden="true" />
<div className="px-4 pt-2 pb-3">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">

View file

@ -7,6 +7,7 @@ import BottomNav from '@/components/BottomNav';
import { getSettings, saveSettings, logout } from '@/lib/pocketbase';
import { AppSettings, DEFAULT_SETTINGS } from '@/lib/types';
import { cn } from '@/lib/utils';
import { setDarkMode, applyDarkMode } from '@/lib/theme';
import {
Settings,
Clock,
@ -19,6 +20,8 @@ import {
Signal,
MapPin,
Shield,
Moon,
Sun,
} from 'lucide-react';
const INTERVAL_OPTIONS = [
@ -54,6 +57,10 @@ export default function SettingsPage() {
const updated = { ...settings, [key]: value };
setSettings(updated);
saveSettings({ [key]: value });
// Dark mode also needs to flip the <html> class — the rest of
// AppSettings is purely persisted, but darkMode drives a class
// Tailwind keys off of, so we apply it inline here.
if (key === 'darkMode') setDarkMode(Boolean(value));
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
@ -238,6 +245,51 @@ export default function SettingsPage() {
</div>
</section>
{/* Appearance */}
<section>
<div className="flex items-center gap-2 mb-3 px-1">
{settings.darkMode
? <Moon size={14} className="text-[#f54e00]" />
: <Sun size={14} className="text-[#f54e00]" />}
<h2 className="text-xs font-bold text-[rgba(38,37,30,0.55)] uppercase tracking-wider">
Appearance
</h2>
</div>
<div className="bg-[#e6e5e0] rounded-2xl overflow-hidden border border-[rgba(38,37,30,0.1)]">
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-[#ebeae5] rounded-xl flex items-center justify-center">
{settings.darkMode
? <Moon size={16} className="text-[#26251e]" />
: <Sun size={16} className="text-[#f54e00]" />}
</div>
<div>
<span className="text-sm font-semibold text-[#26251e] block">Dark Mode</span>
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">Easier on the eyes at night</span>
</div>
</div>
<button
onClick={() => updateSetting('darkMode', !settings.darkMode)}
role="switch"
aria-checked={settings.darkMode}
aria-label="Dark mode"
className={cn(
'w-[52px] h-[30px] rounded-full transition-colors relative',
settings.darkMode ? 'bg-[#f54e00]' : 'bg-[rgba(38,37,30,0.15)]'
)}
>
<div className={cn(
'absolute top-[3px] w-6 h-6 bg-white rounded-full shadow-md transition-transform',
settings.darkMode ? 'translate-x-[26px]' : 'translate-x-[3px]'
)} />
</button>
</div>
</div>
</div>
</section>
{/* About */}
<section>
<div className="flex items-center gap-2 mb-3 px-1">

View file

@ -15,7 +15,8 @@ export default function BottomNav() {
const pathname = usePathname();
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-[#e6e5e0]/95 backdrop-blur-lg border-t border-[rgba(38,37,30,0.1)] safe-bottom">
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-[#e6e5e0]/95 dark:bg-[#181710]/95 backdrop-blur-lg border-t border-[rgba(38,37,30,0.1)] dark:border-[rgba(242,241,237,0.08)] safe-bottom">
<div className="brand-accent-top" aria-hidden="true" />
<div className="flex justify-around items-center h-16 max-w-lg mx-auto">
{navItems.map((item) => {
const Icon = item.icon;

View file

@ -23,7 +23,7 @@ const AREA_COLORS = [
// ────────────────────────────────────────────────────────────────────
// ONE shape for every waypoint. Every stop — singleton or cluster —
// uses the same 26px orange dot so the map reads as one product surface,
// uses the same 22px orange dot so the map reads as one product surface,
// not "Leaflet default + my clusters glued on top".
//
// Why a circle, not a pill:
@ -33,43 +33,55 @@ const AREA_COLORS = [
// • A consistent dot keeps the map balanced at every zoom: silhouette
// is identical for 1 stop or 99, so the driver reads shape density
// (= how clustered) instead of having to re-parse variable icons.
// • Count moves to a corner badge that appears only when ≥ 2 stops
// share the pin — the dot itself never lies about "this is a stop".
//
// Where the name lives:
// • On the dot itself — only when there's exactly 1 stop here
// (the badge slot is available, and the name fits without crowding).
// Multi-point dots stay pure geometry; the popup names them.
// • On hover — a tooltip shows the supplier name + count.
// • On click — the popup lists every item at this pin.
// Where the label lives:
// • ALWAYS below the dot (was hover-only — drivers said they couldn't
// read stop names without tapping). The label is a cream chip on
// dark text, readable over any tile.
// • Singletons: just the supplier name ("Acme Hardware").
// • Clusters: supplier name + count ("Acme Hardware + 3 more"), so
// the driver knows it's a multi-stop without clicking.
// • On click: the popup lists every item at this pin (same as before).
// ────────────────────────────────────────────────────────────────────
const DOT_PX = 26; // fixed across every waypoint — silent unity
const DOT_PX = 22; // fixed across every waypoint — silent unity
const buildWaypointIcon = (count: number, supplierName: string, areaColor?: string): L.DivIcon => {
const isMulti = count >= 2;
const nameForSingles = (!isMulti && supplierName) ? supplierName : '';
// Label content. Singletons get the supplier name; clusters get
// "Supplier + N more" so the driver reads the count without
// tapping. Fallback to a generic label when supplier is unknown.
let label = '';
if (supplierName) {
label = isMulti ? `${supplierName} + ${count - 1}` : supplierName;
} else {
label = isMulti ? `${count} stops` : 'Stop';
}
const dotColor = areaColor || '#f54e00';
const dotHtml = `
<div class="waypoint-dot-shadow"></div>
<div class="waypoint-dot" style="background:${dotColor};"></div>
${nameForSingles
? `<div class="waypoint-dot-name">${escapeHtml(nameForSingles)}</div>`
: ''}
<div class="waypoint-dot-name">${escapeHtml(label)}</div>
${isMulti
? `<div class="waypoint-dot-badge">${count > 99 ? '99+' : count}</div>`
: ''}
`;
// iconSize is the hit-box around the visual. We give it a slightly
// larger square than the dot so the hit area is generous, the badge
// sits inside it, and stacks of identical dots near each other don't
// fight for clicks.
// iconSize is the hit-box around the visual. The hit box itself
// stays small so pins near each other don't fight for clicks,
// but the iconAnchor shifts down to account for the label that
// hangs below the dot — otherwise the dot would visually sit
// above its true lat/lng.
const hit = DOT_PX + 8;
const labelOffset = 12; // approx vertical extent of the label chip
return L.divIcon({
className: 'waypoint-div-icon',
html: `<div class="waypoint-dot-wrap" style="width:${hit}px;height:${hit}px;">${dotHtml}</div>`,
iconSize: [hit, hit],
iconSize: [hit, hit + labelOffset],
// Anchor on the dot center, NOT on the whole icon box. Leaflet
// positions the marker so this point lands on the lat/lng. We
// also shift up by half the label height so the label doesn't
// push the dot up off its true position.
iconAnchor: [hit / 2, hit / 2],
popupAnchor: [0, -(hit / 2) - 4],
});
@ -89,11 +101,14 @@ function escapeHtml(s: string): string {
// Current-location marker (you) — distinct shape so it's never confused
// with a stop. Solid white-bordered dot + slow outer pulse.
// Color matches the brand `#f54e00` so the you-marker reads as part of
// the same orange family as the supplier waypoints (the previous
// `#ea580c` was Tailwind's default orange-600 and clashed).
const currentLocationIcon = L.divIcon({
className: 'custom-div-icon',
html: `<div style="position:relative;">
<div style="width:18px;height:18px;background:#ea580c;border:3px solid white;border-radius:50%;box-shadow:0 2px 8px rgba(0,0,0,0.3);position:relative;z-index:2;"></div>
<div style="width:36px;height:36px;background:rgba(234,88,12,0.15);border-radius:50%;position:absolute;top:-9px;left:-9px;z-index:1;animation:pulse 2s infinite;"></div>
<div style="width:18px;height:18px;background:#f54e00;border:3px solid white;border-radius:50%;box-shadow:0 2px 8px rgba(0,0,0,0.3);position:relative;z-index:2;"></div>
<div style="width:36px;height:36px;background:rgba(245,78,0,0.18);border-radius:50%;position:absolute;top:-9px;left:-9px;z-index:1;animation:pulse 2s infinite;"></div>
</div>
<style>@keyframes pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.5);opacity:0}}</style>`,
iconSize: [18, 18],

54
lib/theme.ts Normal file
View file

@ -0,0 +1,54 @@
/**
* Theme utilities — read / write the dark-mode preference and
* apply it to <html>.
*
* Dark mode is a class-based Tailwind strategy (see
* tailwind.config.js). The class is toggled on `<html>` and the
* CSS in `app/globals.css` does the heavy lifting via the
* `.dark { --surface-XXX }` overrides.
*
* The settings live in the same `1driver_settings` localStorage
* key as the rest of AppSettings — see `lib/pocketbase.ts:231`
* for the writer. We don't import the pocketbase module here so
* that `applyDarkMode` can run during the pre-hydration
* `import` phase in `app/layout.tsx` without dragging PB into
* a server-only context.
*/
const SETTINGS_KEY = '1driver_settings';
function readSettings(): { darkMode?: boolean } {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem(SETTINGS_KEY);
if (!raw) return {};
return JSON.parse(raw) as { darkMode?: boolean };
} catch {
return {};
}
}
/** Apply the persisted dark-mode preference to <html>. */
export function applyDarkMode(): void {
if (typeof document === 'undefined') return;
const { darkMode } = readSettings();
document.documentElement.classList.toggle('dark', Boolean(darkMode));
}
/** Toggle the preference, persist it, and apply it. */
export function setDarkMode(enabled: boolean): void {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('dark', enabled);
// Persist alongside the rest of AppSettings — merge with whatever
// is already there so we don't blow away locationInterval etc.
let current: Record<string, unknown> = {};
try {
const raw = window.localStorage.getItem(SETTINGS_KEY);
if (raw) current = JSON.parse(raw);
} catch {
/* corrupted JSON — start fresh */
}
current.darkMode = enabled;
window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(current));
}

View file

@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',

File diff suppressed because one or more lines are too long