Skip to content

Commit 77be7b2

Browse files
authored
feat: add dark mode with theme toggle (#5)
Use CSS custom properties for all color tokens so the Tailwind config references variables that swap between light and dark palettes. The orange primary/accent/ring colors stay the same in both modes. - Add :root (light) and .dark (dark) CSS variable blocks - Theme toggle (sun/moon icon) in nav bar, persisted in localStorage - Respects prefers-color-scheme as default, no flash on load - Replace hardcoded green-600/red-600 with semantic status color classes - Invert nav logo in dark mode via CSS filter - Fix nav active tab border to align flush with nav bottom border - Dark-aware scrollbar, focus ring, modal overlay, and form controls
1 parent 088323d commit 77be7b2

File tree

5 files changed

+120
-41
lines changed

5 files changed

+120
-41
lines changed

internal/greyproxy/static/css/style.css

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,34 @@
44
.htmx-request .htmx-indicator { display: inline-block; opacity: 1; transition: opacity 200ms ease-in; }
55
.htmx-indicator { opacity: 0; transition: opacity 200ms ease-out; }
66
::-webkit-scrollbar { width: 8px; height: 8px; }
7-
::-webkit-scrollbar-track { background: rgb(240, 240, 236); }
8-
::-webkit-scrollbar-thumb { background: rgb(196, 196, 189); border-radius: 4px; }
9-
::-webkit-scrollbar-thumb:hover { background: rgb(87, 87, 83); }
7+
::-webkit-scrollbar-track { background: var(--color-muted); }
8+
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 4px; }
9+
::-webkit-scrollbar-thumb:hover { background: var(--color-muted-foreground); }
1010
#rule-modal { z-index: 9999; }
1111
@keyframes slideInRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
1212
#toast-container > div { animation: slideInRight 300ms ease-out; }
1313
.transition-colors { transition-property: background-color, border-color, color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
14-
*:focus-visible { outline: 2px solid rgb(217, 94, 42); outline-offset: 2px; }
14+
*:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
1515
*:focus:not(:focus-visible) { outline: none; }
1616
button:hover:not(:disabled):not(.btn-plain):not(.btn-split-part):not(.btn-menu-item) { transform: translateY(-1px); transition: transform 100ms ease-out; }
1717
button:active:not(:disabled):not(.btn-plain):not(.btn-split-part):not(.btn-menu-item) { transform: translateY(0); }
1818
button:disabled { opacity: 0.5; cursor: not-allowed; }
1919
tbody tr { transition: background-color 150ms ease-in-out; }
2020
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2121
.inline-flex.items-center { vertical-align: middle; }
22-
input:focus, select:focus, textarea:focus { border-color: rgb(217, 94, 42); box-shadow: 0 0 0 3px rgba(217, 94, 42, 0.1); }
23-
input[type="checkbox"]:checked, input[type="radio"]:checked { background-color: rgb(217, 94, 42); border-color: rgb(217, 94, 42); }
22+
input:focus, select:focus, textarea:focus { border-color: var(--color-ring); box-shadow: 0 0 0 3px rgba(217, 94, 42, 0.1); }
23+
input[type="checkbox"]:checked, input[type="radio"]:checked { background-color: var(--color-primary); border-color: var(--color-primary); }
2424
#rule-modal .sm\:p-6 { padding: 1.5rem; }
2525
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
2626
@media (max-width: 640px) { .max-w-7xl { padding-left: 1rem; padding-right: 1rem; } table { font-size: 0.875rem; } th, td { padding: 0.5rem; } }
2727
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
2828
.animate-spin { animation: spin 1s linear infinite; }
29+
/* Semantic status colors */
30+
.status-green { color: var(--color-green); }
31+
.status-red { color: var(--color-red); }
32+
.bg-status-green { background-color: var(--color-green); }
33+
.bg-status-red { background-color: var(--color-red); }
34+
/* Color scheme for form controls in dark mode */
35+
.dark select, .dark input[type="datetime-local"] { color-scheme: dark; }
36+
/* Invert dark logo to white in dark mode */
37+
.dark .nav-logo { filter: invert(1); }

internal/greyproxy/ui/templates/base.html

Lines changed: 92 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,28 +22,29 @@
2222
<!-- Custom Tailwind Config -->
2323
<script>
2424
tailwind.config = {
25+
darkMode: 'class',
2526
theme: {
2627
extend: {
2728
fontFamily: {
2829
sans: ['Aspekta', 'system-ui', 'sans-serif'],
2930
serif: ['Source Serif Pro', 'Georgia', 'serif'],
3031
},
3132
colors: {
32-
background: 'rgb(255, 255, 255)',
33-
foreground: 'rgb(22, 22, 20)',
34-
card: 'rgb(249, 249, 247)',
35-
'card-foreground': 'rgb(22, 22, 20)',
36-
primary: 'rgb(217, 94, 42)',
37-
'primary-foreground': 'rgb(255, 255, 255)',
38-
secondary: 'rgb(249, 249, 247)',
39-
'secondary-foreground': 'rgb(22, 22, 20)',
40-
muted: 'rgb(240, 240, 236)',
41-
'muted-foreground': 'rgb(127, 127, 121)',
42-
accent: 'rgb(217, 94, 42)',
43-
destructive: 'rgb(180, 51, 50)',
44-
border: 'rgb(221, 221, 215)',
45-
input: 'rgb(221, 221, 215)',
46-
ring: 'rgb(217, 94, 42)',
33+
background: 'var(--color-background)',
34+
foreground: 'var(--color-foreground)',
35+
card: 'var(--color-card)',
36+
'card-foreground': 'var(--color-card-foreground)',
37+
primary: 'var(--color-primary)',
38+
'primary-foreground': 'var(--color-primary-foreground)',
39+
secondary: 'var(--color-secondary)',
40+
'secondary-foreground': 'var(--color-secondary-foreground)',
41+
muted: 'var(--color-muted)',
42+
'muted-foreground': 'var(--color-muted-foreground)',
43+
accent: 'var(--color-accent)',
44+
destructive: 'var(--color-destructive)',
45+
border: 'var(--color-border)',
46+
input: 'var(--color-input)',
47+
ring: 'var(--color-ring)',
4748
},
4849
borderRadius: {
4950
lg: '0.625rem',
@@ -58,6 +59,44 @@
5859
{{block "head" .}}{{end}}
5960

6061
<style>
62+
:root {
63+
--color-background: #ffffff;
64+
--color-foreground: #161614;
65+
--color-card: #f9f9f7;
66+
--color-card-foreground: #161614;
67+
--color-primary: rgb(217, 94, 42);
68+
--color-primary-foreground: #ffffff;
69+
--color-secondary: #f9f9f7;
70+
--color-secondary-foreground: #161614;
71+
--color-muted: #f0f0ec;
72+
--color-muted-foreground: #7f7f79;
73+
--color-accent: rgb(217, 94, 42);
74+
--color-destructive: rgb(180, 51, 50);
75+
--color-border: #ddddd7;
76+
--color-input: #ddddd7;
77+
--color-ring: rgb(217, 94, 42);
78+
--color-green: #16a34a;
79+
--color-red: #dc2626;
80+
}
81+
.dark {
82+
--color-background: #141413;
83+
--color-foreground: #ededeb;
84+
--color-card: #1c1c1a;
85+
--color-card-foreground: #ededeb;
86+
--color-primary: rgb(217, 94, 42);
87+
--color-primary-foreground: #ffffff;
88+
--color-secondary: #232320;
89+
--color-secondary-foreground: #ededeb;
90+
--color-muted: #2a2a27;
91+
--color-muted-foreground: #9a9a94;
92+
--color-accent: rgb(217, 94, 42);
93+
--color-destructive: rgb(220, 80, 78);
94+
--color-border: #3a3a36;
95+
--color-input: #3a3a36;
96+
--color-ring: rgb(217, 94, 42);
97+
--color-green: #22c55e;
98+
--color-red: #ef4444;
99+
}
61100
@font-face { font-family: 'Aspekta'; src: url('{{.Prefix}}/static/fonts/Aspekta-500.woff2') format('woff2'); font-weight: 500; font-style: normal; font-display: swap; }
62101
@font-face { font-family: 'Aspekta'; src: url('{{.Prefix}}/static/fonts/Aspekta-600.woff2') format('woff2'); font-weight: 600; font-style: normal; font-display: swap; }
63102
@font-face { font-family: 'Aspekta'; src: url('{{.Prefix}}/static/fonts/Aspekta-700.woff2') format('woff2'); font-weight: 700; font-style: normal; font-display: swap; }
@@ -69,6 +108,16 @@
69108
.htmx-request .htmx-indicator { display: inline-block; }
70109
.htmx-request.htmx-indicator { display: inline-block; }
71110
</style>
111+
112+
<!-- Theme initialization (runs before paint to prevent flash) -->
113+
<script>
114+
(function() {
115+
var stored = localStorage.getItem('theme');
116+
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
117+
document.documentElement.classList.add('dark');
118+
}
119+
})();
120+
</script>
72121
</head>
73122

74123
<body class="bg-background text-foreground min-h-screen">
@@ -77,11 +126,19 @@
77126
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
78127
<div class="flex justify-between h-12">
79128
<a href="{{.Prefix}}/dashboard" class="flex-shrink-0 flex items-center cursor-pointer no-underline">
80-
<img src="{{.Prefix}}/static/icons/greyhaven-icon.svg" alt="Greyproxy" class="h-8 w-auto">
129+
<img src="{{.Prefix}}/static/icons/greyhaven-icon.svg" alt="Greyproxy" class="h-8 w-auto nav-logo">
81130
<span class="ml-3 text-xl font-semibold font-sans text-foreground">Greyproxy</span>
82131
</a>
83132
<!-- Mobile menu button -->
84-
<div class="flex items-center sm:hidden">
133+
<div class="flex items-center sm:hidden space-x-1">
134+
<button type="button" onclick="toggleTheme()" class="btn-plain p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted" title="Toggle theme">
135+
<svg class="h-5 w-5 hidden dark:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
136+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
137+
</svg>
138+
<svg class="h-5 w-5 block dark:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
139+
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
140+
</svg>
141+
</button>
85142
<button type="button" onclick="document.getElementById('mobile-menu').classList.toggle('hidden')"
86143
class="inline-flex items-center justify-center p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary">
87144
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
@@ -90,24 +147,32 @@
90147
</button>
91148
</div>
92149
<!-- Desktop nav (right-aligned) -->
93-
<div class="hidden sm:flex sm:space-x-8">
150+
<div class="hidden sm:flex sm:space-x-8 sm:items-stretch">
94151
<a href="{{.Prefix}}/dashboard"
95-
class="{{if contains .CurrentPath "/dashboard"}}border-primary text-foreground{{else}}border-transparent text-muted-foreground hover:border-border hover:text-foreground{{end}} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
152+
class="{{if contains .CurrentPath "/dashboard"}}border-primary text-foreground{{else}}border-transparent text-muted-foreground hover:border-border hover:text-foreground{{end}} inline-flex items-center px-1 pt-[2px] border-b-2 text-sm font-medium">
96153
Dashboard
97154
</a>
98155
<a href="{{.Prefix}}/pending"
99-
class="{{if contains .CurrentPath "/pending"}}border-primary text-foreground{{else}}border-transparent text-muted-foreground hover:border-border hover:text-foreground{{end}} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
156+
class="{{if contains .CurrentPath "/pending"}}border-primary text-foreground{{else}}border-transparent text-muted-foreground hover:border-border hover:text-foreground{{end}} inline-flex items-center px-1 pt-[2px] border-b-2 text-sm font-medium">
100157
Pending Requests
101158
<span id="pending-badge" class="hidden ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-primary-foreground bg-primary rounded-full min-w-[1.25rem]"></span>
102159
</a>
103160
<a href="{{.Prefix}}/rules"
104-
class="{{if contains .CurrentPath "/rules"}}border-primary text-foreground{{else}}border-transparent text-muted-foreground hover:border-border hover:text-foreground{{end}} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
161+
class="{{if contains .CurrentPath "/rules"}}border-primary text-foreground{{else}}border-transparent text-muted-foreground hover:border-border hover:text-foreground{{end}} inline-flex items-center px-1 pt-[2px] border-b-2 text-sm font-medium">
105162
Rules
106163
</a>
107164
<a href="{{.Prefix}}/logs"
108-
class="{{if contains .CurrentPath "/logs"}}border-primary text-foreground{{else}}border-transparent text-muted-foreground hover:border-border hover:text-foreground{{end}} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
165+
class="{{if contains .CurrentPath "/logs"}}border-primary text-foreground{{else}}border-transparent text-muted-foreground hover:border-border hover:text-foreground{{end}} inline-flex items-center px-1 pt-[2px] border-b-2 text-sm font-medium">
109166
Logs
110167
</a>
168+
<button type="button" onclick="toggleTheme()" class="btn-plain ml-2 p-1.5 self-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" title="Toggle theme">
169+
<svg class="h-5 w-5 hidden dark:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
170+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
171+
</svg>
172+
<svg class="h-5 w-5 block dark:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
173+
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
174+
</svg>
175+
</button>
111176
</div>
112177
</div>
113178
<!-- Mobile menu dropdown -->
@@ -144,6 +209,11 @@
144209
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
145210

146211
<script>
212+
function toggleTheme() {
213+
var isDark = document.documentElement.classList.toggle('dark');
214+
localStorage.setItem('theme', isDark ? 'dark' : 'light');
215+
}
216+
147217
function formatNumber(n) {
148218
return Number(n).toLocaleString();
149219
}

0 commit comments

Comments
 (0)