Skip to content

Commit 28ce90e

Browse files
committed
add theme support
1 parent 9b635d7 commit 28ce90e

6 files changed

Lines changed: 245 additions & 32 deletions

File tree

web/e2e/app.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,19 @@ test('renders the Markdown library', async ({ page }) => {
1515
await expect(page.getByRole('heading', { name: 'Вся теория' })).toBeVisible()
1616
await expect(page.locator('.markdown-article')).toContainText('Senior Android')
1717
})
18+
19+
test('forces dark theme and returns to device theme', async ({ page }) => {
20+
await page.goto('/')
21+
const themeSwitch = page.getByRole('switch')
22+
23+
await expect(themeSwitch).toHaveAttribute('aria-checked', 'false')
24+
await themeSwitch.click()
25+
await expect(themeSwitch).toHaveAttribute('aria-checked', 'true')
26+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
27+
28+
await page.reload()
29+
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
30+
31+
await page.getByRole('switch').click()
32+
await expect(page.locator('html')).not.toHaveAttribute('data-theme')
33+
})

web/playwright.config.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import { defineConfig, devices } from '@playwright/test'
22

3+
const port = process.env.PLAYWRIGHT_PORT ?? '4173'
4+
const baseURL = `http://127.0.0.1:${port}`
5+
36
export default defineConfig({
47
testDir: './e2e',
58
fullyParallel: true,
69
use: {
7-
baseURL: 'http://127.0.0.1:4173',
10+
baseURL,
811
trace: 'on-first-retry',
912
},
1013
projects: [
1114
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
1215
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
1316
],
1417
webServer: {
15-
command: 'bun run dev -- --host 127.0.0.1 --port 4173',
16-
url: 'http://127.0.0.1:4173',
18+
command: `bun run dev -- --host 127.0.0.1 --port ${port}`,
19+
url: baseURL,
1720
reuseExistingServer: !process.env.CI,
1821
},
1922
})

web/src/App.css

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
align-items: center;
1616
justify-content: space-between;
1717
gap: 24px;
18-
background: rgba(255, 255, 255, 0.94);
18+
background: var(--header-bg);
1919
border-bottom: 1px solid var(--border);
2020
position: sticky;
2121
top: 0;
@@ -39,6 +39,54 @@
3939
font-size: 0.94rem;
4040
}
4141

42+
.header-actions {
43+
display: flex;
44+
align-items: center;
45+
gap: 22px;
46+
}
47+
48+
.theme-toggle {
49+
min-height: 40px;
50+
padding: 5px 7px 5px 11px;
51+
display: inline-flex;
52+
align-items: center;
53+
gap: 9px;
54+
color: var(--muted);
55+
background: var(--surface-soft);
56+
border: 1px solid var(--border);
57+
border-radius: 999px;
58+
cursor: pointer;
59+
font-size: 0.78rem;
60+
font-weight: 700;
61+
}
62+
63+
.switch-track {
64+
width: 34px;
65+
height: 20px;
66+
padding: 2px;
67+
display: block;
68+
background: var(--border);
69+
border-radius: 999px;
70+
transition: background 0.18s ease;
71+
}
72+
73+
.switch-thumb {
74+
width: 16px;
75+
height: 16px;
76+
display: block;
77+
background: var(--surface);
78+
border-radius: 50%;
79+
transition: transform 0.18s ease;
80+
}
81+
82+
.theme-toggle[aria-checked="true"] .switch-track {
83+
background: var(--accent);
84+
}
85+
86+
.theme-toggle[aria-checked="true"] .switch-thumb {
87+
transform: translateX(14px);
88+
}
89+
4290
.brand {
4391
letter-spacing: -0.02em;
4492
}
@@ -148,7 +196,7 @@ footer {
148196
}
149197

150198
.button-primary {
151-
color: #fff;
199+
color: var(--on-accent);
152200
background: var(--accent);
153201
}
154202

@@ -159,7 +207,7 @@ footer {
159207
.button-secondary {
160208
color: var(--accent);
161209
background: var(--accent-soft);
162-
border-color: #cdd9ff;
210+
border-color: var(--accent-border);
163211
}
164212

165213
.button-ghost {
@@ -240,8 +288,8 @@ progress::-webkit-progress-value {
240288
.explanation pre {
241289
padding: 18px;
242290
overflow: auto;
243-
color: #e7edf9;
244-
background: #172033;
291+
color: var(--code-text);
292+
background: var(--code-bg);
245293
border-radius: 12px;
246294
font-size: 0.88rem;
247295
line-height: 1.55;
@@ -283,15 +331,15 @@ progress::-webkit-progress-value {
283331
}
284332

285333
.answer-correct {
286-
color: #0c6644;
334+
color: var(--success-text);
287335
background: var(--success-soft);
288-
border-color: #74c7a6;
336+
border-color: var(--success-border);
289337
}
290338

291339
.answer-wrong {
292-
color: #9e2739;
340+
color: var(--danger-text);
293341
background: var(--danger-soft);
294-
border-color: #e9a5ae;
342+
border-color: var(--danger-border);
295343
}
296344

297345
.answer-muted {
@@ -480,7 +528,7 @@ progress::-webkit-progress-value {
480528

481529
.markdown-article p,
482530
.markdown-article li {
483-
color: #344058;
531+
color: var(--article-text);
484532
line-height: 1.75;
485533
}
486534

@@ -527,9 +575,9 @@ progress::-webkit-progress-value {
527575
top: 8px;
528576
right: 8px;
529577
z-index: 1;
530-
color: #dbe4f7;
531-
background: #26324b;
532-
border: 1px solid #44516b;
578+
color: var(--code-text);
579+
background: var(--code-control-bg);
580+
border: 1px solid var(--code-control-border);
533581
border-radius: 7px;
534582
cursor: pointer;
535583
font-size: 0.72rem;
@@ -539,8 +587,8 @@ progress::-webkit-progress-value {
539587
margin: 0;
540588
padding: 42px 18px 18px;
541589
overflow-x: auto;
542-
color: #e7edf9;
543-
background: #172033;
590+
color: var(--code-text);
591+
background: var(--code-bg);
544592
border-radius: 12px;
545593
line-height: 1.6;
546594
}
@@ -588,19 +636,36 @@ progress::-webkit-progress-value {
588636
.site-header {
589637
min-height: 60px;
590638
padding: 10px 16px;
639+
gap: 10px;
591640
}
592641

593642
.brand {
594-
max-width: 150px;
595-
font-size: 0.88rem;
643+
max-width: 100px;
644+
font-size: 0.8rem;
645+
line-height: 1.15;
646+
}
647+
648+
.header-actions {
649+
gap: 8px;
596650
}
597651

598652
.site-header nav {
599-
gap: 12px;
653+
gap: 8px;
600654
}
601655

602656
.site-header nav a {
603-
font-size: 0.84rem;
657+
font-size: 0.8rem;
658+
}
659+
660+
.theme-toggle {
661+
width: 40px;
662+
min-height: 38px;
663+
padding: 4px 3px;
664+
justify-content: center;
665+
}
666+
667+
.theme-label {
668+
display: none;
604669
}
605670

606671
.page,

web/src/app/useThemeMode.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useLayoutEffect, useState } from 'react'
2+
3+
export type ThemeMode = 'system' | 'dark'
4+
5+
const STORAGE_KEY = 'android-interview-trainer:theme'
6+
7+
function initialMode(): ThemeMode {
8+
return localStorage.getItem(STORAGE_KEY) === 'dark' ? 'dark' : 'system'
9+
}
10+
11+
export function useThemeMode() {
12+
const [mode, setMode] = useState<ThemeMode>(initialMode)
13+
14+
useLayoutEffect(() => {
15+
if (mode === 'dark') {
16+
document.documentElement.dataset.theme = 'dark'
17+
} else {
18+
delete document.documentElement.dataset.theme
19+
}
20+
localStorage.setItem(STORAGE_KEY, mode)
21+
}, [mode])
22+
23+
return {
24+
mode,
25+
toggle: () => setMode((current) => (current === 'dark' ? 'system' : 'dark')),
26+
}
27+
}

web/src/components/AppLayout.tsx

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,47 @@
11
import { Link, Outlet } from 'react-router-dom'
22
import { useTraining } from '../app/TrainingContext'
3+
import { useThemeMode } from '../app/useThemeMode'
34

45
export function AppLayout() {
56
const { reset } = useTraining()
7+
const { mode, toggle } = useThemeMode()
68

79
return (
810
<div className="app-shell">
911
<header className="site-header">
1012
<Link className="brand" to="/" onClick={reset}>
1113
Android Interview Trainer
1214
</Link>
13-
<nav aria-label="Основная навигация">
14-
<Link to="/" onClick={reset}>
15-
Темы
16-
</Link>
17-
<Link to="/theory" onClick={reset}>
18-
Вся теория
19-
</Link>
20-
</nav>
15+
<div className="header-actions">
16+
<nav aria-label="Основная навигация">
17+
<Link to="/" onClick={reset}>
18+
Темы
19+
</Link>
20+
<Link to="/theory" onClick={reset}>
21+
Теория
22+
</Link>
23+
</nav>
24+
<button
25+
aria-checked={mode === 'dark'}
26+
aria-label={
27+
mode === 'dark'
28+
? 'Использовать тему устройства'
29+
: 'Включить принудительную тёмную тему'
30+
}
31+
className="theme-toggle"
32+
role="switch"
33+
title={mode === 'dark' ? 'Тёмная тема' : 'Тема устройства'}
34+
type="button"
35+
onClick={toggle}
36+
>
37+
<span className="theme-label">
38+
{mode === 'dark' ? 'Тёмная' : 'Как на устройстве'}
39+
</span>
40+
<span aria-hidden="true" className="switch-track">
41+
<span className="switch-thumb" />
42+
</span>
43+
</button>
44+
</div>
2145
</header>
2246
<main>
2347
<Outlet />

0 commit comments

Comments
 (0)