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:
115
e2e/settings.spec.ts
Normal file
115
e2e/settings.spec.ts
Normal 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([]);
|
||||||
|
});
|
||||||
15
index.html
15
index.html
@@ -6,6 +6,21 @@
|
|||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<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="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#f3f3fa" />
|
<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. Найдите остановку и сохраните её в избранное." />
|
<meta name="description" content="Расписание общественного транспорта RideBus. Найдите остановку и сохраните её в избранное." />
|
||||||
<title>RideBus — расписание в вашем кармане</title>
|
<title>RideBus — расписание в вашем кармане</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
16
src/App.tsx
16
src/App.tsx
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { NavLink, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
import { NavLink, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
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 { DataError, loadResource } from './lib/api';
|
||||||
import { db, readPreference, savePreference, stopKey } from './lib/storage';
|
import { db, readPreference, savePreference, stopKey } from './lib/storage';
|
||||||
import { Departures } from './components/Departures';
|
import { Departures } from './components/Departures';
|
||||||
@@ -12,6 +12,8 @@ import { About } from './components/About';
|
|||||||
import { QuickTrips } from './components/QuickTrips';
|
import { QuickTrips } from './components/QuickTrips';
|
||||||
import { NearbyStops } from './components/NearbyStops';
|
import { NearbyStops } from './components/NearbyStops';
|
||||||
import { RoutePage } from './components/RoutePage';
|
import { RoutePage } from './components/RoutePage';
|
||||||
|
import { Settings } from './components/Settings';
|
||||||
|
import { useAppTheme } from './lib/theme';
|
||||||
import { StopList, StopListItem } from './components/StopList';
|
import { StopList, StopListItem } from './components/StopList';
|
||||||
import { positiveParam } from './lib/links';
|
import { positiveParam } from './lib/links';
|
||||||
import { isTravelDate, localDate } from './lib/time';
|
import { isTravelDate, localDate } from './lib/time';
|
||||||
@@ -30,6 +32,7 @@ function useOnline() {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const online = useOnline();
|
const online = useOnline();
|
||||||
|
const theme = useAppTheme();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
@@ -38,7 +41,8 @@ export default function App() {
|
|||||||
const favoritesPage = location.pathname === '/favorites';
|
const favoritesPage = location.pathname === '/favorites';
|
||||||
const aboutPage = location.pathname === '/about';
|
const aboutPage = location.pathname === '/about';
|
||||||
const routePage = location.pathname === '/route';
|
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 routeId = positiveParam(params.get('route')) ?? null;
|
||||||
const [view, setView] = useState<'next' | 'day'>(() => params.get('view') === 'day' ? 'day' : 'next');
|
const [view, setView] = useState<'next' | 'day'>(() => params.get('view') === 'day' ? 'day' : 'next');
|
||||||
const [date, setDate] = useState(() => isTravelDate(params.get('date')) ? params.get('date')! : localDate());
|
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>
|
<a className="skipLink" href="#main">Перейти к расписанию</a>
|
||||||
<header className="header">
|
<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>
|
<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>
|
<div className="headerActions"><PwaControls /></div>
|
||||||
</header>
|
</header>
|
||||||
<main id="main">
|
<main id="main">
|
||||||
{mode === 'demo' && <div className="demoBanner" role="status"><span>Демонстрация · остановки и отправления — примеры</span><button onClick={() => switchMode(false)}>К реальному расписанию <ArrowRight size={15} /></button></div>}
|
{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>}
|
{!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>
|
<section className="favoritesHero"><span className="eyebrow">ВСЕГДА ПОД РУКОЙ</span><h1>Любимые <span>остановки.</span></h1><p>Сохраните те, с которых начинается ваш день.</p></section>
|
||||||
<QuickTrips trips={trips} online={online} onNotice={setNotice} />
|
<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>
|
{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>
|
<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>
|
</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>
|
<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></nav>
|
<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>}
|
{notice && <div className="toast" role="status">{notice}<button aria-label="Закрыть уведомление" onClick={() => setNotice('')}><X size={16} /></button></div>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
@mixin components {
|
@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 { width: 6px; height: 6px; }
|
||||||
.appScrollArea::-webkit-scrollbar-track { background: transparent; }
|
.appScrollArea::-webkit-scrollbar-track { background: transparent; }
|
||||||
.appScrollArea::-webkit-scrollbar-thumb { background: #c7d1e8; border-radius: 8px; }
|
.appScrollArea::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 8px; }
|
||||||
.appScrollArea::-webkit-scrollbar-thumb:hover { background: #aebedc; }
|
.appScrollArea::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); }
|
||||||
.stopDistance { flex-shrink: 0; white-space: nowrap; font-size: 14px; color: var(--blue-muted); }
|
.stopDistance { flex-shrink: 0; white-space: nowrap; font-size: 14px; color: var(--blue-muted); }
|
||||||
.boardCard { scroll-margin-top: 24px; }
|
.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 { display: flex; gap: 8px; align-items: stretch; min-width: 0; }
|
||||||
.departureEntry .departureRow { flex: 1; 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); }
|
.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 { display: flex; align-items: center; gap: 12px; }
|
||||||
.quickTripHeading h3 { font-size: 23px; line-height: 1.4; overflow-wrap: anywhere; }
|
.quickTripHeading h3 { font-size: 23px; line-height: 1.4; overflow-wrap: anywhere; }
|
||||||
.quickTripHeading > button { margin-left: auto; flex-shrink: 0; }
|
.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; }
|
.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; }
|
.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 { 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 > strong { font-size: 29px; font-variant-numeric: tabular-nums; }
|
||||||
.tripNext > span:last-child { font-size: 14px; }
|
.tripNext > span:last-child { font-size: 14px; }
|
||||||
.tripFollowing, .tripSaved { margin-top: 10px; font-size: 14px; line-height: 1.7; }
|
.tripFollowing, .tripSaved { margin-top: 10px; font-size: 14px; line-height: 1.7; }
|
||||||
.tripSaved { padding: 10px 12px; border-radius: 12px; background: #ffffff65; }
|
.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 #bdd4b6; }
|
.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 > a { display: flex; align-items: center; gap: 8px; min-height: 44px; font-size: 15px; font-weight: 800; }
|
||||||
.tripActions .textButton { color: inherit; min-height: 44px; }
|
.tripActions .textButton { color: inherit; min-height: 44px; }
|
||||||
.carouselControls { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 8px; }
|
.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); }
|
.carouselControls .iconButton { background: var(--lavender); color: var(--blue); }
|
||||||
.carouselPrevious > .vkuiIcon { transform: rotate(180deg); }
|
.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); }
|
.appDialog::backdrop { background: #14274b70; backdrop-filter: blur(5px); }
|
||||||
.dialogBody { padding: 26px; }
|
.dialogBody { padding: 26px; }
|
||||||
.dialogHeading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 16px; }
|
.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 h2 { font-size: 25px; line-height: 1.4; }
|
||||||
.dialogHeading .iconButton { margin-top: -4px; margin-right: -8px; }
|
.dialogHeading .iconButton { margin-top: -4px; margin-right: -8px; }
|
||||||
.dialogDescription { color: var(--muted); font-size: 15px; line-height: 1.8; }
|
.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 { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||||
.namePresets .secondaryButton { padding: 10px 14px; min-height: 44px; }
|
.namePresets .secondaryButton { padding: 10px 14px; min-height: 44px; }
|
||||||
.tripForm > .primaryButton { margin-top: 24px; min-height: 48px; }
|
.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; }
|
.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%; }
|
.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 { 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; }
|
.hourDeparture strong { font-size: 16px; font-variant-numeric: tabular-nums; }
|
||||||
.hourRoute { font-size: 13px; font-weight: 800; }
|
.hourRoute { font-size: 13px; font-weight: 800; }
|
||||||
.hourDeparture.pastDeparture { background: #f4f4f1; color: #69758c; }
|
.hourDeparture.pastDeparture { background: var(--past-bg); color: var(--past-ink); }
|
||||||
.hourDeparture.nextDeparture { background: var(--green); border-color: #9db994; color: var(--green-ink); }
|
.hourDeparture.nextDeparture { background: var(--green); border-color: var(--green-line); color: var(--green-ink); }
|
||||||
.hourDeparture:hover { border-color: var(--blue-muted); }
|
.hourDeparture:hover { border-color: var(--blue-muted); }
|
||||||
.hasTripNote { font-size: 14px; position: absolute; right: 4px; top: -2px; }
|
.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; }
|
.alertsHeading { display: flex; align-items: center; gap: 8px; font-size: 15px; }
|
||||||
.serviceAlert { padding-top: 14px; }
|
.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; }
|
.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 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; }
|
.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 { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; align-items: start; }
|
||||||
.routeBody:has(> .serviceAlerts) { grid-template-columns: minmax(0, 1fr) 320px; }
|
.routeBody:has(> .serviceAlerts) { grid-template-columns: minmax(0, 1fr) 320px; }
|
||||||
.routeBody > .serviceAlerts { margin: 0; }
|
.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 { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||||
.routeTimelineHeading h2 { font-size: 24px; }
|
.routeTimelineHeading h2 { font-size: 24px; }
|
||||||
.routeTimelineHeading > span { font-size: 14px; color: var(--blue); background: var(--lavender); padding: 4px 9px; border-radius: 20px; }
|
.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 { 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 { 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; }
|
.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 white; font-size: 13px; font-weight: 800; }
|
.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 { 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 strong { font-size: 16px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||||
.journeyTimeline a span { font-size: 13px; color: var(--blue-muted); }
|
.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 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; }
|
.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); }
|
.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); }
|
.journeyTimeline .passedJourneyStop:not(.selectedJourneyStop) { color: var(--muted); }
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
--motion-ease: cubic-bezier(.2, .75, .25, 1);
|
--motion-ease: cubic-bezier(.2, .75, .25, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroCopy, .favoritesHero, .aboutIntro,
|
.heroCopy, .favoritesHero, .aboutIntro, .settingsIntro, .settingsCard,
|
||||||
.stopsCard, .boardCard, .favoritesEmpty,
|
.stopsCard, .boardCard, .favoritesEmpty,
|
||||||
.favoriteCard, .quickTripCard, .routeHero, .routeTimelineCard,
|
.favoriteCard, .quickTripCard, .routeHero, .routeTimelineCard,
|
||||||
.aboutGrid > article, .benefitStrip {
|
.aboutGrid > article, .benefitStrip {
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
.primaryButton, .secondaryButton, .installButton, .iconButton,
|
.primaryButton, .secondaryButton, .installButton, .iconButton,
|
||||||
.textButton, .showMore, .refreshButton, .stopItem,
|
.textButton, .showMore, .refreshButton, .stopItem,
|
||||||
.directionChips button, .routeFilters button, .scheduleViews button,
|
.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,
|
transition: background-color var(--motion-fast) ease, color var(--motion-fast) ease,
|
||||||
border-color var(--motion-fast) ease, box-shadow var(--motion-fast) ease,
|
border-color var(--motion-fast) ease, box-shadow var(--motion-fast) ease,
|
||||||
transform var(--motion-fast) var(--motion-ease);
|
transform var(--motion-fast) var(--motion-ease);
|
||||||
|
|||||||
36
src/_settings.scss
Normal file
36
src/_settings.scss
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,5 +10,72 @@
|
|||||||
--lavender: #f3f3fa;
|
--lavender: #f3f3fa;
|
||||||
--green: #ddf9d0;
|
--green: #ddf9d0;
|
||||||
--green-ink: #254817;
|
--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;
|
--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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Icon24Bookmark, Icon24BookmarkOutline, Icon24Cancel,
|
Icon24Bookmark, Icon24BookmarkOutline, Icon24Cancel,
|
||||||
Icon24ChevronDown, Icon24LocationOutline, Icon24SearchOutline,
|
Icon24ChevronDown, Icon24LocationOutline, Icon24SearchOutline,
|
||||||
Icon24Add, Icon24HomeOutline, Icon24WorkOutline, Icon24WarningTriangleOutline,
|
Icon24Add, Icon24HomeOutline, Icon24WorkOutline, Icon24WarningTriangleOutline,
|
||||||
|
Icon28SettingsOutline, Icon28MoonOutline, Icon28SunOutline, Icon28ComputerSmartphoneOutline,
|
||||||
} from '@vkontakte/icons';
|
} from '@vkontakte/icons';
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
@@ -35,6 +36,10 @@ export const Home = adapt(Icon24HomeOutline);
|
|||||||
export const Work = adapt(Icon24WorkOutline);
|
export const Work = adapt(Icon24WorkOutline);
|
||||||
export const Warning = adapt(Icon24WarningTriangleOutline);
|
export const Warning = adapt(Icon24WarningTriangleOutline);
|
||||||
export const Share = adapt(Icon20ShareOutline);
|
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 }) {
|
export function Bookmark({ size = 24, className, fill }: IconProps & { fill?: string }) {
|
||||||
const Icon = fill === 'currentColor' ? Icon24Bookmark : Icon24BookmarkOutline;
|
const Icon = fill === 'currentColor' ? Icon24Bookmark : Icon24BookmarkOutline;
|
||||||
|
|||||||
28
src/components/Settings.tsx
Normal file
28
src/components/Settings.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -15,5 +15,5 @@ export function readPreference(key: string) {
|
|||||||
try { return localStorage.getItem(`ridebus:${key}`); } catch { return null; }
|
try { return localStorage.getItem(`ridebus:${key}`); } catch { return null; }
|
||||||
}
|
}
|
||||||
export function savePreference(key: string, value: string) {
|
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
41
src/lib/theme.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
@use './tokens';
|
@use './tokens';
|
||||||
@use './motion';
|
@use './motion';
|
||||||
@use './features';
|
@use './features';
|
||||||
|
@use './settings';
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { 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; }
|
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; }
|
button:disabled { cursor: default; opacity: .5; }
|
||||||
a { color: inherit; text-decoration: none; }
|
a { color: inherit; text-decoration: none; }
|
||||||
button, a { touch-action: manipulation; }
|
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; }
|
button { color: inherit; }
|
||||||
h1, h2, h3, p { margin: 0; }
|
h1, h2, h3, p { margin: 0; }
|
||||||
svg, .vkuiIcon { flex-shrink: 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; }
|
.brand > span:first-of-type { font-size: 28px; letter-spacing: -.5px; font-weight: 800; }
|
||||||
.brandDot { color: var(--blue); }
|
.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; }
|
.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 { 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; }
|
.navCount { background: var(--lavender); border-radius: 20px; padding: 1px 7px; font-size: 13px; font-weight: 750; }
|
||||||
.headerActions { display: flex; gap: 19px; align-items: center; }
|
.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; }
|
.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; }
|
.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; }
|
.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; }
|
.sectionHeading h2 { font-size: 25px; font-weight: 800; letter-spacing: -.5px; }
|
||||||
.sectionDescription { margin: 6px 0 25px; font-size: 14px; color: var(--blue-muted); }
|
.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; }
|
.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; }
|
.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 { 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; }
|
.searchField input::placeholder { color: var(--muted); font-size: 16px; }
|
||||||
.citySelect:focus-within, .searchField:focus-within { box-shadow: 0 0 0 2px #aec6ff; }
|
.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; }
|
.citySelect select:focus-visible, .searchField input:focus-visible { outline: 0; }
|
||||||
.stopsList { margin: 18px -8px 8px; padding: 0 8px; max-height: 398px; overflow-y: auto; }
|
.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 { 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:hover { background: var(--hover-surface); }
|
||||||
.stopItem.activeStop { background: white; border-color: #c3d3f5; box-shadow: 0 3px 8px #2c467809; }
|
.stopItem.activeStop { background: var(--surface); border-color: var(--control-border); box-shadow: 0 3px 8px #2c467809; }
|
||||||
.stopCopy { flex: 1; min-width: 0; }
|
.stopCopy { flex: 1; min-width: 0; }
|
||||||
.stopCopy strong { font-size: 16px; line-height: 1.4; font-weight: 750; overflow-wrap: anywhere; }
|
.stopCopy strong { font-size: 16px; line-height: 1.4; font-weight: 750; overflow-wrap: anywhere; }
|
||||||
.stopEnd { margin-left: auto; opacity: .6; }
|
.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; }
|
.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 { 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; }
|
.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 { 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); }
|
.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); }
|
.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; }
|
.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; }
|
.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; }
|
.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 { 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 { 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.selected { background: var(--blue); color: var(--on-accent); }
|
||||||
.directionChips button:hover { background: #aec6ff; }
|
.directionChips button:hover { background: var(--focus-soft); }
|
||||||
.directionChips button.selected:hover { background: var(--blue-muted); }
|
.directionChips button.selected:hover { background: var(--blue-muted); }
|
||||||
.navIcon { position: relative; display: inline-flex; width: 28px; height: 28px; }
|
.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); }
|
.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; }
|
.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 { 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 { 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; }
|
.scheduleControls { margin-top: 22px; }
|
||||||
.scheduleViews { display: inline-flex; gap: 4px; padding: 4px; background: var(--lavender); border-radius: 16px; }
|
.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 { 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 { 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 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; }
|
.timetableSummary { margin: -5px 0 18px; color: var(--muted); font-size: 14px; line-height: 1.7; }
|
||||||
.departureList { list-style: none; margin: 0; padding: 0; }
|
.departureList { list-style: none; margin: 0; padding: 0; }
|
||||||
.departureRow { display: flex; gap: 15px; align-items: center; min-height: 94px; padding: 17px 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 { 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 h3 { font-size: 16px; }
|
||||||
.listMessage p { color: var(--blue-muted); }
|
.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); }
|
.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; }
|
.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); }
|
.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; }
|
.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: #eeede6; border-radius: 22px; margin: 24px 0 42px; }
|
.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: #faf9f6; color: var(--blue); }
|
.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 strong { font-size: 15px; font-weight: 800; }
|
||||||
.benefitStrip p { margin-top: 4px; font-size: 14px; color: var(--muted); line-height: 1.6; }
|
.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); }
|
.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 button { border: 0; background: transparent; font-size: inherit; color: inherit; padding: 7px 0; }
|
||||||
.footer > small { font-size: 13px; color: var(--muted); }
|
.footer > small { font-size: 13px; color: var(--muted); }
|
||||||
.mobileNav { display: none; }
|
.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; }
|
.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); }
|
.offlineBanner { justify-content: flex-start; background: var(--lavender); color: var(--blue); }
|
||||||
.favoritesHero { padding: 55px 0 40px; }
|
.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 .emptyIcon { margin: 0 auto 25px; }
|
||||||
.favoritesEmpty h2 { font-size: 23px; }
|
.favoritesEmpty h2 { font-size: 23px; }
|
||||||
.favoritesEmpty p { font-size: 15px; line-height: 1.8; color: var(--muted); margin: 13px 0 24px; }
|
.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; }
|
.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 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); }
|
.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 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 { 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 > 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 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; }
|
.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 { 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; }
|
.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; }
|
.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 { font-size: clamp(36px, 4.6vw, 58px); line-height: 1.2; letter-spacing: -.5px; margin: 16px 0; }
|
||||||
.aboutIntro h1 span { color: var(--blue); }
|
.aboutIntro h1 span { color: var(--blue); }
|
||||||
.aboutIntro > p { color: var(--muted); line-height: 1.8; }
|
.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 { 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); }
|
.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 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; }
|
.aboutGrid p { font-size: 16px; line-height: 1.9; margin: 16px 0; }
|
||||||
.aboutStory p { color: var(--muted); }
|
.aboutStory p { color: var(--muted); }
|
||||||
.aboutStory .primaryButton { margin-top: 14px; }
|
.aboutStory .primaryButton { margin-top: 14px; }
|
||||||
.aboutSupport { background: var(--green); color: var(--green-ink); }
|
.aboutSupport { background: var(--green); color: var(--green-ink); }
|
||||||
.aboutSupport > img { transform: rotate(12deg); opacity: .3; }
|
.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; }
|
.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: white; border-radius: 18px; padding: 16px 20px; display: flex; align-items: center; gap: 15px; box-shadow: 0 10px 30px #2c467830; font-size: 15px; }
|
.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; }
|
.toast button, .updateToast button { border: 0; background: transparent; color: inherit; display: flex; padding: 4px; }
|
||||||
.updateToast { bottom: 90px; }
|
.updateToast { bottom: 90px; }
|
||||||
.updateToast button { font-weight: 750; font-size: 15px; }
|
.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; }
|
.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; }
|
.closeDialog { position: absolute; top: 14px; right: 14px; }
|
||||||
.installDialog h2 { font-size: 25px; letter-spacing: -.5px; margin: 20px 0 15px; }
|
.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 p { font-size: 16px; line-height: 1.8; margin: 10px 0; }
|
||||||
.installDialog .muted { color: var(--muted); font-size: 14px; }
|
.installDialog .muted { color: var(--muted); font-size: 14px; }
|
||||||
.installDialog .primaryButton { margin-top: 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; }
|
.skipLink:focus { clip-path: none; }
|
||||||
.skeletonRows { padding-top: 15px; }
|
.skeletonRows { padding-top: 15px; }
|
||||||
.skeletonRow { display: flex; gap: 15px; align-items: center; height: 84px; }
|
.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 { flex-wrap: wrap; padding: 24px 0 20px; }
|
||||||
.footer > small { display: none; }
|
.footer > small { display: none; }
|
||||||
.footer > div { gap: 18px; flex-wrap: wrap; }
|
.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 { 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); }
|
.mobileNav a.active { color: var(--blue); }
|
||||||
.toast { bottom: 110px; font-size: 14px; width: max-content; padding: 14px 16px; }
|
.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; } }
|
@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 features.components;
|
||||||
|
@include settings.components;
|
||||||
@include motion.components;
|
@include motion.components;
|
||||||
|
|||||||
Reference in New Issue
Block a user