Skip to content

Commit 43d6c22

Browse files
D3: Dark mode toggle (Phase 3 stretch) (#3)
* D3: Dark mode toggle (Phase 3 stretch) Implements the D3 desired item from the RFP — operator-selectable theme, targeted at low-light warehouse stations per the proposal. Built on a separate feat/dark-mode branch via git worktree so the main PR stays focused on R1-R4 and this can be reviewed (or shelved) independently. Architecture - useTheme composable persists the choice in localStorage and applies a data-theme="dark" attribute to <html>. - App.vue's <style> (intentionally non-scoped) hosts the CSS-variable palette: :root for light, :root[data-theme="dark"] for dark. Per-component scoped styles still apply on top — overrides cover only the high-traffic surfaces (banner, filter bar, cards, tables, badges, page headers). - ThemeToggle.vue is a small banner button with a sun/moon icon swap. - color-scheme set to match so native form controls (selects, scrollbars) follow. Coverage - High-traffic surfaces flip cleanly. - Long-tail per-view scoped styles remain on the original light palette in dark mode and are tracked as Phase-3 polish follow-on (see PR description). Tests - tests/e2e/specs/09-dark-mode.spec.js — 5 specs covering toggle visibility, default light, click → dark with localStorage persistence and luminance check, click again → light, full-reload persistence. - Test note: localStorage seed must be done via page.evaluate AFTER goto (not addInitScript) so a later page.reload() doesn't re-clear the value. Body has a 0.2s background-color transition; tests poll until the colour settles. Verification - 53/53 Playwright tests pass on this branch (was 48; +5 for Flow lindsey-anthropic#9). - Manual probe via Playwright MCP: toggle flips data-theme, body bg switches from rgb(248,250,252) to rgb(11,17,32), preference survives reload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * D3 follow-up: extend dark coverage to view-scoped surfaces Initial dark mode (commit 09bbbe5 on this branch — wait, prior commit on this branch) covered the global surfaces but missed several view-scoped classes whose CSS specificity ties with our globals and whose stylesheet is loaded after App.vue. Surfaced visually as white islands on the dashboard: - Dashboard `.kpi-card` (the five Key Performance Indicator cards) - FilterBar `.filter-select` and `.filters-bar` - LanguageSwitcher `.language-button` - ProfileMenu `.profile-button` - FilterBar `.reset-filters-btn` - Dashboard `.h-bar-container` and `.task-item:hover` - `.clickable-row:hover` (Dashboard had `!important` on light hover) Approach - Add `:root[data-theme="dark"]` selectors with `!important` for the specific class names. The trade-off (using !important) is bounded to the dark-mode override block; light mode is unaffected. - Cover dropdown menus too (LanguageSwitcher dropdown, ProfileMenu items) so the click states stay coherent. This is still a prototype; full per-view refactor of every scoped style to use the CSS variables would be the production-grade path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * D3 follow-up 2: profile dropdown internals + brand residual in email Two issues spotted in the profile-menu popover: 1. Dropdown header banner (.dropdown-header), user name/email, divider, and logout item still rendered with their light-mode component-scoped styles. Added overrides under :root[data-theme="dark"] for: .dropdown-header, .user-name, .user-email, .profile-name, .dropdown-divider, .dropdown-item.logout (+ hover), .task-badge 2. composables/useAuth.js still hardcoded john.doe@catalystcomponents.com — the previous brand-correction pass (lindsey-anthropic#15) only caught the H1 in the locale files. This is the email field shown inside the dropdown header. Renamed to john.doe@meridiancomponents.example to match the brand. Verified via Playwright MCP: - All dropdown surfaces resolve to dark tokens - Email text in DOM now reads "john.doe@meridiancomponents.example" Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7e7fe86 commit 43d6c22

6 files changed

Lines changed: 478 additions & 4 deletions

File tree

client/src/App.vue

Lines changed: 282 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
</router-link>
3434
</nav>
3535
<LanguageSwitcher />
36+
<ThemeToggle />
3637
<ProfileMenu
3738
@show-profile-details="showProfileDetails = true"
3839
@show-tasks="showTasks = true"
@@ -70,6 +71,7 @@ import ProfileMenu from './components/ProfileMenu.vue'
7071
import ProfileDetailsModal from './components/ProfileDetailsModal.vue'
7172
import TasksModal from './components/TasksModal.vue'
7273
import LanguageSwitcher from './components/LanguageSwitcher.vue'
74+
import ThemeToggle from './components/ThemeToggle.vue'
7375
7476
export default {
7577
name: 'App',
@@ -78,7 +80,8 @@ export default {
7880
ProfileMenu,
7981
ProfileDetailsModal,
8082
TasksModal,
81-
LanguageSwitcher
83+
LanguageSwitcher,
84+
ThemeToggle
8285
},
8386
setup() {
8487
const { currentUser } = useAuth()
@@ -174,12 +177,49 @@ export default {
174177
box-sizing: border-box;
175178
}
176179
180+
/* Theme tokens.
181+
* Light is the default; [data-theme="dark"] (set on <html> by useTheme.js)
182+
* overrides the same names. New components should consume these variables
183+
* rather than hardcoded hex values. */
184+
:root {
185+
--bg-app: #f8fafc;
186+
--bg-elevated: #ffffff;
187+
--bg-muted: #f1f5f9;
188+
--ink-strong: #0f172a;
189+
--ink: #1e293b;
190+
--ink-soft: #64748b;
191+
--border: #e2e8f0;
192+
--border-soft: #f1f5f9;
193+
--accent: #2563eb;
194+
--accent-soft: #eff6ff;
195+
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
196+
--shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.06);
197+
color-scheme: light;
198+
}
199+
200+
:root[data-theme="dark"] {
201+
--bg-app: #0b1120;
202+
--bg-elevated: #111827;
203+
--bg-muted: #1f2937;
204+
--ink-strong: #f1f5f9;
205+
--ink: #e2e8f0;
206+
--ink-soft: #94a3b8;
207+
--border: #1f2937;
208+
--border-soft: #1e293b;
209+
--accent: #60a5fa;
210+
--accent-soft: #1e3a8a;
211+
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4);
212+
--shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.5);
213+
color-scheme: dark;
214+
}
215+
177216
body {
178217
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
179-
background: #f8fafc;
180-
color: #1e293b;
218+
background: var(--bg-app);
219+
color: var(--ink);
181220
-webkit-font-smoothing: antialiased;
182221
-moz-osx-font-smoothing: grayscale;
222+
transition: background-color 0.2s ease, color 0.2s ease;
183223
}
184224
185225
.app {
@@ -489,4 +529,243 @@ tbody tr:hover {
489529
margin: 1rem 0;
490530
font-size: 0.938rem;
491531
}
532+
533+
/* ---------------------------------------------------------------
534+
* Dark mode overrides
535+
*
536+
* App.vue's <style> is intentionally non-scoped so these rules can
537+
* reach component classes (e.g. `.card`, `.stat-card`, `.badge`,
538+
* `.filters-bar`) without `:deep()` boilerplate per file. Per-component
539+
* scoped styles still apply on top — these rules only override what
540+
* needs to flip in dark mode.
541+
*
542+
* Coverage is intentionally limited to the high-traffic surfaces
543+
* (banner, filter bar, cards, tables, badges, page headers). Long-tail
544+
* surfaces remain on their original light palette in dark mode and are
545+
* tracked as a Phase-3 polish follow-on.
546+
* --------------------------------------------------------------- */
547+
:root[data-theme="dark"] .top-nav {
548+
background: var(--bg-elevated);
549+
border-bottom-color: var(--border);
550+
}
551+
552+
:root[data-theme="dark"] .logo h1,
553+
:root[data-theme="dark"] .page-header h2,
554+
:root[data-theme="dark"] .card-title,
555+
:root[data-theme="dark"] .stat-value {
556+
color: var(--ink-strong);
557+
}
558+
559+
:root[data-theme="dark"] .subtitle,
560+
:root[data-theme="dark"] .page-header p,
561+
:root[data-theme="dark"] .stat-label {
562+
color: var(--ink-soft);
563+
}
564+
565+
:root[data-theme="dark"] .nav-tabs a {
566+
color: var(--ink-soft);
567+
}
568+
569+
:root[data-theme="dark"] .nav-tabs a:hover {
570+
color: var(--ink-strong);
571+
background: var(--bg-muted);
572+
}
573+
574+
:root[data-theme="dark"] .nav-tabs a.active {
575+
color: var(--accent);
576+
background: var(--accent-soft);
577+
}
578+
579+
:root[data-theme="dark"] .stat-card,
580+
:root[data-theme="dark"] .card {
581+
background: var(--bg-elevated);
582+
border-color: var(--border);
583+
}
584+
585+
:root[data-theme="dark"] .stat-card:hover {
586+
border-color: var(--ink-soft);
587+
box-shadow: var(--shadow-hover);
588+
}
589+
590+
:root[data-theme="dark"] .card-header {
591+
border-bottom-color: var(--border);
592+
}
593+
594+
:root[data-theme="dark"] thead {
595+
background: var(--bg-muted);
596+
border-color: var(--border);
597+
}
598+
599+
:root[data-theme="dark"] th {
600+
color: var(--ink-soft);
601+
}
602+
603+
:root[data-theme="dark"] td {
604+
color: var(--ink);
605+
border-top-color: var(--border-soft);
606+
}
607+
608+
:root[data-theme="dark"] tbody tr:hover {
609+
background: var(--bg-muted);
610+
}
611+
612+
:root[data-theme="dark"] .filters-bar,
613+
:root[data-theme="dark"] .filter-group select,
614+
:root[data-theme="dark"] .filter-select {
615+
background: var(--bg-elevated);
616+
border-color: var(--border);
617+
color: var(--ink);
618+
}
619+
620+
:root[data-theme="dark"] .filter-group label {
621+
color: var(--ink-soft);
622+
}
623+
624+
:root[data-theme="dark"] input[type="text"],
625+
:root[data-theme="dark"] input[type="number"],
626+
:root[data-theme="dark"] input[type="search"] {
627+
background: var(--bg-elevated);
628+
border: 1px solid var(--border);
629+
color: var(--ink);
630+
}
631+
632+
:root[data-theme="dark"] input::placeholder {
633+
color: var(--ink-soft);
634+
opacity: 0.7;
635+
}
636+
637+
/* ThemeToggle button picks up its own vars via the scoped fallback chain */
638+
:root[data-theme="dark"] {
639+
--toggle-border: var(--border);
640+
--toggle-fg: var(--ink-soft);
641+
--toggle-bg-hover: var(--bg-muted);
642+
--toggle-fg-hover: var(--ink-strong);
643+
}
644+
645+
/* ---------------------------------------------------------------
646+
* High-specificity overrides for surfaces that are styled in
647+
* component-scoped CSS. Vue scoped styles add a [data-v-xxx]
648+
* attribute to selectors; their specificity ties with our globals,
649+
* but they are loaded after App.vue and win on tie. We use
650+
* `!important` here deliberately — these rules only apply under
651+
* [data-theme="dark"], so the trade-off is bounded.
652+
* --------------------------------------------------------------- */
653+
:root[data-theme="dark"] .kpi-card {
654+
background: var(--bg-elevated) !important;
655+
border-color: var(--border) !important;
656+
color: var(--ink) !important;
657+
}
658+
659+
:root[data-theme="dark"] .filter-select,
660+
:root[data-theme="dark"] select.filter-select {
661+
background: var(--bg-elevated) !important;
662+
border-color: var(--border) !important;
663+
color: var(--ink) !important;
664+
}
665+
666+
:root[data-theme="dark"] .filter-select:hover,
667+
:root[data-theme="dark"] .filter-select:focus {
668+
border-color: var(--ink-soft) !important;
669+
}
670+
671+
:root[data-theme="dark"] .filters-bar {
672+
background: var(--bg-app) !important;
673+
border-color: var(--border) !important;
674+
}
675+
676+
:root[data-theme="dark"] .clickable-row:hover {
677+
background: var(--bg-muted) !important;
678+
}
679+
680+
:root[data-theme="dark"] .h-bar-container,
681+
:root[data-theme="dark"] .task-item:hover {
682+
background: var(--bg-muted) !important;
683+
}
684+
685+
/* Stat-card and kpi-card values often have hardcoded ink color in
686+
* scoped CSS — pull them onto the variable too. */
687+
:root[data-theme="dark"] .stat-value,
688+
:root[data-theme="dark"] .kpi-card .stat-value,
689+
:root[data-theme="dark"] .kpi-value {
690+
color: var(--ink-strong) !important;
691+
}
692+
693+
/* Generic card-like containers in views often re-declare bg: white. */
694+
:root[data-theme="dark"] .stats-grid > .stat-card,
695+
:root[data-theme="dark"] .stats-grid > .kpi-card {
696+
background: var(--bg-elevated) !important;
697+
border-color: var(--border) !important;
698+
}
699+
700+
/* Banner control buttons — language switcher, profile menu, reset filters.
701+
* All three live in component-scoped CSS with `background: white;`. */
702+
:root[data-theme="dark"] .language-button,
703+
:root[data-theme="dark"] .profile-button,
704+
:root[data-theme="dark"] .reset-filters-btn {
705+
background: var(--bg-elevated) !important;
706+
border-color: var(--border) !important;
707+
color: var(--ink) !important;
708+
}
709+
710+
:root[data-theme="dark"] .language-button:hover,
711+
:root[data-theme="dark"] .profile-button:hover,
712+
:root[data-theme="dark"] .reset-filters-btn:hover:not(:disabled) {
713+
background: var(--bg-muted) !important;
714+
border-color: var(--ink-soft) !important;
715+
}
716+
717+
/* Dropdowns hanging off those buttons (LanguageSwitcher dropdown menu,
718+
* ProfileMenu menu and items). */
719+
:root[data-theme="dark"] .dropdown-menu,
720+
:root[data-theme="dark"] .profile-menu,
721+
:root[data-theme="dark"] .menu-item,
722+
:root[data-theme="dark"] .dropdown-item {
723+
background: var(--bg-elevated) !important;
724+
border-color: var(--border) !important;
725+
color: var(--ink) !important;
726+
}
727+
728+
:root[data-theme="dark"] .dropdown-item:hover,
729+
:root[data-theme="dark"] .menu-item:hover {
730+
background: var(--bg-muted) !important;
731+
color: var(--ink-strong) !important;
732+
}
733+
734+
:root[data-theme="dark"] .language-name,
735+
:root[data-theme="dark"] .language-label {
736+
color: var(--ink) !important;
737+
}
738+
739+
/* ProfileMenu dropdown internals: header banner, name/email, divider,
740+
* task badge, logout item. The .dropdown-menu is already darkened above;
741+
* these are the children that re-declare their own backgrounds/colors. */
742+
:root[data-theme="dark"] .dropdown-header {
743+
background: var(--bg-muted) !important;
744+
}
745+
746+
:root[data-theme="dark"] .user-name,
747+
:root[data-theme="dark"] .profile-name {
748+
color: var(--ink-strong) !important;
749+
}
750+
751+
:root[data-theme="dark"] .user-email {
752+
color: var(--ink-soft) !important;
753+
}
754+
755+
:root[data-theme="dark"] .dropdown-divider {
756+
background: var(--border) !important;
757+
}
758+
759+
:root[data-theme="dark"] .dropdown-item.logout {
760+
color: #f87171 !important; /* keep destructive cue but lighten for dark bg */
761+
}
762+
763+
:root[data-theme="dark"] .dropdown-item.logout:hover {
764+
background: rgba(248, 113, 113, 0.12) !important;
765+
}
766+
767+
:root[data-theme="dark"] .task-badge {
768+
background: var(--accent) !important;
769+
color: var(--bg-app) !important;
770+
}
492771
</style>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<template>
2+
<button
3+
class="theme-toggle"
4+
type="button"
5+
:aria-label="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
6+
:title="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
7+
@click="toggleTheme"
8+
>
9+
<!-- Sun (shown in dark mode = click to go light) -->
10+
<svg
11+
v-if="currentTheme === 'dark'"
12+
width="20" height="20" viewBox="0 0 24 24" fill="none"
13+
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
14+
>
15+
<circle cx="12" cy="12" r="4" />
16+
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
17+
</svg>
18+
<!-- Moon (shown in light mode = click to go dark) -->
19+
<svg
20+
v-else
21+
width="20" height="20" viewBox="0 0 24 24" fill="none"
22+
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
23+
>
24+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
25+
</svg>
26+
</button>
27+
</template>
28+
29+
<script setup>
30+
import { useTheme } from '../composables/useTheme'
31+
32+
const { currentTheme, toggleTheme } = useTheme()
33+
</script>
34+
35+
<style scoped>
36+
.theme-toggle {
37+
background: transparent;
38+
border: 1px solid var(--toggle-border, #e2e8f0);
39+
color: var(--toggle-fg, #475569);
40+
width: 38px;
41+
height: 38px;
42+
border-radius: 8px;
43+
display: inline-flex;
44+
align-items: center;
45+
justify-content: center;
46+
cursor: pointer;
47+
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
48+
}
49+
50+
.theme-toggle:hover {
51+
background: var(--toggle-bg-hover, #f1f5f9);
52+
color: var(--toggle-fg-hover, #0f172a);
53+
}
54+
</style>

client/src/composables/useAuth.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useI18n } from './useI18n'
44
// Base user data (language-independent)
55
const baseUserData = {
66
id: 1,
7-
email: 'john.doe@catalystcomponents.com',
7+
email: 'john.doe@meridiancomponents.example',
88
phone: '+1 (111) 111-1111',
99
avatar: null,
1010
joinDate: '2022-03-15'

0 commit comments

Comments
 (0)