feat: implement theme settings with light and dark modes

- Added new icons for settings and theme options in Icons.tsx.
- Enhanced local storage functions to return success status in storage.ts.
- Updated styles to support new theme settings in styles.scss.
- Created a new settings page with theme selection options in Settings.tsx.
- Implemented theme management logic in theme.ts, allowing users to choose between system, light, and dark themes.
- Added Playwright tests for theme settings functionality in settings.spec.ts.
- Introduced new SCSS file for settings styles in _settings.scss.
This commit is contained in:
2026-10-07 03:33:50 +03:00
parent 664421662c
commit 81b2c7d675
12 changed files with 375 additions and 62 deletions

115
e2e/settings.spec.ts Normal file
View File

@@ -0,0 +1,115 @@
import { test, expect } from '@playwright/test';
test('theme settings are reachable from navigation and preserve timetable and favorites', async ({ page }, info) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/?demo=1&city=1&stop=101&view=day&date=2026-10-07&route=1');
await expect(page.locator('.hourDeparture')).toHaveCount(8);
await page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true }).click();
await page.getByRole('button', { name: 'Закрыть уведомление', exact: true }).click();
const nav = page.getByRole('navigation', { name: info.project.name === 'mobile' ? 'Навигация на телефоне' : 'Основная навигация' });
await nav.getByRole('link', { name: 'Настройки', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Настройки', exact: true })).toBeVisible();
await expect(nav.getByRole('link', { name: 'Настройки', exact: true })).toHaveAttribute('aria-current', 'page');
await expect(page.getByRole('radio', { name: /^Как на устройстве/ })).toBeChecked();
await page.getByRole('radio', { name: /^Тёмная/ }).check();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(20, 24, 32)');
await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#202735');
await page.reload();
await expect(page.getByRole('radio', { name: /^Тёмная/ })).toBeChecked();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page.screenshot({ path: `test-results/settings-dark-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
for (const width of [320, 760, 761, 820, 1050, 1440]) {
await page.setViewportSize({ width, height: 900 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
}
await page.getByRole('link', { name: 'К расписанию', exact: true }).click();
await expect(page).toHaveURL(/city=1&stop=101&view=day&date=2026-10-07&route=1/);
await expect(page.locator('.hourDeparture')).toHaveCount(8);
await expect(page.getByRole('button', { name: 'Убрать остановку из избранного', exact: true })).toBeVisible();
await expect(page.locator('.boardCard')).toHaveCSS('background-color', 'rgb(28, 35, 48)');
await expect(page.locator('.citySelect')).toHaveCSS('background-color', 'rgb(28, 35, 48)');
await page.screenshot({ path: `test-results/schedule-dark-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
await page.getByRole('link', { name: 'В избранное', exact: true }).click();
await expect(page.locator('.favoriteCard')).toHaveCount(1);
await expect(page.locator('.favoriteRoute').first()).toHaveCSS('background-color', 'rgb(28, 35, 48)');
});
test('system appearance follows live changes while explicit themes override it and support keyboard controls', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
let requests = 0;
await page.route('**/api/data?**', route => { requests++; return route.abort(); });
await page.goto('/settings');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.locator('.themeStatus')).toContainText('тёмная тема устройства');
await page.emulateMedia({ colorScheme: 'light' });
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await expect(page.locator('.themeStatus')).toContainText('светлая тема устройства');
const light = page.getByRole('radio', { name: /^Светлая/ });
await light.check();
await page.emulateMedia({ colorScheme: 'dark' });
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await light.focus(); await page.keyboard.press('ArrowRight');
await expect(page.getByRole('radio', { name: /^Тёмная/ })).toBeChecked();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page.keyboard.press('ArrowRight');
await expect(page.getByRole('radio', { name: /^Как на устройстве/ })).toBeChecked();
await page.emulateMedia({ colorScheme: 'light' });
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
expect(requests).toBe(0);
});
test('theme changes synchronize between tabs and settings remain available after an offline reload', async ({ page, context }) => {
await page.goto('/settings');
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) await new Promise<void>(resolve => navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true }));
});
const other = await context.newPage();
await other.goto('/settings');
await other.getByRole('radio', { name: /^Тёмная/ }).check();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.getByRole('radio', { name: /^Тёмная/ })).toBeChecked();
await context.setOffline(true);
await page.reload();
await expect(page.getByRole('heading', { name: 'Настройки', exact: true })).toBeVisible();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page.getByRole('radio', { name: /^Светлая/ }).check();
await expect(other.locator('html')).toHaveAttribute('data-theme', 'light');
await page.reload();
await expect(page.getByRole('radio', { name: /^Светлая/ })).toBeChecked();
await other.close();
});
test('the saved theme is applied before the application bundle executes', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.addInitScript(() => localStorage.setItem('ridebus:theme', 'dark'));
const bundle = /\/assets\/index-[^/]+\.js$/;
await page.route(bundle, route => route.fulfill({ status: 200, contentType: 'application/javascript', body: 'export {};' }));
await page.goto('/settings');
await expect(page.locator('#root')).toBeEmpty();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark');
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(20, 24, 32)');
await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#202735');
await page.unroute(bundle);
await page.reload();
await expect(page.getByRole('radio', { name: /^Тёмная/ })).toBeChecked();
});
test('blocked preference storage does not prevent changing theme or viewing settings', async ({ page }) => {
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
await page.emulateMedia({ colorScheme: 'light' });
await page.addInitScript(() => {
Storage.prototype.getItem = () => { throw new DOMException('Blocked', 'SecurityError'); };
Storage.prototype.setItem = () => { throw new DOMException('Blocked', 'SecurityError'); };
});
await page.goto('/settings');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.getByRole('radio', { name: /^Тёмная/ }).check();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.locator('.themeStatus')).toContainText('браузер не разрешил сохранить выбор');
await page.reload();
await expect(page.getByRole('radio', { name: /^Как на устройстве/ })).toBeChecked();
expect(errors).toEqual([]);
});

View File

@@ -6,6 +6,21 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#f3f3fa" />
<meta name="color-scheme" content="light dark" />
<script>
// Apply the saved theme before the application and its styles load.
(() => {
let choice = 'system';
try {
const saved = localStorage.getItem('ridebus:theme');
if (saved === 'light' || saved === 'dark') choice = saved;
} catch {}
const theme = choice === 'system' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : choice;
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.querySelector('meta[name="theme-color"]').content = theme === 'dark' ? '#202735' : '#f3f3fa';
})();
</script>
<meta name="description" content="Расписание общественного транспорта RideBus. Найдите остановку и сохраните её в избранное." />
<title>RideBus — расписание в вашем кармане</title>
</head>

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useLiveQuery } from 'dexie-react-hooks';
import { ArrowDown, ArrowRight, ArrowUpRight, Bookmark, BusFront, ChevronDown, MapPin, Search, WifiOff, X } from './components/Icons';
import { ArrowDown, ArrowRight, ArrowUpRight, Bookmark, BusFront, ChevronDown, MapPin, Search, SettingsIcon, WifiOff, X } from './components/Icons';
import { DataError, loadResource } from './lib/api';
import { db, readPreference, savePreference, stopKey } from './lib/storage';
import { Departures } from './components/Departures';
@@ -12,6 +12,8 @@ import { About } from './components/About';
import { QuickTrips } from './components/QuickTrips';
import { NearbyStops } from './components/NearbyStops';
import { RoutePage } from './components/RoutePage';
import { Settings } from './components/Settings';
import { useAppTheme } from './lib/theme';
import { StopList, StopListItem } from './components/StopList';
import { positiveParam } from './lib/links';
import { isTravelDate, localDate } from './lib/time';
@@ -30,6 +32,7 @@ function useOnline() {
export default function App() {
const online = useOnline();
const theme = useAppTheme();
const location = useLocation();
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
@@ -38,7 +41,8 @@ export default function App() {
const favoritesPage = location.pathname === '/favorites';
const aboutPage = location.pathname === '/about';
const routePage = location.pathname === '/route';
const schedulePage = !favoritesPage && !aboutPage && !routePage;
const settingsPage = location.pathname === '/settings';
const schedulePage = !favoritesPage && !aboutPage && !routePage && !settingsPage;
const routeId = positiveParam(params.get('route')) ?? null;
const [view, setView] = useState<'next' | 'day'>(() => params.get('view') === 'day' ? 'day' : 'next');
const [date, setDate] = useState(() => isTravelDate(params.get('date')) ? params.get('date')! : localDate());
@@ -145,13 +149,13 @@ export default function App() {
<a className="skipLink" href="#main">Перейти к расписанию</a>
<header className="header">
<NavLink to={`/${urlSuffix}`} className="brand" aria-label="RideBus, главная"><img src="/logo.svg" width="45" height="45" alt="" /><span>ridebus</span><span className="webTag">web</span></NavLink>
<nav className="desktopNav" aria-label="Основная навигация"><NavLink end to={`/${urlSuffix}`}><BusFront size={18} />Расписание</NavLink><NavLink to={`/favorites${urlSuffix}`}><Bookmark size={18} />Избранное{savedCount > 0 && <span className="navCount" key={savedCount}>{savedCount}</span>}</NavLink></nav>
<nav className="desktopNav" aria-label="Основная навигация"><NavLink end to={`/${urlSuffix}`}><BusFront size={18} />Расписание</NavLink><NavLink to={`/favorites${urlSuffix}`}><Bookmark size={18} />Избранное{savedCount > 0 && <span className="navCount" key={savedCount}>{savedCount}</span>}</NavLink><NavLink className="settingsNavLink" to={`/settings${location.search}`} aria-label="Настройки" title="Настройки"><SettingsIcon size={18} /><span>Настройки</span></NavLink></nav>
<div className="headerActions"><PwaControls /></div>
</header>
<main id="main">
{mode === 'demo' && <div className="demoBanner" role="status"><span>Демонстрация · остановки и отправления — примеры</span><button onClick={() => switchMode(false)}>К реальному расписанию <ArrowRight size={15} /></button></div>}
{!online && <div className="offlineBanner" role="status"><WifiOff size={17} />Вы офлайн. Ранее просмотренные остановки и отправления доступны из сохранённых данных.</div>}
{routePage ? <RoutePage online={online} onNotice={setNotice} /> : aboutPage ? <About urlSuffix={urlSuffix} /> : favoritesPage ? <>
{settingsPage ? <Settings choice={theme.choice} resolved={theme.resolved} saved={theme.saved} onChange={theme.changeChoice} scheduleLink={`/${location.search}`} /> : routePage ? <RoutePage online={online} onNotice={setNotice} /> : aboutPage ? <About urlSuffix={urlSuffix} /> : favoritesPage ? <>
<section className="favoritesHero"><span className="eyebrow">ВСЕГДА ПОД РУКОЙ</span><h1>Любимые <span>остановки.</span></h1><p>Сохраните те, с которых начинается ваш день.</p></section>
<QuickTrips trips={trips} online={online} onNotice={setNotice} />
{favorites.length === 0 && trips.length === 0 ? <section className="favoritesEmpty"><span className="emptyIcon"><Bookmark size={32} /></span><h2>Здесь будут ваши остановки</h2><p>Нажмите на закладку рядом с названием остановки<br />или сохраните поездку с нужным маршрутом.</p><NavLink className="primaryButton" to={`/${urlSuffix}`}>Найти остановку <ArrowRight size={18} /></NavLink></section>
@@ -182,8 +186,8 @@ export default function App() {
<section className="benefitStrip"><span className="benefitIcon"><Bookmark size={21} /></span><div><strong>Ваша остановка — в один тап</strong><p>Добавьте её в избранное, чтобы в следующий раз найти быстрее.</p></div><NavLink to={`/favorites${urlSuffix}`}>В избранное <ArrowRight size={18} /></NavLink></section>
</>}
</main>
<footer className="footer"><span>ridebus<small>Расписание в вашем кармане</small></span><div><NavLink to={`/about${urlSuffix}`}>О проекте <ArrowRight size={18} /></NavLink><a href="https://ridebus.by" target="_blank" rel="noreferrer">Сайт RideBus <ArrowUpRight size={20} /></a>{(import.meta.env.DEV || mode === 'demo') && <button onClick={() => switchMode(mode !== 'demo')}>{mode === 'demo' ? 'Реальное расписание' : 'Посмотреть пример'}</button>}</div></footer>
<nav className="mobileNav" aria-label="Навигация на телефоне"><NavLink end to={`/${urlSuffix}`}><BusFront size={28} /><span>Расписание</span></NavLink><NavLink to={`/favorites${urlSuffix}`} aria-label={`Избранное${savedCount ? `, сохранено: ${savedCount}` : ''}`}><span className="navIcon"><Bookmark size={28} />{savedCount > 0 && <span className="mobileBadge" key={savedCount} aria-hidden="true">{savedCount > 99 ? '99+' : savedCount}</span>}</span><span>Избранное</span></NavLink></nav>
<footer className="footer"><span>ridebus<small>Расписание в вашем кармане</small></span><div><NavLink to={`/settings${location.search}`}>Настройки <SettingsIcon size={18} /></NavLink><NavLink to={`/about${urlSuffix}`}>О проекте <ArrowRight size={18} /></NavLink><a href="https://ridebus.by" target="_blank" rel="noreferrer">Сайт RideBus <ArrowUpRight size={20} /></a>{(import.meta.env.DEV || mode === 'demo') && <button onClick={() => switchMode(mode !== 'demo')}>{mode === 'demo' ? 'Реальное расписание' : 'Посмотреть пример'}</button>}</div></footer>
<nav className="mobileNav" aria-label="Навигация на телефоне"><NavLink end to={`/${urlSuffix}`}><BusFront size={28} /><span>Расписание</span></NavLink><NavLink to={`/favorites${urlSuffix}`} aria-label={`Избранное${savedCount ? `, сохранено: ${savedCount}` : ''}`}><span className="navIcon"><Bookmark size={28} />{savedCount > 0 && <span className="mobileBadge" key={savedCount} aria-hidden="true">{savedCount > 99 ? '99+' : savedCount}</span>}</span><span>Избранное</span></NavLink><NavLink to={`/settings${location.search}`}><SettingsIcon size={28} /><span>Настройки</span></NavLink></nav>
{notice && <div className="toast" role="status">{notice}<button aria-label="Закрыть уведомление" onClick={() => setNotice('')}><X size={16} /></button></div>}
</div>;
}

View File

@@ -1,12 +1,12 @@
@mixin components {
.appScrollArea { scrollbar-width: thin; scrollbar-color: #c7d1e8 transparent; }
.appScrollArea { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; }
.appScrollArea::-webkit-scrollbar { width: 6px; height: 6px; }
.appScrollArea::-webkit-scrollbar-track { background: transparent; }
.appScrollArea::-webkit-scrollbar-thumb { background: #c7d1e8; border-radius: 8px; }
.appScrollArea::-webkit-scrollbar-thumb:hover { background: #aebedc; }
.appScrollArea::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 8px; }
.appScrollArea::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); }
.stopDistance { flex-shrink: 0; white-space: nowrap; font-size: 14px; color: var(--blue-muted); }
.boardCard { scroll-margin-top: 24px; }
.boardCard:focus-visible, .tripsCarouselTrack:focus-visible { outline: 3px solid #638ce1; outline-offset: 4px; }
.boardCard:focus-visible, .tripsCarouselTrack:focus-visible { outline: 3px solid var(--focus); outline-offset: 4px; }
.departureEntry { display: flex; gap: 8px; align-items: stretch; min-width: 0; }
.departureEntry .departureRow { flex: 1; min-width: 0; }
.departureEntry > .shareDepartureButton { flex-shrink: 0; align-self: flex-start; margin-top: 21px; background: var(--lavender); color: var(--blue); }
@@ -33,16 +33,16 @@
.quickTripHeading { display: flex; align-items: center; gap: 12px; }
.quickTripHeading h3 { font-size: 23px; line-height: 1.4; overflow-wrap: anywhere; }
.quickTripHeading > button { margin-left: auto; flex-shrink: 0; }
.tripSymbol { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 14px; background: #ffffff70; flex-shrink: 0; }
.tripSymbol { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 14px; background: var(--green-overlay); flex-shrink: 0; }
.tripPlace { margin-top: 15px; font-size: 16px; font-weight: 750; overflow-wrap: anywhere; }
.tripDirection { margin-top: 5px; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
.tripNext { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 20px; }
.tripNext .routeNumber { background: #ffffff90; color: var(--green-ink); min-width: 44px; height: 44px; }
.tripNext .routeNumber { background: var(--green-overlay-strong); color: var(--green-ink); min-width: 44px; height: 44px; }
.tripNext > strong { font-size: 29px; font-variant-numeric: tabular-nums; }
.tripNext > span:last-child { font-size: 14px; }
.tripFollowing, .tripSaved { margin-top: 10px; font-size: 14px; line-height: 1.7; }
.tripSaved { padding: 10px 12px; border-radius: 12px; background: #ffffff65; }
.tripActions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 20px; padding-top: 12px; border-top: 1px solid #bdd4b6; }
.tripSaved { padding: 10px 12px; border-radius: 12px; background: var(--green-overlay-subtle); }
.tripActions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--green-line); }
.tripActions > a { display: flex; align-items: center; gap: 8px; min-height: 44px; font-size: 15px; font-weight: 800; }
.tripActions .textButton { color: inherit; min-height: 44px; }
.carouselControls { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 8px; }
@@ -51,18 +51,18 @@
.carouselControls .iconButton { background: var(--lavender); color: var(--blue); }
.carouselPrevious > .vkuiIcon { transform: rotate(180deg); }
.appDialog { border: 0; padding: 0; margin: auto; width: min(460px, calc(100% - 32px)); max-height: calc(100dvh - 32px); overflow: auto; border-radius: 28px; background: white; color: var(--ink); box-shadow: 0 20px 80px #14274b35; }
.appDialog { border: 0; padding: 0; margin: auto; width: min(460px, calc(100% - 32px)); max-height: calc(100dvh - 32px); overflow: auto; border-radius: 28px; background: var(--surface); color: var(--ink); box-shadow: 0 20px 80px #14274b35; }
.appDialog::backdrop { background: #14274b70; backdrop-filter: blur(5px); }
.dialogBody { padding: 26px; }
.dialogHeading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 16px; }
.dialogHeading h2 { font-size: 25px; line-height: 1.4; }
.dialogHeading .iconButton { margin-top: -4px; margin-right: -8px; }
.dialogDescription { color: var(--muted); font-size: 15px; line-height: 1.8; }
.tripForm input, .tripForm select, .shareLinkForm input { width: 100%; min-width: 0; border: 1px solid #c3d3f5; border-radius: 13px; min-height: 48px; padding: 11px 12px; font-size: 16px; background: var(--lavender); color: var(--blue); }
.tripForm input, .tripForm select, .shareLinkForm input { width: 100%; min-width: 0; border: 1px solid var(--control-border); border-radius: 13px; min-height: 48px; padding: 11px 12px; font-size: 16px; background: var(--lavender); color: var(--blue); }
.namePresets { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.namePresets .secondaryButton { padding: 10px 14px; min-height: 44px; }
.tripForm > .primaryButton { margin-top: 24px; min-height: 48px; }
.formError { margin-top: 16px; font-size: 15px; color: #a34239; }
.formError { margin-top: 16px; font-size: 15px; color: var(--error); }
.shareLinkForm p { font-size: 14px; color: var(--muted); line-height: 1.8; margin: 12px 0 20px; }
.routeHero .shareDepartureButton { margin-top: 16px; min-height: 44px; max-width: 100%; }
@@ -76,15 +76,15 @@
.hourDeparture { display: flex; justify-content: center; align-items: center; gap: 7px; min-height: 44px; padding: 8px 7px; border: 1px solid transparent; border-radius: 12px; background: var(--lavender); color: var(--blue); position: relative; }
.hourDeparture strong { font-size: 16px; font-variant-numeric: tabular-nums; }
.hourRoute { font-size: 13px; font-weight: 800; }
.hourDeparture.pastDeparture { background: #f4f4f1; color: #69758c; }
.hourDeparture.nextDeparture { background: var(--green); border-color: #9db994; color: var(--green-ink); }
.hourDeparture.pastDeparture { background: var(--past-bg); color: var(--past-ink); }
.hourDeparture.nextDeparture { background: var(--green); border-color: var(--green-line); color: var(--green-ink); }
.hourDeparture:hover { border-color: var(--blue-muted); }
.hasTripNote { font-size: 14px; position: absolute; right: 4px; top: -2px; }
.serviceAlerts { padding: 18px; border-radius: 18px; background: #fff7e9; color: #735421; margin: 18px 0; min-width: 0; }
.serviceAlerts { padding: 18px; border-radius: 18px; background: var(--warning-bg); color: var(--warning-ink); margin: 18px 0; min-width: 0; }
.alertsHeading { display: flex; align-items: center; gap: 8px; font-size: 15px; }
.serviceAlert { padding-top: 14px; }
.serviceAlert + .serviceAlert { margin-top: 14px; border-top: 1px solid #eadcc6; }
.serviceAlert + .serviceAlert { margin-top: 14px; border-top: 1px solid var(--warning-line); }
.alertRoutes { display: block; font-size: 14px; font-weight: 800; margin-bottom: 5px; }
.serviceAlert h3 { font-size: 16px; line-height: 1.5; overflow-wrap: anywhere; }
.serviceAlert p { margin: 7px 0 10px; font-size: 15px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; }
@@ -109,14 +109,14 @@
.routeBody { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; align-items: start; }
.routeBody:has(> .serviceAlerts) { grid-template-columns: minmax(0, 1fr) 320px; }
.routeBody > .serviceAlerts { margin: 0; }
.routeTimelineCard { padding: 28px; border: 1px solid var(--line); border-radius: 32px; background: white; min-width: 0; }
.routeTimelineCard { padding: 28px; border: 1px solid var(--line); border-radius: 32px; background: var(--surface); min-width: 0; }
.routeTimelineHeading { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.routeTimelineHeading h2 { font-size: 24px; }
.routeTimelineHeading > span { font-size: 14px; color: var(--blue); background: var(--lavender); padding: 4px 9px; border-radius: 20px; }
.journeyTimeline { list-style: none; margin: 20px 0; padding: 0; }
.journeyTimeline li { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 12px; min-height: 72px; position: relative; }
.journeyTimeline li:not(:last-child)::before { content: ''; position: absolute; top: 50%; bottom: -50%; left: 26px; width: 2px; background: #dce0ee; }
.journeyPoint { position: relative; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; color: var(--blue); background: var(--lavender); border: 2px solid white; font-size: 13px; font-weight: 800; }
.journeyTimeline li:not(:last-child)::before { content: ''; position: absolute; top: 50%; bottom: -50%; left: 26px; width: 2px; background: var(--subtle-line); }
.journeyPoint { position: relative; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; color: var(--blue); background: var(--lavender); border: 2px solid var(--surface); font-size: 13px; font-weight: 800; }
.journeyTimeline a { display: flex; flex-direction: column; justify-content: center; gap: 4px; min-height: 44px; }
.journeyTimeline a strong { font-size: 16px; line-height: 1.5; overflow-wrap: anywhere; }
.journeyTimeline a span { font-size: 13px; color: var(--blue-muted); }
@@ -125,7 +125,7 @@
.journeyTime strong { font-size: 21px; font-variant-numeric: tabular-nums; }
.journeyTime small { display: block; max-width: 85px; font-size: 13px; color: var(--muted); line-height: 1.6; }
.journeyTimeline .selectedJourneyStop { border-radius: 15px; background: var(--blue-soft); }
.selectedJourneyStop .journeyPoint { background: var(--blue); color: white; }
.selectedJourneyStop .journeyPoint { background: var(--blue); color: var(--on-accent); }
.journeyTimeline .passedJourneyStop:not(.selectedJourneyStop) { color: var(--muted); }
@media (max-width: 760px) {

View File

@@ -5,7 +5,7 @@
--motion-ease: cubic-bezier(.2, .75, .25, 1);
}
.heroCopy, .favoritesHero, .aboutIntro,
.heroCopy, .favoritesHero, .aboutIntro, .settingsIntro, .settingsCard,
.stopsCard, .boardCard, .favoritesEmpty,
.favoriteCard, .quickTripCard, .routeHero, .routeTimelineCard,
.aboutGrid > article, .benefitStrip {
@@ -25,7 +25,7 @@
.primaryButton, .secondaryButton, .installButton, .iconButton,
.textButton, .showMore, .refreshButton, .stopItem,
.directionChips button, .routeFilters button, .scheduleViews button,
.desktopNav a, .mobileNav a, .hourDeparture, .nearbyResults button {
.desktopNav a, .mobileNav a, .hourDeparture, .nearbyResults button, .themeOption {
transition: background-color var(--motion-fast) ease, color var(--motion-fast) ease,
border-color var(--motion-fast) ease, box-shadow var(--motion-fast) ease,
transform var(--motion-fast) var(--motion-ease);

36
src/_settings.scss Normal file
View File

@@ -0,0 +1,36 @@
@mixin components {
.settingsPage { padding: 32px 0 48px; }
.settingsIntro { margin: 24px 0 32px; }
.settingsIntro h1 { font-size: clamp(36px, 4.6vw, 58px); line-height: 1.2; letter-spacing: -.5px; margin-bottom: 14px; }
.settingsIntro p { font-size: 16px; line-height: 1.8; color: var(--muted); }
.settingsCard { padding: 32px; border-radius: var(--card-radius); background: var(--surface); border: 1px solid var(--line); }
.themeFieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.themeFieldset legend { padding: 0; font-size: 25px; font-weight: 750; letter-spacing: -.3px; }
.themeOptions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 24px; }
.themeOption { position: relative; display: flex; flex-direction: column; gap: 10px; padding: 22px; border: 2px solid transparent; border-radius: 22px; background: var(--lavender); cursor: pointer; min-width: 0; }
.themeOption:hover { border-color: var(--control-border); }
.themeOption.selected { border-color: var(--blue); background: var(--blue-soft); }
.themeOption:has(input:focus-visible) { outline: 3px solid var(--focus); outline-offset: 4px; }
.themeRadio { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.themeOptionTop { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; gap: 16px; }
.themeSymbol { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 15px; background: var(--surface); color: var(--blue); }
.themeIndicator { display: grid; place-items: center; width: 22px; height: 22px; border: 2px solid var(--blue-muted); border-radius: 50%; color: var(--on-accent); flex-shrink: 0; }
.selected .themeIndicator { border-color: var(--blue); background: var(--blue); }
.themeOption > strong { font-size: 19px; line-height: 1.5; }
.themeDescription { font-size: 15px; line-height: 1.7; color: var(--blue-muted); }
.themeStatus { margin-top: 24px; color: var(--muted); font-size: 15px; line-height: 1.8; }
@media (min-width: 761px) and (max-width: 1050px) {
.settingsNavLink > span { display: none; }
}
@media (max-width: 760px) {
.settingsPage { padding: 24px 0 32px; }
.settingsIntro { margin: 20px 0 26px; }
.settingsCard { padding: 22px 18px; border-radius: 26px; }
.themeFieldset legend { font-size: 23px; }
.themeOptions { grid-template-columns: 1fr; gap: 12px; margin-top: 20px; }
.themeOption { padding: 18px; border-radius: 18px; }
.mobileNav { gap: 4px; padding-left: 10px; padding-right: 10px; }
.mobileNav a { padding: 0 6px; }
}
}

View File

@@ -10,5 +10,72 @@
--lavender: #f3f3fa;
--green: #ddf9d0;
--green-ink: #254817;
--surface: #fff;
--nav-surface: #eeeee9;
--nav-glass: #fffffff0;
--subtle-line: #dce0ee;
--control-border: #c3d3f5;
--focus: #638ce1;
--focus-soft: #aec6ff;
--on-accent: #fff;
--hover-surface: #ffffff90;
--green-overlay: #ffffff70;
--green-overlay-strong: #ffffff90;
--green-overlay-subtle: #ffffff65;
--green-line: #bdd4b6;
--warning-bg: #fff7e9;
--warning-ink: #735421;
--warning-line: #eadcc6;
--warning-badge-bg: #fff1d8;
--warning-badge-ink: #876022;
--error: #a34239;
--scrollbar: #c7d1e8;
--scrollbar-hover: #aebedc;
--past-bg: #f4f4f1;
--past-ink: #69758c;
--muted-icon: #8390a9;
--benefit-bg: #eeede6;
--note-dot: #6e955f;
--card-radius: 32px;
color-scheme: light;
}
:root[data-theme='dark'] {
--page: #141820;
--ink: #f0f1f5;
--muted: #abb3c2;
--line: #343d4c;
--blue: #b5cbfc;
--blue-muted: #b8c9ed;
--blue-soft: #2d3b5b;
--lavender: #202735;
--green: #243c2b;
--green-ink: #c6edb8;
--surface: #1c2330;
--nav-surface: #252d3b;
--nav-glass: #1c2330f0;
--subtle-line: #3b465c;
--control-border: #4b5e83;
--focus: #adc7ff;
--focus-soft: #3c5079;
--on-accent: #18243e;
--hover-surface: #ffffff0c;
--green-overlay: #ffffff0b;
--green-overlay-strong: #ffffff14;
--green-overlay-subtle: #ffffff0a;
--green-line: #49644d;
--warning-bg: #3a3020;
--warning-ink: #f1d49e;
--warning-line: #5b4930;
--warning-badge-bg: #3e311e;
--warning-badge-ink: #f1d49e;
--error: #ffaaa2;
--scrollbar: #56637a;
--scrollbar-hover: #728199;
--past-bg: #202630;
--past-ink: #a2aabd;
--muted-icon: #98a8c3;
--benefit-bg: #242d2a;
--note-dot: #a7c792;
color-scheme: dark;
}

View File

@@ -5,6 +5,7 @@ import {
Icon24Bookmark, Icon24BookmarkOutline, Icon24Cancel,
Icon24ChevronDown, Icon24LocationOutline, Icon24SearchOutline,
Icon24Add, Icon24HomeOutline, Icon24WorkOutline, Icon24WarningTriangleOutline,
Icon28SettingsOutline, Icon28MoonOutline, Icon28SunOutline, Icon28ComputerSmartphoneOutline,
} from '@vkontakte/icons';
import type { ComponentType } from 'react';
@@ -35,6 +36,10 @@ export const Home = adapt(Icon24HomeOutline);
export const Work = adapt(Icon24WorkOutline);
export const Warning = adapt(Icon24WarningTriangleOutline);
export const Share = adapt(Icon20ShareOutline);
export const SettingsIcon = adapt(Icon28SettingsOutline);
export const Moon = adapt(Icon28MoonOutline);
export const Sun = adapt(Icon28SunOutline);
export const Devices = adapt(Icon28ComputerSmartphoneOutline);
export function Bookmark({ size = 24, className, fill }: IconProps & { fill?: string }) {
const Icon = fill === 'currentColor' ? Icon24Bookmark : Icon24BookmarkOutline;

View File

@@ -0,0 +1,28 @@
import { NavLink } from 'react-router-dom';
import { ArrowRight, Check, Devices, Moon, Sun } from './Icons';
import type { ThemeChoice } from '../lib/theme';
const options = [
{ value: 'system', title: 'Как на устройстве', description: 'Меняется вместе с темой телефона или компьютера.', Icon: Devices },
{ value: 'light', title: 'Светлая', description: 'Светлый фон и знакомые пастельные цвета.', Icon: Sun },
{ value: 'dark', title: 'Тёмная', description: 'Мягкие тёмные оттенки для вечера.', Icon: Moon },
] as const;
export function Settings({ choice, resolved, saved, onChange, scheduleLink }: {
choice: ThemeChoice; resolved: 'light' | 'dark'; saved: boolean; onChange: (choice: ThemeChoice) => void; scheduleLink: string;
}) {
return <section className="settingsPage" aria-labelledby="settings-title">
<NavLink className="backToSchedule" to={scheduleLink}><ArrowRight size={18} />К расписанию</NavLink>
<div className="settingsIntro"><h1 id="settings-title">Настройки</h1><p>Оформите RideBus так, как вам удобно.</p></div>
<div className="settingsCard">
<fieldset className="themeFieldset"><legend>Тема приложения</legend>
<div className="themeOptions">{options.map(({ value, title, description, Icon }) => <label key={value} className={`themeOption ${choice === value ? 'selected' : ''}`}>
<input className="themeRadio" type="radio" name="theme" value={value} checked={choice === value} onChange={() => onChange(value)} />
<span className="themeOptionTop" aria-hidden="true"><span className="themeSymbol"><Icon size={26} /></span><span className="themeIndicator">{choice === value && <Check size={16} />}</span></span>
<strong>{title}</strong><span className="themeDescription">{description}</span>
</label>)}</div>
</fieldset>
<p className="themeStatus" role="status">{!saved ? 'Тема изменена, но браузер не разрешил сохранить выбор. Он будет действовать, пока эта вкладка открыта.' : choice === 'system' ? `Сейчас используется ${resolved === 'dark' ? 'тёмная' : 'светлая'} тема устройства.` : 'Выбор сохранён в этом браузере.'}</p>
</div>
</section>;
}

View File

@@ -15,5 +15,5 @@ export function readPreference(key: string) {
try { return localStorage.getItem(`ridebus:${key}`); } catch { return null; }
}
export function savePreference(key: string, value: string) {
try { localStorage.setItem(`ridebus:${key}`, value); } catch { /* browsing still works without storage */ }
try { localStorage.setItem(`ridebus:${key}`, value); return true; } catch { return false; }
}

41
src/lib/theme.ts Normal file
View File

@@ -0,0 +1,41 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { readPreference, savePreference } from './storage';
export type ThemeChoice = 'system' | 'light' | 'dark';
function themeChoice(value: string | null): ThemeChoice {
return value === 'light' || value === 'dark' ? value : 'system';
}
export function useAppTheme() {
const [choice, setChoice] = useState<ThemeChoice>(() => themeChoice(readPreference('theme')));
const [systemDark, setSystemDark] = useState(() => matchMedia('(prefers-color-scheme: dark)').matches);
const [saved, setSaved] = useState(true);
const resolved = choice === 'system' ? systemDark ? 'dark' : 'light' : choice;
useLayoutEffect(() => {
document.documentElement.dataset.theme = resolved;
document.documentElement.style.colorScheme = resolved;
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', resolved === 'dark' ? '#202735' : '#f3f3fa');
}, [resolved]);
useEffect(() => {
const media = matchMedia('(prefers-color-scheme: dark)');
const updateSystem = () => setSystemDark(media.matches);
const updatePreference = (event: StorageEvent) => {
if (event.key === 'ridebus:theme' || event.key === null) {
setChoice(themeChoice(event.newValue)); setSaved(true);
}
};
updateSystem();
media.addEventListener('change', updateSystem);
window.addEventListener('storage', updatePreference);
return () => { media.removeEventListener('change', updateSystem); window.removeEventListener('storage', updatePreference); };
}, []);
function changeChoice(next: ThemeChoice) {
setChoice(next); setSaved(savePreference('theme', next));
}
return { choice, resolved, saved, changeChoice };
}

View File

@@ -1,6 +1,7 @@
@use './tokens';
@use './motion';
@use './features';
@use './settings';
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Manrope Variable', 'Manrope', sans-serif; color: var(--ink); background: var(--page); font-size: 16px; line-height: 1.5; font-weight: 500; -webkit-font-smoothing: antialiased; }
@@ -10,7 +11,7 @@ button { cursor: pointer; }
button:disabled { cursor: default; opacity: .5; }
a { color: inherit; text-decoration: none; }
button, a { touch-action: manipulation; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid #638ce1; outline-offset: 4px; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid var(--focus); outline-offset: 4px; }
button { color: inherit; }
h1, h2, h3, p { margin: 0; }
svg, .vkuiIcon { flex-shrink: 0; }
@@ -20,12 +21,12 @@ svg, .vkuiIcon { flex-shrink: 0; }
.brand > span:first-of-type { font-size: 28px; letter-spacing: -.5px; font-weight: 800; }
.brandDot { color: var(--blue); }
.webTag { font-size: 14px; letter-spacing: .5px; background: var(--lavender); color: var(--blue); padding: 5px 8px; border-radius: 7px; font-weight: 750; }
.desktopNav { display: flex; gap: 6px; background: #eeeee9; border-radius: 40px; padding: 5px; }
.desktopNav { display: flex; gap: 6px; background: var(--nav-surface); border-radius: 40px; padding: 5px; }
.desktopNav a { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 750; padding: 11px 17px; border-radius: 30px; color: var(--muted); }
.desktopNav a.active { background: white; color: var(--blue); box-shadow: 0 2px 6px #172d6010; }
.desktopNav a.active { background: var(--surface); color: var(--blue); box-shadow: 0 2px 6px #172d6010; }
.navCount { background: var(--lavender); border-radius: 20px; padding: 1px 7px; font-size: 13px; font-weight: 750; }
.headerActions { display: flex; gap: 19px; align-items: center; }
.noteDot { width: 6px; height: 6px; border-radius: 50%; background: #6e955f; }
.noteDot { width: 6px; height: 6px; border-radius: 50%; background: var(--note-dot); }
.installButton { display: flex; align-items: center; gap: 8px; border: 0; border-radius: 30px; padding: 11px 17px; color: var(--blue); background: var(--blue-soft); font-size: 15px; font-weight: 750; }
.hero { display: flex; gap: 45px; align-items: center; justify-content: space-between; padding: 56px 0 42px; }
.eyebrow { display: flex; align-items: center; gap: 8px; color: var(--blue); font-size: 14px; letter-spacing: .2px; font-weight: 800; }
@@ -45,27 +46,27 @@ svg, .vkuiIcon { flex-shrink: 0; }
.sectionHeading h2 { font-size: 25px; font-weight: 800; letter-spacing: -.5px; }
.sectionDescription { margin: 6px 0 25px; font-size: 14px; color: var(--blue-muted); }
.fieldLabel { display: block; font-size: 14px; font-weight: 750; margin: 16px 0 8px; }
.citySelect, .searchField { display: flex; align-items: center; gap: 9px; border-radius: 14px; padding: 0 14px; height: 49px; background: white; }
.citySelect, .searchField { display: flex; align-items: center; gap: 9px; border-radius: 14px; padding: 0 14px; height: 49px; background: var(--surface); }
.citySelect select { appearance: none; width: 100%; border: 0; background: transparent; font-size: 16px; color: var(--blue); font-weight: 700; cursor: pointer; padding: 8px 0; }
.searchField input { min-width: 0; width: 100%; border: 0; background: transparent; font-size: 16px; height: 100%; color: var(--blue); }
.searchField input::placeholder { color: #69758e; font-size: 16px; }
.citySelect:focus-within, .searchField:focus-within { box-shadow: 0 0 0 2px #aec6ff; }
.searchField input::placeholder { color: var(--muted); font-size: 16px; }
.citySelect:focus-within, .searchField:focus-within { box-shadow: 0 0 0 2px var(--focus-soft); }
.citySelect select:focus-visible, .searchField input:focus-visible { outline: 0; }
.stopsList { margin: 18px -8px 8px; padding: 0 8px; max-height: 398px; overflow-y: auto; }
.stopItem { width: 100%; min-height: 54px; text-align: left; display: flex; gap: 12px; align-items: center; padding: 14px 16px; border: 1px solid transparent; border-radius: 15px; background: transparent; margin-bottom: 4px; transition: background .18s; }
.stopItem:hover { background: #ffffff90; }
.stopItem.activeStop { background: white; border-color: #c3d3f5; box-shadow: 0 3px 8px #2c467809; }
.stopItem:hover { background: var(--hover-surface); }
.stopItem.activeStop { background: var(--surface); border-color: var(--control-border); box-shadow: 0 3px 8px #2c467809; }
.stopCopy { flex: 1; min-width: 0; }
.stopCopy strong { font-size: 16px; line-height: 1.4; font-weight: 750; overflow-wrap: anywhere; }
.stopEnd { margin-left: auto; opacity: .6; }
.stopsFooter { border-top: 1px solid #dce0ee; display: flex; justify-content: space-between; font-size: 13px; color: var(--blue-muted); padding: 14px 0 18px; }
.stopsFooter { border-top: 1px solid var(--subtle-line); display: flex; justify-content: space-between; font-size: 13px; color: var(--blue-muted); padding: 14px 0 18px; }
.stopsFooter span { display: flex; align-items: center; gap: 4px; }
.boardCard { background: white; border: 1px solid #eeeee9; border-radius: var(--card-radius); min-height: 590px; padding: 29px 30px 20px; min-width: 0; }
.boardCard { background: var(--surface); border: 1px solid var(--line); border-radius: var(--card-radius); min-height: 590px; padding: 29px 30px 20px; min-width: 0; }
.boardHeading { display: flex; align-items: center; justify-content: space-between; min-height: 44px; gap: 10px; }
.boardHeading .eyebrow { font-size: 15px; letter-spacing: 0; font-weight: 650; }
.iconButton { background: transparent; border: 0; min-width: 44px; min-height: 44px; padding: 10px; border-radius: 50%; display: grid; place-items: center; }
.iconButton:hover { background: var(--lavender); }
.favoriteToggle { color: #8390a9; background: var(--lavender); }
.favoriteToggle { color: var(--muted-icon); background: var(--lavender); }
.favoriteToggle.isFavorite { color: var(--blue); background: var(--blue-soft); }
.boardCard h2 { font-size: 28px; line-height: 1.25; letter-spacing: -.5px; margin: 11px 0 7px; overflow-wrap: anywhere; }
.boardDirection { font-size: 15px; color: var(--muted); line-height: 1.6; }
@@ -73,24 +74,24 @@ svg, .vkuiIcon { flex-shrink: 0; }
.directionLabel { display: block; color: var(--blue-muted); font-size: 14px; font-weight: 650; margin-bottom: 10px; }
.directionChips { display: flex; flex-wrap: wrap; gap: 8px; }
.directionChips button { min-height: 44px; padding: 10px 16px; border: 0; border-radius: 24px; color: var(--blue); background: var(--blue-soft); font-size: 15px; font-weight: 650; line-height: 1.5; text-align: left; overflow-wrap: anywhere; }
.directionChips button.selected { background: var(--blue); color: white; }
.directionChips button:hover { background: #aec6ff; }
.directionChips button.selected { background: var(--blue); color: var(--on-accent); }
.directionChips button:hover { background: var(--focus-soft); }
.directionChips button.selected:hover { background: var(--blue-muted); }
.navIcon { position: relative; display: inline-flex; width: 28px; height: 28px; }
.mobileBadge { position: absolute; top: -8px; right: -13px; display: grid; place-items: center; min-width: 22px; height: 22px; padding: 0 5px; border: 2px solid white; border-radius: 20px; color: white; background: var(--blue); font-size: 12px; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; }
.mobileBadge { position: absolute; top: -8px; right: -13px; display: grid; place-items: center; min-width: 22px; height: 22px; padding: 0 5px; border: 2px solid var(--surface); border-radius: 20px; color: var(--on-accent); background: var(--blue); font-size: 12px; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; }
.boardMeta { margin: 19px 0 21px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: 14px; color: var(--muted); }
.scheduleBadge { display: flex; align-items: center; gap: 5px; padding: 6px 10px; border-radius: 30px; background: var(--green); color: var(--green-ink); font-size: 13px; font-weight: 750; }
.savedBadge { background: #fff1d8; color: #876022; }
.savedBadge { background: var(--warning-badge-bg); color: var(--warning-badge-ink); }
.routeFilters { display: flex; flex-wrap: wrap; gap: 7px; padding-bottom: 17px; border-bottom: 1px solid var(--line); }
.routeFilters button { border: 0; border-radius: 20px; min-height: 40px; min-width: 40px; background: var(--lavender); color: var(--blue); padding: 9px 13px; font-size: 15px; font-weight: 750; }
.routeFilters button.selected { background: var(--blue); color: white; }
.routeFilters button.selected { background: var(--blue); color: var(--on-accent); }
.scheduleControls { margin-top: 22px; }
.scheduleViews { display: inline-flex; gap: 4px; padding: 4px; background: var(--lavender); border-radius: 16px; }
.scheduleViews button { border: 0; background: transparent; padding: 10px 16px; min-height: 44px; border-radius: 12px; font-size: 15px; font-weight: 750; color: var(--blue-muted); }
.scheduleViews button.selected { background: white; color: var(--blue); box-shadow: 0 2px 6px #172d6010; }
.scheduleViews button.selected { background: var(--surface); color: var(--blue); box-shadow: 0 2px 6px #172d6010; }
.scheduleDate { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; margin-top: 16px; }
.scheduleDate label { width: 100%; font-size: 14px; font-weight: 700; color: var(--muted); }
.scheduleDate input { border: 1px solid #c3d3f5; border-radius: 12px; padding: 10px 12px; min-width: 0; max-width: 100%; min-height: 44px; color: var(--blue); background: var(--lavender); font-size: 16px; }
.scheduleDate input { border: 1px solid var(--control-border); border-radius: 12px; padding: 10px 12px; min-width: 0; max-width: 100%; min-height: 44px; color: var(--blue); background: var(--lavender); font-size: 16px; }
.timetableSummary { margin: -5px 0 18px; color: var(--muted); font-size: 14px; line-height: 1.7; }
.departureList { list-style: none; margin: 0; padding: 0; }
.departureRow { display: flex; gap: 15px; align-items: center; min-height: 94px; padding: 17px 0; }
@@ -115,13 +116,13 @@ svg, .vkuiIcon { flex-shrink: 0; }
.listMessage { display: flex; align-items: center; flex-direction: column; gap: 12px; text-align: center; padding: 28px 8px; font-size: 15px; line-height: 1.6; }
.listMessage h3 { font-size: 16px; }
.listMessage p { color: var(--blue-muted); }
.primaryButton, .secondaryButton { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 0; border-radius: 30px; padding: 13px 20px; font-size: 15px; font-weight: 750; background: var(--blue); color: white; }
.primaryButton, .secondaryButton { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 0; border-radius: 30px; padding: 13px 20px; font-size: 15px; font-weight: 750; background: var(--blue); color: var(--on-accent); }
.secondaryButton { background: var(--blue-soft); color: var(--blue); }
.textButton { border: 0; background: transparent; color: var(--blue); display: flex; align-items: center; gap: 5px; padding: 8px; font-size: 14px; font-weight: 750; }
.showMore { background: var(--lavender); border: 0; border-radius: 13px; padding: 11px; width: 100%; font-weight: 750; font-size: 14px; color: var(--blue); }
.savedNotice, .partialNotice { margin-top: 12px; padding: 12px 14px; border-radius: 13px; background: #fff7e9; color: #866529; font-size: 14px; line-height: 1.6; }
.benefitStrip { display: flex; align-items: center; gap: 18px; padding: 22px 26px; background: #eeede6; border-radius: 22px; margin: 24px 0 42px; }
.benefitIcon { width: 43px; height: 43px; border-radius: 15px; display: grid; place-items: center; background: #faf9f6; color: var(--blue); }
.savedNotice, .partialNotice { margin-top: 12px; padding: 12px 14px; border-radius: 13px; background: var(--warning-bg); color: var(--warning-ink); font-size: 14px; line-height: 1.6; }
.benefitStrip { display: flex; align-items: center; gap: 18px; padding: 22px 26px; background: var(--benefit-bg); border-radius: 22px; margin: 24px 0 42px; }
.benefitIcon { width: 43px; height: 43px; border-radius: 15px; display: grid; place-items: center; background: var(--page); color: var(--blue); }
.benefitStrip strong { font-size: 15px; font-weight: 800; }
.benefitStrip p { margin-top: 4px; font-size: 14px; color: var(--muted); line-height: 1.6; }
.benefitStrip > a { display: flex; align-items: center; gap: 9px; margin-left: auto; font-size: 14px; white-space: nowrap; font-weight: 750; color: var(--blue); }
@@ -133,11 +134,11 @@ svg, .vkuiIcon { flex-shrink: 0; }
.footer button { border: 0; background: transparent; font-size: inherit; color: inherit; padding: 7px 0; }
.footer > small { font-size: 13px; color: var(--muted); }
.mobileNav { display: none; }
.demoBanner, .offlineBanner { display: flex; align-items: center; justify-content: space-between; gap: 12px; background: #fff1d8; color: #75561f; font-size: 14px; line-height: 1.5; border-radius: 15px; padding: 13px 18px; margin-top: 20px; }
.demoBanner, .offlineBanner { display: flex; align-items: center; justify-content: space-between; gap: 12px; background: var(--warning-badge-bg); color: var(--warning-badge-ink); font-size: 14px; line-height: 1.5; border-radius: 15px; padding: 13px 18px; margin-top: 20px; }
.demoBanner button { display: flex; align-items: center; gap: 5px; background: transparent; border: 0; font-size: 14px; font-weight: 750; color: inherit; }
.offlineBanner { justify-content: flex-start; background: var(--lavender); color: var(--blue); }
.favoritesHero { padding: 55px 0 40px; }
.favoritesEmpty { border-radius: 32px; padding: 70px 20px; text-align: center; background: white; border: 1px solid var(--line); margin-bottom: 50px; }
.favoritesEmpty { border-radius: 32px; padding: 70px 20px; text-align: center; background: var(--surface); border: 1px solid var(--line); margin-bottom: 50px; }
.favoritesEmpty .emptyIcon { margin: 0 auto 25px; }
.favoritesEmpty h2 { font-size: 23px; }
.favoritesEmpty p { font-size: 15px; line-height: 1.8; color: var(--muted); margin: 13px 0 24px; }
@@ -147,42 +148,42 @@ svg, .vkuiIcon { flex-shrink: 0; }
.favoriteCity button { margin-left: auto; }
.favoriteCard h2 { font-size: 23px; letter-spacing: -.5px; line-height: 1.3; margin: 15px 0 8px; color: var(--blue); }
.favoriteCard > p { font-size: 15px; line-height: 1.6; color: var(--blue-muted); }
.favoriteDepartures { margin-top: 20px; padding-top: 12px; border-top: 1px solid #dce0ee; min-height: 156px; }
.favoriteDepartures { margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--subtle-line); min-height: 156px; }
.favoriteDepartures ul { list-style: none; margin: 0; padding: 0; }
.favoriteDepartures li { display: flex; align-items: center; gap: 12px; padding: 8px 0; font-size: 14px; }
.favoriteDepartures li > strong { font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums; color: var(--blue); }
.favoriteDepartures li > span:last-child { margin-left: auto; color: var(--blue-muted); text-align: right; }
.favoriteDepartures p { color: var(--blue-muted); font-size: 15px; padding: 18px 0; line-height: 1.7; }
.favoriteRoute { display: grid; place-items: center; min-width: 42px; min-height: 36px; padding: 4px 8px; font-weight: 800; border-radius: 10px; color: var(--blue); background: white; }
.favoriteRoute { display: grid; place-items: center; min-width: 42px; min-height: 36px; padding: 4px 8px; font-weight: 800; border-radius: 10px; color: var(--blue); background: var(--surface); }
.favoriteStatus { display: flex; align-items: flex-start; gap: 7px; font-size: 13px; color: var(--blue-muted); margin-top: 12px; line-height: 1.7; }
.favoriteStatus > .vkuiIcon { margin-top: 3px; }
.favoriteLink { display: flex; align-items: center; justify-content: space-between; font-size: 14px; font-weight: 750; color: var(--blue); padding-top: 20px; border-top: 1px solid #dce0ee; margin-top: 22px; }
.favoriteLink { display: flex; align-items: center; justify-content: space-between; font-size: 14px; font-weight: 750; color: var(--blue); padding-top: 20px; border-top: 1px solid var(--subtle-line); margin-top: 22px; }
.aboutPage { padding: 55px 0 50px; }
.aboutIntro h1 { font-size: clamp(36px, 4.6vw, 58px); line-height: 1.2; letter-spacing: -.5px; margin: 16px 0; }
.aboutIntro h1 span { color: var(--blue); }
.aboutIntro > p { color: var(--muted); line-height: 1.8; }
.aboutGrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 24px; margin-top: 36px; align-items: start; }
.aboutGrid article { padding: 32px; border-radius: var(--card-radius); }
.aboutStory { background: white; border: 1px solid var(--line); }
.aboutStory { background: var(--surface); border: 1px solid var(--line); }
.aboutGrid h2 { font-size: 25px; line-height: 1.35; letter-spacing: -.3px; margin: 20px 0 16px; }
.aboutGrid p { font-size: 16px; line-height: 1.9; margin: 16px 0; }
.aboutStory p { color: var(--muted); }
.aboutStory .primaryButton { margin-top: 14px; }
.aboutSupport { background: var(--green); color: var(--green-ink); }
.aboutSupport > img { transform: rotate(12deg); opacity: .3; }
.supportNote { font-size: 14px; line-height: 1.8; padding-top: 20px; margin-top: 24px; border-top: 1px solid #bdd4b6; }
.toast, .updateToast { position: fixed; z-index: 50; left: 50%; bottom: 30px; transform: translateX(-50%); max-width: calc(100vw - 32px); background: var(--blue); color: white; border-radius: 18px; padding: 16px 20px; display: flex; align-items: center; gap: 15px; box-shadow: 0 10px 30px #2c467830; font-size: 15px; }
.supportNote { font-size: 14px; line-height: 1.8; padding-top: 20px; margin-top: 24px; border-top: 1px solid var(--green-line); }
.toast, .updateToast { position: fixed; z-index: 50; left: 50%; bottom: 30px; transform: translateX(-50%); max-width: calc(100vw - 32px); background: var(--blue); color: var(--on-accent); border-radius: 18px; padding: 16px 20px; display: flex; align-items: center; gap: 15px; box-shadow: 0 10px 30px #2c467830; font-size: 15px; }
.toast button, .updateToast button { border: 0; background: transparent; color: inherit; display: flex; padding: 4px; }
.updateToast { bottom: 90px; }
.updateToast button { font-weight: 750; font-size: 15px; }
.modalBackdrop { position: fixed; inset: 0; z-index: 100; background: #14274b70; backdrop-filter: blur(5px); display: grid; place-items: center; padding: 20px; }
.installDialog { position: relative; padding: 34px; border-radius: 30px; max-width: 430px; background: white; }
.installDialog { position: relative; padding: 34px; border-radius: 30px; max-width: 430px; background: var(--surface); }
.closeDialog { position: absolute; top: 14px; right: 14px; }
.installDialog h2 { font-size: 25px; letter-spacing: -.5px; margin: 20px 0 15px; }
.installDialog p { font-size: 16px; line-height: 1.8; margin: 10px 0; }
.installDialog .muted { color: var(--muted); font-size: 14px; }
.installDialog .primaryButton { margin-top: 14px; }
.skipLink { position: fixed; left: 20px; top: 20px; z-index: 200; padding: 15px; border-radius: 10px; background: white; clip-path: inset(100%); }
.skipLink { position: fixed; left: 20px; top: 20px; z-index: 200; padding: 15px; border-radius: 10px; background: var(--surface); clip-path: inset(100%); }
.skipLink:focus { clip-path: none; }
.skeletonRows { padding-top: 15px; }
.skeletonRow { display: flex; gap: 15px; align-items: center; height: 84px; }
@@ -232,7 +233,7 @@ svg, .vkuiIcon { flex-shrink: 0; }
.footer { flex-wrap: wrap; padding: 24px 0 20px; }
.footer > small { display: none; }
.footer > div { gap: 18px; flex-wrap: wrap; }
.mobileNav { position: fixed; z-index: 30; bottom: 0; left: 0; right: 0; display: flex; justify-content: space-evenly; gap: 16px; border-top: 1px solid #e0e3ed; background: #fffffff0; backdrop-filter: blur(14px); padding: 11px 20px max(11px, env(safe-area-inset-bottom)); }
.mobileNav { position: fixed; z-index: 30; bottom: 0; left: 0; right: 0; display: flex; justify-content: space-evenly; gap: 16px; border-top: 1px solid var(--line); background: var(--nav-glass); backdrop-filter: blur(14px); padding: 11px 20px max(11px, env(safe-area-inset-bottom)); }
.mobileNav a { display: flex; flex-direction: column; align-items: center; gap: 5px; font-size: 14px; font-weight: 700; color: var(--muted); padding: 0 12px; }
.mobileNav a.active { color: var(--blue); }
.toast { bottom: 110px; font-size: 14px; width: max-content; padding: 14px 16px; }
@@ -257,4 +258,5 @@ svg, .vkuiIcon { flex-shrink: 0; }
}
@media (max-width: 360px) { .app { padding: 0 14px 105px; } .hero h1 { font-size: 31px; } .boardCard { padding: 20px 16px; } .departureRow { gap: 9px; } .departureName strong { font-size: 14px; } .departureTime strong { font-size: 24px; } .brand { gap: 5px; } .footer > div { gap: 12px; } }
@include features.components;
@include settings.components;
@include motion.components;