Skip to content

Commit 2eccca9

Browse files
committed
Sync theme live across dashboard, panel, and settings
1 parent 6d24433 commit 2eccca9

8 files changed

Lines changed: 107 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ For complete project history, architecture decisions, execution timeline, built/
1717
- Time range filters: `1h`, `4h`, `24h`, `7d`
1818
- Global side panel UI (dark mode default) with:
1919
- Search
20-
- Theme toggle (shared with full dashboard)
20+
- Theme toggle (shared with full dashboard, live sync across open views)
2121
- `Expand` to full dashboard tab
2222
- Click any entry to focus an existing tab or open it if already closed.
2323
- Local-only storage (IndexedDB), no cloud sync.

background/service-worker.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ function getSettingsUrl() {
4949
return chrome.runtime.getURL("ui/settings.html");
5050
}
5151

52+
function broadcastTheme(theme) {
53+
chrome.runtime.sendMessage({ type: "theme-changed", theme }).catch(() => {
54+
// Ignore when no extension page is listening.
55+
});
56+
}
57+
5258
function getSenderWindowId(sender) {
5359
return typeof sender?.tab?.windowId === "number" ? sender.tab.windowId : null;
5460
}
@@ -536,6 +542,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
536542
} else {
537543
await syncCurrentActiveContext("settings_updated");
538544
}
545+
broadcastTheme(state.theme);
539546
sendResponse({ ok: true });
540547
})
541548
.catch((error) => {
@@ -549,6 +556,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
549556
updateSettings({ theme: message.theme })
550557
.then(async (settings) => {
551558
state.theme = settings.theme;
559+
broadcastTheme(settings.theme);
552560
sendResponse({ ok: true, theme: settings.theme });
553561
})
554562
.catch((error) => sendResponse({ ok: false, error: String(error) }));

project-history.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,14 @@ Chronological execution log:
192192
- updated dashboard/panel/settings favicon links and header icon references
193193
- extended smoke assertions to require `icon-v2-32.png` favicon path in both dashboard and settings
194194

195+
34. Fixed cross-surface live theme sync regression:
196+
- added runtime theme broadcast (`theme-changed`) from service worker on `set-theme` and `settings-updated`
197+
- added listeners in dashboard/panel and settings pages to apply incoming theme updates live
198+
- synchronized settings dropdown value with runtime theme updates
199+
- expanded tests:
200+
- e2e runtime broadcast tests for dashboard and settings
201+
- smoke assertions for two-way live sync (`dashboard -> panel` and `panel -> dashboard/settings`)
202+
195203
## 4. What Were The Decisions That We Took?
196204

197205
### Product/Architecture Decisions
@@ -306,6 +314,7 @@ Not in MVP (intentionally out of scope):
306314
- `npm run test:all`: passing
307315
- `npm run test:smoke:extension`: passing
308316
- `npm run test:flows:extension`: passing (`18/18` transition sequences)
317+
- Cross-surface live theme sync check: passing (`dashboard->panel`, `panel->dashboard/settings`)
309318
- Long-duration headed validation: passing (`runId=20260226-192909`, `allTabsCount=6`, `neverFocused=4`, `retentionDays=30`, `theme=dark`)
310319
- Action-click config check: passing (`sidePanelApiAvailable=true`, `openPanelOnActionClick=true`)
311320

scripts/extension-smoke-test.mjs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ async function run() {
3030
const dashboardPage = await context.newPage();
3131
await dashboardPage.goto(dashboardUrl, { waitUntil: "domcontentloaded" });
3232
const dashboardFaviconHref = await dashboardPage.getAttribute('link[rel="icon"]', "href");
33+
const dashboardThemeInitial = await dashboardPage.getAttribute("body", "data-theme");
3334

3435
const heading = await dashboardPage.textContent("h1");
3536
const activityListCount = await dashboardPage.locator("#activity-list").count();
@@ -44,6 +45,7 @@ async function run() {
4445
const panelPage = await context.newPage();
4546
await panelPage.goto(panelUrl, { waitUntil: "domcontentloaded" });
4647
const panelViewCount = await panelPage.locator('[data-view]').count();
48+
const panelThemeInitial = await panelPage.getAttribute("body", "data-theme");
4749
const initialPageCount = context.pages().length;
4850

4951
await dashboardPage.click("#open-side-panel");
@@ -55,6 +57,8 @@ async function run() {
5557
await dashboardPage.click("#theme-toggle");
5658
await dashboardPage.waitForTimeout(250);
5759
const dashboardThemeAfterToggle = await dashboardPage.getAttribute("body", "data-theme");
60+
await panelPage.waitForFunction(() => document.body?.dataset?.theme === "light", null, { timeout: 5_000 });
61+
const panelThemeAfterDashboardToggle = await panelPage.getAttribute("body", "data-theme");
5862

5963
await dashboardPage.click("#open-settings");
6064
await dashboardPage.waitForURL((url) => url.toString().endsWith("/ui/settings.html"), { timeout: 5_000 });
@@ -74,12 +78,18 @@ async function run() {
7478
const dashboardOpenedInCurrentTab = dashboardPage.url().endsWith("/ui/dashboard.html");
7579
const pageCountAfterBackToDashboard = context.pages().length;
7680

81+
await panelPage.click("#theme-toggle");
82+
await panelPage.waitForTimeout(250);
83+
const panelThemeAfterPanelToggle = await panelPage.getAttribute("body", "data-theme");
84+
await dashboardPage.waitForFunction(() => document.body?.dataset?.theme === "dark", null, { timeout: 5_000 });
85+
const dashboardThemeAfterPanelToggle = await dashboardPage.getAttribute("body", "data-theme");
86+
7787
await dashboardPage.click("#open-settings");
7888
await dashboardPage.waitForURL((url) => url.toString().endsWith("/ui/settings.html"), { timeout: 5_000 });
7989
await dashboardPage.waitForFunction(
8090
() =>
81-
document.body?.dataset?.theme === "light" &&
82-
document.querySelector("#theme")?.value === "light",
91+
document.body?.dataset?.theme === "dark" &&
92+
document.querySelector("#theme")?.value === "dark",
8393
null,
8494
{ timeout: 5_000 }
8595
);
@@ -133,6 +143,11 @@ async function run() {
133143
paused: status?.paused,
134144
sidePanelApiAvailable: status?.sidePanelApiAvailable,
135145
openPanelOnActionClick: status?.openPanelOnActionClick,
146+
dashboardThemeInitial,
147+
panelThemeInitial,
148+
panelThemeAfterDashboardToggle,
149+
panelThemeAfterPanelToggle,
150+
dashboardThemeAfterPanelToggle,
136151
runtimeAfterDashboardSidePanel,
137152
runtimeAfterSettingsSidePanel,
138153
dashboardFaviconHref,
@@ -171,9 +186,14 @@ async function run() {
171186
result.settingsOpenedInCurrentTab !== true ||
172187
result.dashboardOpenedInCurrentTab !== true ||
173188
result.pageCountUnchangedOnSettingsRoundTrip !== true ||
189+
result.dashboardThemeInitial !== "dark" ||
190+
result.panelThemeInitial !== "dark" ||
174191
result.dashboardThemeAfterToggle !== "light" ||
175-
result.settingsTheme !== "light" ||
176-
result.settingsThemeValue !== "light" ||
192+
result.panelThemeAfterDashboardToggle !== "light" ||
193+
result.panelThemeAfterPanelToggle !== "dark" ||
194+
result.dashboardThemeAfterPanelToggle !== "dark" ||
195+
result.settingsTheme !== "dark" ||
196+
result.settingsThemeValue !== "dark" ||
177197
Number(result.themeSelectContrast || 0) < 4.5 ||
178198
result.settingsHeading !== "Settings"
179199
) {

tests/e2e/dashboard.spec.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ test.beforeEach(async ({ page }) => {
7171
await page.addInitScript(() => {
7272
window.__queryResult = [];
7373
window.__calls = [];
74+
window.__runtimeMessageListener = null;
7475

7576
window.chrome = {
7677
tabs: {
@@ -95,6 +96,11 @@ test.beforeEach(async ({ page }) => {
9596
window.__calls.push({ api: "runtime.openOptionsPage" });
9697
return {};
9798
},
99+
onMessage: {
100+
addListener: (listener) => {
101+
window.__runtimeMessageListener = listener;
102+
}
103+
},
98104
sendMessage: async (message) => {
99105
window.__calls.push({ api: "runtime.sendMessage", message });
100106
if (message?.type === "get-runtime-status") {
@@ -286,3 +292,16 @@ test("settings theme select maintains readable contrast in dark and light modes"
286292
expect(contrastRatios.darkRatio).toBeGreaterThanOrEqual(4.5);
287293
expect(contrastRatios.lightRatio).toBeGreaterThanOrEqual(4.5);
288294
});
295+
296+
test("dashboard applies broadcast theme updates from runtime", async ({ page }) => {
297+
await page.goto("/ui/dashboard.html");
298+
await expect(page.locator("body")).toHaveAttribute("data-theme", "dark");
299+
300+
await page.evaluate(() => {
301+
if (typeof window.__runtimeMessageListener === "function") {
302+
window.__runtimeMessageListener({ type: "theme-changed", theme: "light" }, {}, () => {});
303+
}
304+
});
305+
306+
await expect(page.locator("body")).toHaveAttribute("data-theme", "light");
307+
});

tests/e2e/settings.spec.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ test.beforeEach(async ({ page }) => {
88
"open-side-panel": { ok: true, mode: "sender_window" },
99
"settings-updated": { ok: true }
1010
};
11+
window.__runtimeMessageListener = null;
1112

1213
window.chrome = {
1314
tabs: {
@@ -17,6 +18,11 @@ test.beforeEach(async ({ page }) => {
1718
}
1819
},
1920
runtime: {
21+
onMessage: {
22+
addListener: (listener) => {
23+
window.__runtimeMessageListener = listener;
24+
}
25+
},
2026
sendMessage: async (message) => {
2127
window.__calls.push({ api: "runtime.sendMessage", message });
2228
const type = message?.type;
@@ -66,3 +72,18 @@ test("settings side panel action shows success status when opened", async ({ pag
6672
await page.click("#open-side-panel");
6773
await expect(page.locator("#status")).toHaveText("Side panel opened.");
6874
});
75+
76+
test("settings page applies broadcast theme updates and syncs dropdown value", async ({ page }) => {
77+
await page.goto("/ui/settings.html");
78+
await expect(page.locator("body")).toHaveAttribute("data-theme", "dark");
79+
await expect(page.locator("#theme")).toHaveValue("dark");
80+
81+
await page.evaluate(() => {
82+
if (typeof window.__runtimeMessageListener === "function") {
83+
window.__runtimeMessageListener({ type: "theme-changed", theme: "light" }, {}, () => {});
84+
}
85+
});
86+
87+
await expect(page.locator("body")).toHaveAttribute("data-theme", "light");
88+
await expect(page.locator("#theme")).toHaveValue("light");
89+
});

ui/activity.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,14 @@ function bindEvents() {
326326
});
327327
}
328328

329+
function bindRuntimeListeners() {
330+
chrome.runtime?.onMessage?.addListener?.((message) => {
331+
if (message?.type === "theme-changed") {
332+
applyTheme(message.theme);
333+
}
334+
});
335+
}
336+
329337
async function bootstrapRuntimeSettings() {
330338
try {
331339
const runtime = await chrome.runtime.sendMessage({ type: "get-runtime-status" });
@@ -359,6 +367,7 @@ function startAutoRefresh() {
359367

360368
async function initialize() {
361369
await bootstrapRuntimeSettings();
370+
bindRuntimeListeners();
362371
bindEvents();
363372
await loadActivities();
364373
startAutoRefresh();

ui/settings.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ function applyTheme(theme) {
2727
document.body.dataset.theme = theme === "light" ? "light" : "dark";
2828
}
2929

30+
function syncThemeUi(theme) {
31+
const normalized = theme === "light" ? "light" : "dark";
32+
applyTheme(normalized);
33+
themeElement.value = normalized;
34+
}
35+
3036
function renderStatus(message, isError = false) {
3137
statusElement.textContent = message;
3238
if (isError) {
@@ -41,8 +47,7 @@ async function loadSettings() {
4147
pausedElement.checked = Boolean(settings.paused);
4248
excludedDomainsElement.value = (settings.excludedDomains || []).join("\n");
4349
retentionDaysElement.textContent = String(settings.retentionDays || DEFAULT_SETTINGS.retentionDays);
44-
themeElement.value = settings.theme || DEFAULT_SETTINGS.theme;
45-
applyTheme(themeElement.value);
50+
syncThemeUi(settings.theme || DEFAULT_SETTINGS.theme);
4651
}
4752

4853
async function saveSettings() {
@@ -103,8 +108,17 @@ function bindEvents() {
103108
});
104109
}
105110

111+
function bindRuntimeListeners() {
112+
chrome.runtime?.onMessage?.addListener?.((message) => {
113+
if (message?.type === "theme-changed") {
114+
syncThemeUi(message.theme);
115+
}
116+
});
117+
}
118+
106119
async function initialize() {
107120
await loadSettings();
121+
bindRuntimeListeners();
108122
bindEvents();
109123
}
110124

0 commit comments

Comments
 (0)