diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 0000000..e898b51 --- /dev/null +++ b/e2e/settings.spec.ts @@ -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(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([]); +}); diff --git a/index.html b/index.html index 85b3829..fce799a 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,21 @@ + + RideBus — расписание в вашем кармане diff --git a/src/App.tsx b/src/App.tsx index ae16dc0..4dbf6d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { Перейти к расписанию
ridebusweb - +
{mode === 'demo' &&
Демонстрация · остановки и отправления — примеры
} {!online &&
Вы офлайн. Ранее просмотренные остановки и отправления доступны из сохранённых данных.
} - {routePage ? : aboutPage ? : favoritesPage ? <> + {settingsPage ? : routePage ? : aboutPage ? : favoritesPage ? <>
ВСЕГДА ПОД РУКОЙ

Любимые остановки.

Сохраните те, с которых начинается ваш день.

{favorites.length === 0 && trips.length === 0 ?

Здесь будут ваши остановки

Нажмите на закладку рядом с названием остановки
или сохраните поездку с нужным маршрутом.

Найти остановку
@@ -182,8 +186,8 @@ export default function App() {
Ваша остановка — в один тап

Добавьте её в избранное, чтобы в следующий раз найти быстрее.

В избранное
}
- - + + {notice &&
{notice}
} ; } diff --git a/src/_features.scss b/src/_features.scss index 52939e5..28bcbb0 100644 --- a/src/_features.scss +++ b/src/_features.scss @@ -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) { diff --git a/src/_motion.scss b/src/_motion.scss index cc17664..f1a7411 100644 --- a/src/_motion.scss +++ b/src/_motion.scss @@ -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); diff --git a/src/_settings.scss b/src/_settings.scss new file mode 100644 index 0000000..503ad9c --- /dev/null +++ b/src/_settings.scss @@ -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; } + } +} diff --git a/src/_tokens.scss b/src/_tokens.scss index 89b32f6..23921c5 100644 --- a/src/_tokens.scss +++ b/src/_tokens.scss @@ -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; } diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 5413b58..7c6d1bc 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -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; diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx new file mode 100644 index 0000000..874d79b --- /dev/null +++ b/src/components/Settings.tsx @@ -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
+ К расписанию +

Настройки

Оформите RideBus так, как вам удобно.

+
+
Тема приложения +
{options.map(({ value, title, description, Icon }) => )}
+
+

{!saved ? 'Тема изменена, но браузер не разрешил сохранить выбор. Он будет действовать, пока эта вкладка открыта.' : choice === 'system' ? `Сейчас используется ${resolved === 'dark' ? 'тёмная' : 'светлая'} тема устройства.` : 'Выбор сохранён в этом браузере.'}

+
+
; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 448662c..b382fb9 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -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; } } diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..e7d5ab4 --- /dev/null +++ b/src/lib/theme.ts @@ -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(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 }; +} diff --git a/src/styles.scss b/src/styles.scss index fcde21a..70a4ad1 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -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;