Skip to content

Commit a256da0

Browse files
committed
feat(ui): implement light mode, cross-tab auth sync, and QPU deletion
1 parent cd48914 commit a256da0

35 files changed

Lines changed: 376 additions & 269 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project follows versions of format `{year}.{month}.{patch_number}`.
77

88
## [Unreleased]
9+
### Added
10+
- Light mode support for the dashboard UI with a theme toggle. Dark mode remains the default.
11+
- Cross-tab authentication logout synchronization using `pb.authStore.onChange`.
12+
- Ability for administrators to delete QPUs from the QPU Registry with a confirmation modal.
913

1014
### Added
1115

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,8 +333,7 @@ make clean
333333

334334
## TODOs
335335

336-
- [ ] Implement a light mode in the dashboard and improve the overall dark mode UI.
337-
- [ ] Fix authentication routing: ensure super users can sign in at the `/dashboard` (especially when `passwordAuth` is disabled in `qpi.config.yml`), rather than being restricted to the default PocketBase `/_/` dashboard.
336+
- [x] Implement a light mode in the dashboard and improve the overall dark mode UI.
338337
- [ ] Add a way of translating the configuration files (calib seed etc.) of tergite to the quantify.device.yml of this one
339338
- [ ] Share this library in quantum journals, Euro projects, etc.
340339
- [ ] On the QPU Registry screen for admins, we probably need an option to delete any of the QPUs (with a confirmation modal first of course)

e2e/test_dashboard_cypress.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ sleep 2
3434

3535
status=0
3636
echo "[e2e] Executing Cypress spec tests..."
37-
(cd "${PROJECT_ROOT}/qpi-ui/internal/dashboard" && npx cypress run) || status=$?
37+
(cd "${PROJECT_ROOT}/qpi-ui/internal/dashboard" && npx cypress run "$@") || status=$?
3838

3939
stop_driver
4040
stop_pocketbase
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
describe("Overview Header — Theme Toggle", () => {
2+
beforeEach(() => {
3+
// Clear storage and cookies so we always start fresh
4+
cy.clearCookies();
5+
cy.clearLocalStorage();
6+
7+
// Log in as an admin for testing header
8+
cy.visit("/", {
9+
onBeforeLoad: (win) => {
10+
// We do not set any theme in localStorage, so it should default to dark
11+
// Mock prefers-color-scheme to light just to prove our code defaults to dark anyway
12+
cy.stub(win, 'matchMedia').withArgs('(prefers-color-scheme: dark)').returns({ matches: false, addEventListener: () => {}, removeEventListener: () => {} });
13+
}
14+
});
15+
cy.contains("button", "Administrator").click();
16+
cy.get('input[type="text"]').clear().type("admin@example.com");
17+
cy.get('input[type="password"]').clear().type("supersecretpassword1234");
18+
cy.get('button[type="submit"]').click();
19+
20+
// Verify dashboard is loaded
21+
cy.contains("h1", "QPI Interface").should("be.visible");
22+
});
23+
24+
it("defaults to dark mode (has dark class on HTML)", () => {
25+
// We expect dark mode to be default regardless of OS theme
26+
cy.get('[data-testid="theme-toggle"]').should("be.visible");
27+
cy.get("html").should("have.class", "dark");
28+
});
29+
30+
it("toggles the 'dark' class on the HTML element and persists it", () => {
31+
// 1. Initial State: Should be dark mode
32+
cy.get("html").should("have.class", "dark");
33+
cy.window().its("localStorage").invoke("getItem", "theme").should("be.null");
34+
35+
// 2. Click Toggle: Should switch to light mode
36+
cy.get('[data-testid="theme-toggle"]').click();
37+
cy.get("html").should("not.have.class", "dark");
38+
cy.window().its("localStorage").invoke("getItem", "theme").should("eq", "light");
39+
40+
// 3. Reload Page: Should stay in light mode
41+
cy.reload();
42+
cy.get("html").should("not.have.class", "dark");
43+
44+
// 4. Click Toggle Again: Should switch back to dark mode
45+
cy.get('[data-testid="theme-toggle"]').click();
46+
cy.get("html").should("have.class", "dark");
47+
cy.window().its("localStorage").invoke("getItem", "theme").should("eq", "dark");
48+
});
49+
});

qpi-ui/internal/dashboard/cypress/e2e/qpu-registry/admin-delete.cy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe("QPU Registry — Admin: Delete QPU", () => {
4949

5050
// Find the newly created QPU card by its name, and click its Delete button
5151
cy.contains("h3", qpuToDelete)
52-
.parents("div.bg-zinc-900") // find the card container
52+
.parents("div.bg-white") // find the card container
5353
.contains("button", "Delete")
5454
.click();
5555

qpi-ui/internal/dashboard/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ export const App: React.FC = () => {
493493
onRejectRequest={handleRejectRequest}
494494
/>
495495
) : (
496-
<div className="text-zinc-500">Access Denied.</div>
496+
<div className="text-gray-400 dark:text-zinc-500">Access Denied.</div>
497497
);
498498
case "settings":
499499
return (

qpi-ui/internal/dashboard/src/components/Header.tsx

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useRef, useEffect } from "react";
22
import { Bell, X, Info, AlertTriangle, Settings, LogOut } from "lucide-react";
33
import type { Notification } from "../types";
4+
import { ThemeToggle } from "./ThemeToggle";
45

56
interface HeaderProps {
67
pageTitle: string;
@@ -52,8 +53,8 @@ export const Header: React.FC<HeaderProps> = ({
5253
};
5354

5455
return (
55-
<header className="h-16 border-b border-zinc-800 flex items-center justify-between px-8 bg-zinc-950/20 z-40">
56-
<h2 className="font-geist text-lg font-bold text-white capitalize">
56+
<header className="h-16 border-b border-gray-200 dark:border-zinc-800 flex items-center justify-between px-8 bg-gray-50 dark:bg-zinc-950/20 z-40">
57+
<h2 className="font-geist text-lg font-bold text-gray-900 dark:text-white capitalize">
5758
{pageTitle === "qpus"
5859
? "QPU Registry"
5960
: pageTitle === "jobs"
@@ -66,7 +67,7 @@ export const Header: React.FC<HeaderProps> = ({
6667
<button
6768
aria-label="Notifications"
6869
onClick={() => setDropdownOpen(!dropdownOpen)}
69-
className="w-9 h-9 rounded-full bg-zinc-900 border border-zinc-800 hover:bg-zinc-800 text-zinc-400 hover:text-white transition-all flex items-center justify-center relative focus:outline-none"
70+
className="w-9 h-9 rounded-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 hover:bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-zinc-400 hover:text-gray-900 dark:text-white transition-all flex items-center justify-center relative focus:outline-none"
7071
>
7172
<Bell className="w-5 h-5" />
7273
{notifications.length > 0 && (
@@ -78,9 +79,9 @@ export const Header: React.FC<HeaderProps> = ({
7879

7980
{/* Dropdown panel */}
8081
{dropdownOpen && (
81-
<div className="absolute right-0 mt-2 w-80 bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl p-4 space-y-3 z-[100]">
82-
<div className="flex justify-between items-center border-b border-zinc-800 pb-2">
83-
<span className="text-xs font-semibold text-white uppercase tracking-wider">
82+
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-lg shadow-2xl p-4 space-y-3 z-[100]">
83+
<div className="flex justify-between items-center border-b border-gray-200 dark:border-zinc-800 pb-2">
84+
<span className="text-xs font-semibold text-gray-900 dark:text-white uppercase tracking-wider">
8485
Announcements
8586
</span>
8687
{notifications.length > 0 && (
@@ -89,15 +90,15 @@ export const Header: React.FC<HeaderProps> = ({
8990
onDismissAllNotifications();
9091
setDropdownOpen(false);
9192
}}
92-
className="text-[10px] text-zinc-500 hover:text-white transition-colors focus:outline-none"
93+
className="text-[10px] text-gray-400 dark:text-zinc-500 hover:text-gray-900 dark:text-white transition-colors focus:outline-none"
9394
>
9495
Clear All
9596
</button>
9697
)}
9798
</div>
9899
<div className="max-h-60 overflow-y-auto space-y-2 text-xs">
99100
{notifications.length === 0 ? (
100-
<div className="text-zinc-500 text-center py-4">
101+
<div className="text-gray-400 dark:text-zinc-500 text-center py-4">
101102
No new notifications
102103
</div>
103104
) : (
@@ -111,27 +112,27 @@ export const Header: React.FC<HeaderProps> = ({
111112
className={`border p-3 rounded flex justify-between items-start gap-4 transition-colors relative group ${
112113
isFail
113114
? "bg-red-500/10 border-red-500/20 text-red-200"
114-
: "bg-zinc-900 border-zinc-800 text-zinc-300"
115+
: "bg-white dark:bg-zinc-900 border-gray-200 dark:border-zinc-800 text-gray-600 dark:text-zinc-300"
115116
}`}
116117
>
117118
<div className="flex items-start gap-2.5">
118119
{isFail ? (
119120
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5" />
120121
) : (
121-
<Info className="w-4 h-4 text-zinc-400 mt-0.5" />
122+
<Info className="w-4 h-4 text-gray-500 dark:text-zinc-400 mt-0.5" />
122123
)}
123124
<div>
124-
<p className="font-semibold text-xs text-white">
125+
<p className="font-semibold text-xs text-gray-900 dark:text-white">
125126
{ann.title}
126127
</p>
127-
<p className="text-[10px] text-zinc-400 mt-1">
128+
<p className="text-[10px] text-gray-500 dark:text-zinc-400 mt-1">
128129
{ann.description}
129130
</p>
130131
</div>
131132
</div>
132133
<button
133134
onClick={() => onDismissNotification(ann.id)}
134-
className="text-zinc-500 hover:text-white transition-colors focus:outline-none"
135+
className="text-gray-400 dark:text-zinc-500 hover:text-gray-900 dark:text-white transition-colors focus:outline-none"
135136
>
136137
<X className="w-3.5 h-3.5" />
137138
</button>
@@ -144,25 +145,28 @@ export const Header: React.FC<HeaderProps> = ({
144145
)}
145146
</div>
146147

148+
{/* Theme Toggle */}
149+
<ThemeToggle />
150+
147151
{/* User menu */}
148152
<div className="relative flex items-center gap-3" ref={profileDropdownRef}>
149-
<span className="text-xs text-zinc-400 font-medium">{userEmail}</span>
153+
<span className="text-xs text-gray-500 dark:text-zinc-400 font-medium">{userEmail}</span>
150154
<button
151155
data-testid="user-avatar"
152156
onClick={() => setProfileDropdownOpen(!profileDropdownOpen)}
153-
className="w-8 h-8 rounded-full bg-zinc-800 border border-zinc-700 flex items-center justify-center text-white font-semibold uppercase text-xs hover:border-zinc-500 transition-colors focus:outline-none"
157+
className="w-8 h-8 rounded-full bg-gray-100 dark:bg-zinc-800 border border-gray-300 dark:border-zinc-700 flex items-center justify-center text-gray-900 dark:text-white font-semibold uppercase text-xs hover:border-gray-400 dark:hover:border-zinc-500 transition-colors focus:outline-none"
154158
>
155159
{getInitials(userEmail)}
156160
</button>
157161

158162
{profileDropdownOpen && (
159-
<div className="absolute right-0 top-12 mt-2 w-48 bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl p-2 space-y-1 z-[100]">
163+
<div className="absolute right-0 top-12 mt-2 w-48 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-lg shadow-2xl p-2 space-y-1 z-[100]">
160164
<button
161165
onClick={() => {
162166
setProfileDropdownOpen(false);
163167
onGoToSettings();
164168
}}
165-
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-300 hover:text-white hover:bg-zinc-800 rounded transition-colors focus:outline-none"
169+
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-600 dark:text-zinc-300 hover:text-gray-900 dark:text-white hover:bg-gray-100 dark:bg-zinc-800 rounded transition-colors focus:outline-none"
166170
>
167171
<Settings className="w-4 h-4" />
168172
Settings

qpi-ui/internal/dashboard/src/components/LoginModal.tsx

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -85,23 +85,23 @@ export const LoginModal: React.FC<LoginModalProps> = ({
8585
return (
8686
<div
8787
data-testid="login-modal"
88-
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-md"
88+
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-gray-50 dark:bg-zinc-950/80 backdrop-blur-md"
8989
>
90-
<div className="w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl p-8 space-y-6">
90+
<div className="w-full max-w-md bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-lg shadow-2xl p-8 space-y-6">
9191
<div className="text-center">
92-
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-zinc-800 text-white mb-4 border border-zinc-700">
92+
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-gray-100 dark:bg-zinc-800 text-gray-900 dark:text-white mb-4 border border-gray-300 dark:border-zinc-700">
9393
<Lock className="w-6 h-6" />
9494
</div>
95-
<h2 className="text-2xl font-geist font-bold text-white">
95+
<h2 className="text-2xl font-geist font-bold text-gray-900 dark:text-white">
9696
Sign in to QPI
9797
</h2>
98-
<p className="text-sm text-zinc-400 mt-1">
98+
<p className="text-sm text-gray-500 dark:text-zinc-400 mt-1">
9999
Access your quantum computing environment
100100
</p>
101101
</div>
102102

103103
{/* Role tabs */}
104-
<div className="flex border-b border-zinc-800">
104+
<div className="flex border-b border-gray-200 dark:border-zinc-800">
105105
<button
106106
type="button"
107107
onClick={() => {
@@ -110,8 +110,8 @@ export const LoginModal: React.FC<LoginModalProps> = ({
110110
}}
111111
className={`flex-1 pb-2 font-geist text-sm text-center border-b-2 font-medium focus:outline-none transition-all ${
112112
role === "user"
113-
? "border-white text-white"
114-
: "border-transparent text-zinc-400 hover:text-zinc-200"
113+
? "border-white text-gray-900 dark:text-white"
114+
: "border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:text-zinc-200"
115115
}`}
116116
>
117117
Regular User
@@ -124,8 +124,8 @@ export const LoginModal: React.FC<LoginModalProps> = ({
124124
}}
125125
className={`flex-1 pb-2 font-geist text-sm text-center border-b-2 font-medium focus:outline-none transition-all ${
126126
role === "admin"
127-
? "border-white text-white"
128-
: "border-transparent text-zinc-400 hover:text-zinc-200"
127+
? "border-white text-gray-900 dark:text-white"
128+
: "border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:text-zinc-200"
129129
}`}
130130
>
131131
Administrator
@@ -143,7 +143,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({
143143
type="button"
144144
disabled={loading}
145145
onClick={() => handleOAuth2Login(provider.name)}
146-
className="w-full bg-zinc-800 hover:bg-zinc-700 text-white font-medium py-2 px-4 rounded transition-colors flex items-center justify-center gap-2 border border-zinc-700 disabled:opacity-50"
146+
className="w-full bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-900 dark:text-white font-medium py-2 px-4 rounded transition-colors flex items-center justify-center gap-2 border border-gray-300 dark:border-zinc-700 disabled:opacity-50"
147147
>
148148
<span className="capitalize">
149149
Continue with {provider.name}
@@ -155,10 +155,10 @@ export const LoginModal: React.FC<LoginModalProps> = ({
155155
{(!authMethods || authMethods.password?.enabled !== false) && (
156156
<div className="relative pt-4 pb-2">
157157
<div className="absolute inset-0 flex items-center pt-2">
158-
<span className="w-full border-t border-zinc-800" />
158+
<span className="w-full border-t border-gray-200 dark:border-zinc-800" />
159159
</div>
160160
<div className="relative flex justify-center text-xs uppercase">
161-
<span className="bg-zinc-900 px-2 text-zinc-500 font-medium tracking-wider">
161+
<span className="bg-white dark:bg-zinc-900 px-2 text-gray-400 dark:text-zinc-500 font-medium tracking-wider">
162162
Or use credentials
163163
</span>
164164
</div>
@@ -170,30 +170,30 @@ export const LoginModal: React.FC<LoginModalProps> = ({
170170
{(!authMethods || role === "admin" || authMethods.password?.enabled !== false) && (
171171
<form onSubmit={handleLogin} className="space-y-4">
172172
<div>
173-
<label className="block text-xs font-medium text-zinc-400 mb-1.5 uppercase tracking-wider">
173+
<label className="block text-xs font-medium text-gray-500 dark:text-zinc-400 mb-1.5 uppercase tracking-wider">
174174
Email or Username
175175
</label>
176176
<input
177177
type="text"
178178
required
179179
value={identity}
180180
onChange={(e) => setIdentity(e.target.value)}
181-
className="w-full bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-zinc-500 transition-colors"
181+
className="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-200 dark:border-zinc-800 rounded px-3 py-2 text-gray-900 dark:text-white text-sm focus:outline-none focus:border-zinc-500 transition-colors"
182182
placeholder={
183183
role === "admin" ? "admin@example.com" : "user@example.com"
184184
}
185185
/>
186186
</div>
187187
<div>
188-
<label className="block text-xs font-medium text-zinc-400 mb-1.5 uppercase tracking-wider">
188+
<label className="block text-xs font-medium text-gray-500 dark:text-zinc-400 mb-1.5 uppercase tracking-wider">
189189
Password
190190
</label>
191191
<input
192192
type="password"
193193
required
194194
value={password}
195195
onChange={(e) => setPassword(e.target.value)}
196-
className="w-full bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-zinc-500 transition-colors"
196+
className="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-200 dark:border-zinc-800 rounded px-3 py-2 text-gray-900 dark:text-white text-sm focus:outline-none focus:border-zinc-500 transition-colors"
197197
placeholder="••••••••"
198198
/>
199199
</div>

0 commit comments

Comments
 (0)