Skip to content

Commit c3aa469

Browse files
authored
Merge pull request #42 from devitools/fix/theme-flicker
fix(theme): stabilize multi-window theme sync and eliminate flicker
2 parents e2fff0f + b937ccf commit c3aa469

16 files changed

Lines changed: 472 additions & 22 deletions

File tree

apps/tauri/index.html

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,32 @@
11
<!DOCTYPE html>
2-
<html lang="en">
2+
<html lang="en" data-window="main">
33
<head>
44
<meta charset="UTF-8" />
55
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>Arandu</title>
8+
<script>
9+
(() => {
10+
try {
11+
const rawTheme = localStorage.getItem("arandu-theme");
12+
const theme = rawTheme === "light" || rawTheme === "dark" || rawTheme === "system"
13+
? rawTheme
14+
: "system";
15+
const resolvedTheme =
16+
theme === "system"
17+
? typeof window.matchMedia === "function" && window.matchMedia("(prefers-color-scheme: dark)").matches
18+
? "dark"
19+
: "light"
20+
: theme;
21+
const root = document.documentElement;
22+
root.classList.toggle("dark", resolvedTheme === "dark");
23+
root.style.colorScheme = resolvedTheme;
24+
root.dataset.theme = resolvedTheme;
25+
} catch {
26+
// Ignore early theme bootstrap failures and let React apply the fallback.
27+
}
28+
})();
29+
</script>
830
</head>
931
<body>
1032
<div id="root"></div>

apps/tauri/settings.html

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
11
<!DOCTYPE html>
2-
<html lang="en">
2+
<html lang="en" data-window="settings">
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>Arandu Settings</title>
7+
<script>
8+
(() => {
9+
try {
10+
const rawTheme = localStorage.getItem("arandu-theme");
11+
const theme = rawTheme === "light" || rawTheme === "dark" || rawTheme === "system"
12+
? rawTheme
13+
: "system";
14+
const resolvedTheme =
15+
theme === "system"
16+
? typeof window.matchMedia === "function" && window.matchMedia("(prefers-color-scheme: dark)").matches
17+
? "dark"
18+
: "light"
19+
: theme;
20+
const root = document.documentElement;
21+
root.classList.toggle("dark", resolvedTheme === "dark");
22+
root.style.colorScheme = resolvedTheme;
23+
root.dataset.theme = resolvedTheme;
24+
} catch {
25+
// Ignore early theme bootstrap failures and let React apply the fallback.
26+
}
27+
})();
28+
</script>
729
</head>
830
<body>
931
<div id="root"></div>

apps/tauri/src/App.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useRef } from "react";
2-
import { ThemeProvider } from "next-themes";
2+
import { ThemeProvider } from "@/lib/theme";
33
import { AppProvider, useApp, ANIMATION_DURATION } from "@/contexts/AppContext";
44
import { TopBar } from "@/components/TopBar";
55
import { HomeScreen } from "@/components/HomeScreen";
@@ -226,7 +226,13 @@ function App() {
226226

227227
return (
228228
<ErrorBoundary>
229-
<ThemeProvider attribute="class" defaultTheme="system" enableSystem storageKey="arandu-theme">
229+
<ThemeProvider
230+
attribute="class"
231+
defaultTheme="system"
232+
enableSystem
233+
storageKey="arandu-theme"
234+
disableTransitionOnChange
235+
>
230236
<TooltipProvider>
231237
<AppProvider>
232238
<AppContent />

apps/tauri/src/SettingsApp.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from "react";
2-
import { ThemeProvider } from "next-themes";
2+
import { ThemeProvider } from "@/lib/theme";
33
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
44
import { TooltipProvider } from "@/components/ui/tooltip";
55
import { WhisperSettings } from "@/components/settings/WhisperSettings";
@@ -41,7 +41,13 @@ export function SettingsApp() {
4141
}, []);
4242

4343
return (
44-
<ThemeProvider attribute="class" defaultTheme="system" enableSystem storageKey="arandu-theme">
44+
<ThemeProvider
45+
attribute="class"
46+
defaultTheme="system"
47+
enableSystem
48+
storageKey="arandu-theme"
49+
disableTransitionOnChange
50+
>
4551
<TooltipProvider>
4652
<div className="h-screen overflow-hidden bg-background text-foreground flex flex-col">
4753
<div className="p-6 flex-1 overflow-y-auto">

apps/tauri/src/WhisperApp.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useEffect, useRef, useState } from "react";
2-
import { ThemeProvider } from "next-themes";
2+
import { ThemeProvider } from "@/lib/theme";
33
import { useTranslation } from "react-i18next";
44
import { Button } from "@/components/ui/button";
55

@@ -347,7 +347,13 @@ function WhisperContent() {
347347

348348
export function WhisperApp() {
349349
return (
350-
<ThemeProvider attribute="class" defaultTheme="system" enableSystem storageKey="arandu-theme">
350+
<ThemeProvider
351+
attribute="class"
352+
defaultTheme="system"
353+
enableSystem
354+
storageKey="arandu-theme"
355+
disableTransitionOnChange
356+
>
351357
<WhisperContent />
352358
</ThemeProvider>
353359
);

apps/tauri/src/__tests__/components/ChatPanel.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ describe('ChatPanel', () => {
7272

7373
render(<ChatPanel messages={[]} onSendMessage={onSendMessage} />);
7474

75-
const textarea = screen.getByPlaceholderText(/Digite uma mensagem/i);
75+
const textarea = screen.getByPlaceholderText(/Passe orientações para o agente/i);
7676
await user.type(textarea, 'Test message');
7777
await user.click(screen.getByRole('button'));
7878

@@ -84,7 +84,7 @@ describe('ChatPanel', () => {
8484

8585
render(<ChatPanel messages={[]} onSendMessage={vi.fn()} />);
8686

87-
const textarea = screen.getByPlaceholderText(/Digite uma mensagem/i) as HTMLTextAreaElement;
87+
const textarea = screen.getByPlaceholderText(/Passe orientações para o agente/i) as HTMLTextAreaElement;
8888
await user.type(textarea, 'Test message');
8989
await user.click(screen.getByRole('button'));
9090

@@ -97,7 +97,7 @@ describe('ChatPanel', () => {
9797

9898
render(<ChatPanel messages={[]} onSendMessage={onSendMessage} />);
9999

100-
const textarea = screen.getByPlaceholderText(/Digite uma mensagem/i);
100+
const textarea = screen.getByPlaceholderText(/Passe orientações para o agente/i);
101101
await user.type(textarea, 'Test message{Enter}');
102102

103103
expect(onSendMessage).toHaveBeenCalledWith('Test message');

apps/tauri/src/__tests__/components/HomeScreen.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ describe('HomeScreen', () => {
1313

1414
expect(screen.getByText('Arandu')).toBeInTheDocument();
1515
expect(screen.getByText('Abrir Arquivo')).toBeInTheDocument();
16-
expect(screen.getByText('Abrir Diretório')).toBeInTheDocument();
16+
expect(screen.getByText('Abrir Área de Trabalho')).toBeInTheDocument();
1717
});
1818
});

apps/tauri/src/__tests__/components/TopBar.test.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { describe, it, expect } from 'vitest';
22
import { render, screen } from '@testing-library/react';
3-
import { ThemeProvider } from 'next-themes';
3+
import { ThemeProvider } from '@/lib/theme';
4+
import { TooltipProvider } from '@/components/ui/tooltip';
45
import { TopBar } from '@/components/TopBar';
56

67
describe('TopBar', () => {
78
it('renders logo and app name', () => {
89
render(
910
<ThemeProvider>
10-
<TopBar />
11+
<TooltipProvider>
12+
<TopBar />
13+
</TooltipProvider>
1114
</ThemeProvider>
1215
);
1316

@@ -18,7 +21,9 @@ describe('TopBar', () => {
1821
it('renders theme toggle button', () => {
1922
render(
2023
<ThemeProvider>
21-
<TopBar />
24+
<TooltipProvider>
25+
<TopBar />
26+
</TooltipProvider>
2227
</ThemeProvider>
2328
);
2429

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { ThemeProvider, useTheme } from "@/lib/theme";
4+
5+
function mockMatchMedia(matches = false) {
6+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
7+
matches,
8+
media: query,
9+
onchange: null,
10+
addListener: vi.fn(),
11+
removeListener: vi.fn(),
12+
addEventListener: vi.fn(),
13+
removeEventListener: vi.fn(),
14+
dispatchEvent: vi.fn(),
15+
}));
16+
}
17+
18+
function ThemeHarness() {
19+
const { theme, resolvedTheme, setTheme } = useTheme();
20+
21+
return (
22+
<>
23+
<span data-testid="theme">{theme}</span>
24+
<span data-testid="resolved-theme">{resolvedTheme}</span>
25+
<button onClick={() => setTheme("light")}>light</button>
26+
<button onClick={() => setTheme("dark")}>dark</button>
27+
<button onClick={() => setTheme("system")}>system</button>
28+
</>
29+
);
30+
}
31+
32+
afterEach(() => {
33+
cleanup();
34+
localStorage.clear();
35+
document.documentElement.className = "";
36+
delete document.documentElement.dataset.theme;
37+
document.documentElement.style.colorScheme = "";
38+
vi.restoreAllMocks();
39+
mockMatchMedia(false);
40+
});
41+
42+
describe("ThemeProvider", () => {
43+
it("applies the stored theme on mount", () => {
44+
mockMatchMedia(false);
45+
localStorage.setItem("test-theme", "dark");
46+
47+
render(
48+
<ThemeProvider storageKey="test-theme">
49+
<ThemeHarness />
50+
</ThemeProvider>
51+
);
52+
53+
expect(screen.getByTestId("theme")).toHaveTextContent("dark");
54+
expect(screen.getByTestId("resolved-theme")).toHaveTextContent("dark");
55+
expect(document.documentElement).toHaveClass("dark");
56+
expect(document.documentElement.dataset.theme).toBe("dark");
57+
});
58+
59+
it("writes to localStorage only when the active window changes theme", () => {
60+
mockMatchMedia(false);
61+
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
62+
const emitSpy = vi.spyOn(window.__TAURI__.event, "emit");
63+
64+
render(
65+
<ThemeProvider storageKey="test-theme">
66+
<ThemeHarness />
67+
</ThemeProvider>
68+
);
69+
70+
fireEvent.click(screen.getByRole("button", { name: "dark" }));
71+
72+
expect(setItemSpy).toHaveBeenCalledTimes(1);
73+
expect(setItemSpy).toHaveBeenCalledWith("test-theme", "dark");
74+
expect(emitSpy).toHaveBeenCalledWith("theme-changed", "dark");
75+
expect(document.documentElement).toHaveClass("dark");
76+
});
77+
78+
it("reacts to storage sync without echo-writing the same key", () => {
79+
mockMatchMedia(false);
80+
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
81+
const emitSpy = vi.spyOn(window.__TAURI__.event, "emit");
82+
83+
render(
84+
<ThemeProvider storageKey="test-theme">
85+
<ThemeHarness />
86+
</ThemeProvider>
87+
);
88+
89+
setItemSpy.mockClear();
90+
emitSpy.mockClear();
91+
92+
act(() => {
93+
window.dispatchEvent(
94+
new StorageEvent("storage", {
95+
key: "test-theme",
96+
newValue: "dark",
97+
})
98+
);
99+
});
100+
101+
expect(screen.getByTestId("theme")).toHaveTextContent("dark");
102+
expect(screen.getByTestId("resolved-theme")).toHaveTextContent("dark");
103+
expect(document.documentElement).toHaveClass("dark");
104+
expect(setItemSpy).not.toHaveBeenCalled();
105+
expect(emitSpy).not.toHaveBeenCalled();
106+
});
107+
108+
it("tracks system appearance changes while the preference is system", () => {
109+
let listener: (() => void) | null = null;
110+
let matches = false;
111+
112+
window.matchMedia = vi.fn().mockImplementation(() => ({
113+
get matches() {
114+
return matches;
115+
},
116+
media: "(prefers-color-scheme: dark)",
117+
onchange: null,
118+
addListener: vi.fn((fn: () => void) => {
119+
listener = fn;
120+
}),
121+
removeListener: vi.fn(),
122+
addEventListener: vi.fn((_: string, fn: () => void) => {
123+
listener = fn;
124+
}),
125+
removeEventListener: vi.fn(),
126+
dispatchEvent: vi.fn(),
127+
}));
128+
129+
render(
130+
<ThemeProvider storageKey="test-theme" defaultTheme="system">
131+
<ThemeHarness />
132+
</ThemeProvider>
133+
);
134+
135+
expect(screen.getByTestId("resolved-theme")).toHaveTextContent("light");
136+
137+
matches = true;
138+
act(() => {
139+
listener?.();
140+
});
141+
142+
expect(screen.getByTestId("theme")).toHaveTextContent("system");
143+
expect(screen.getByTestId("resolved-theme")).toHaveTextContent("dark");
144+
expect(document.documentElement).toHaveClass("dark");
145+
});
146+
});

apps/tauri/src/__tests__/setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ globalThis.__TAURI__ = {
1717
open: vi.fn(),
1818
},
1919
event: {
20+
emit: vi.fn(() => Promise.resolve()),
2021
listen: vi.fn(() => Promise.resolve(() => {})),
2122
},
2223
};

0 commit comments

Comments
 (0)