docs: Add verification-info-simple.md detailing the simplified information screen changes as of October 9, 2026

This commit is contained in:
2026-10-09 22:43:35 +03:00
parent bddd125306
commit 046e3c6a2f
16 changed files with 112 additions and 245 deletions

View File

@@ -21,8 +21,8 @@ npm start
- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами каждые 7 секунд с явным счётчиком. На 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием.
- В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки.
- Информационный экран: маршруты, погода, QR-код и предупреждения. Часы и ближайший рейс с цветным номером остаются видимыми. В вертикальной и короткой компоновке показывается одна карточка; при следующих посещениях информационного экрана карточки сменяются. Их также можно выбрать кнопками «Маршруты», «Погода», «RideBus».
- В широком блоке маршрутов — несколько слегка наклонённых дорожек с плавным встречным движением, номерами, названиями, типом транспорта и заметным временем следующего отправления. Зазоры учитывают наклон на краях. В узком блоке, включая компоновку с тремя карточками, — вертикальные карточки, сменяющиеся каждые 6,5 секунды с прокруткой названий и чисел. Пауза останавливает оба варианта. Технический статус полного кеша пассажирам не показывается.
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены с крупными иконками, цветными номерами маршрутов, сроками действия и ненавязчивой графикой. Исходные заголовки, тексты и детали отмен сохраняются.
- Блок маршрутов показывает только номер и название. На широком экране список занимает 2–3 колонки, в узкой карточке — одну. Вместимость определяется доступной высотой; остальные маршруты сменяются страницами каждые 6,5 секунды с плавным переходом названий и чисел. Пауза останавливает смену страниц. Тип транспорта, повторные времена, наклонённые дорожки, фоновые рисунки и технический статус кеша убраны.
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности.
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
- Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят.
- Настройки остановки, маршрута, числа строк, интервалов и деморежима сохраняются на устройстве. Новая остановка загружает свои данные и свой кеш.
@@ -46,9 +46,9 @@ npm start
При сбое появляется пометка сохранённых данных. Истёкшие рейсы удаляются. Если сохранённые рейсы закончились, табло честно сообщает об этом и продолжает переподключение. Предупреждения фильтруются по городу, маршрутам, срокам действия; сохраняются полный текст и детали отмены. Полный кеш расписания не подтверждает актуальность предупреждений — без свежего ответа API это явно указано.
QR-код создаётся локально, без стороннего сервиса генерации, и открывает `https://pwa.ridebus.by/?city=1&stop=1` для текущей остановки (параметры меняются вместе с площадкой). Адрес PWA можно переопределить серверной переменной `BOARD_PWA_URL`. Карточка оформлена как приглашение забрать расписание: большой неподвижный QR на акцентной панели с плавно движущейся линией маршрута, название остановки, шаги открытия и заметная ссылка. В узкой и короткой карточке используется упрощённая компоновка, размер кода рассчитывается по свободному месту. Декоративное движение выключено при `prefers-reduced-motion`. QR использует скруглённые чёрные модули, белый фон и свободное поле шириной 4 модуля; рамка и внешняя подсветка следуют акценту темы. Декодирование нескольких ссылок и QR на снимках страницы проверено автоматически.
QR-код создаётся локально, без стороннего сервиса генерации, и открывает `https://pwa.ridebus.by/?city=1&stop=1` для текущей остановки (параметры меняются вместе с площадкой). Адрес PWA можно переопределить серверной переменной `BOARD_PWA_URL`. В карточке — большой неподвижный QR, одна инструкция и ссылка. Размер кода рассчитывается по свободному месту. QR использует скруглённые чёрные модули, белый фон и свободное поле шириной 4 модуля. Декодирование нескольких ссылок и QR на снимках страницы проверено автоматически.
Погода запрашивается независимо через `/api/weather` у [Open-Meteo](https://open-meteo.com/en/docs) по публичным координатам остановки из API. В шапке — температура и состояние; на информационном экране — ощущаемая температура, ветер в м/с и три следующих часа. Процент осадков — максимальная почасовая вероятность в этих трёх часах. Это модельные погодные данные. Обновление раз в 10 минут; сохранённая погода помечается и показывается не дольше часа, затем скрывается. При отсутствии координат или сбое погоды транспорт продолжает работать. Данные сохраняются по городу и остановке; RideBus-ключ не передаётся поставщику погоды. Указаны источник и CC BY 4.0.
Погода запрашивается независимо через `/api/weather` у [Open-Meteo](https://open-meteo.com/en/docs) по публичным координатам остановки из API. В шапке — температура и состояние; на информационном экране — текущая температура, состояние и прогноз температуры с крупными иконками на три следующих часа. Это модельные погодные данные. Обновление раз в 10 минут; сохранённая погода помечается и показывается не дольше часа, затем скрывается. При отсутствии координат или сбое погоды транспорт продолжает работать. Данные сохраняются по городу и остановке; RideBus-ключ не передаётся поставщику погоды. Указаны источник и CC BY 4.0.
Бесплатный Open-Meteo подходит для прототипа; [условия сервиса](https://open-meteo.com/en/terms) требуют коммерческую подписку для приложения с рекламой. Сервер уже поддерживает `OPEN_METEO_API_KEY` и автоматически выбирает коммерческий endpoint, ключ остаётся на сервере. Панель оператора планируется отдельно в `polotsk-transit-api`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,21 @@
# Упрощение информационного экрана — 9 октября 2026
Оставлены номер и название маршрута; текущая температура, состояние и прогноз на три часа; QR-код, одна инструкция и ссылка. Удалены дорожки с непрерывным движением, фоновые рисунки, водяные знаки, тип транспорта и повторные времена в каталоге маршрутов, пошаговая инструкция QR и дополнительные показатели погоды. Вместо «Пока вы ждёте» используется «Информация». Строка предупреждений появляется при недоступности данных, без постоянной подписи об отсутствии активных уведомлений.
Маршруты используют адаптивную сетку из 1–3 колонок, сбалансированные страницы и прежнюю анимацию смены чисел и названий. Уведомления оформлены проще, но исходные сообщения, номера маршрутов и сроки сохранены. Сервер, кеш расписаний, правила актуальности погоды и предупреждений не изменены.
Проверено:
- `npm run build`: TypeScript и Vite проходят. `npm test`: 48/48. `git diff --check` проходит.
- В браузере загружена итоговая production-сборка `/assets/index-CvnZ7b-g.js`.
- 1280×900, тёмная и светлая темы: все три карточки видимы одновременно. У каждой clientHeight и scrollHeight равны 535 px; вертикальной обрезки нет. Дополнительные подписи и декоративные узлы отсутствуют в DOM.
- 879×593: одиночные карточки переключаются кнопками «Маршруты / Погода / RideBus». Погода и QR имеют clientHeight=scrollHeight=348 px. Основная погодная иконка ≈81 px, прогнозные ≈51 px. QR ≈262 px, ссылка указывает на текущую остановку.
- 390×667: QR ≈196 px, карточка clientHeight=scrollHeight=354 px; подпись и ссылка полностью видны. Погода использует компактные строки «иконка + температура», иконки прогноза 40 px. Прогноз заканчивается на y≈474,3, блок источника начинается на y=500,5; пересечения нет. Карточка маршрутов имеет ту же высоту без обрезки.
- 1500×600: каталог маршрутов занимает три колонки по 440 px. Карточка clientHeight=scrollHeight=344 px. После включения смены экранов номера, названия и счётчик страниц обновились с страницы 3/4 на 1/4; после паузы счётчик оставался 1/4. Распределение страниц не оставляет последнюю страницу с одним маршрутом.
- QR на снимках тёмной компоновки с тремя карточками, вертикального экрана и светлой темы декодирован через jsQR в `https://pwa.ridebus.by/?city=1&stop=1`.
- Уведомления проверены на существующей отдельной визуальной странице с тестовыми отменой, объездом и переносом посадки. Тексты, даты и номера сохранены; декоративные линии и водяные знаки отсутствуют. Это проверка примеров, а не активных сообщений API. Временная сборка страницы удалена повторной production-сборкой, временная вкладка закрыта.
- Ошибок и предупреждений в консоли не обнаружено. После проверки восстановлены тёмная тема, остановка 1, четыре следующих рейса, интервалы 25/8 и удержание ближайшего рейса. Пауза выключена, viewport сброшен.
Снимки: `info-simple-three-cards.jpg`, `info-simple-light.jpg`, `info-simple-routes-wide.jpg`, `info-simple-weather-879x593.jpg`, `info-simple-weather-390x667.jpg`, `info-simple-qr-879x593.jpg`, `info-simple-qr-390x667.jpg`, `info-simple-alerts.jpg`.
Проверка выполнена в браузере. Физическая Mi Box в этой проверке не использовалась.

View File

@@ -1,4 +1,4 @@
import { ArrowRight, Clock, Close, Info, Reset } from './Icons';
import { Clock, Close, Info, Reset } from './Icons';
import { RouteBadge } from './RouteBadge';
import type { Alert } from './types';
@@ -12,13 +12,11 @@ export function AlertCard({ alert }: { alert: Alert }) {
const periodAt = alert.endAt || alert.startAt;
const period = periodAt && Number.isFinite(Date.parse(periodAt)) ? periodFormat.format(Date.parse(periodAt)) : null;
return <article className="notice" data-kind={cancelled ? 'cancellation' : detour ? 'detour' : delayed ? 'delay' : 'info'}>
<div className="notice-graphic" aria-hidden="true"><div className="notice-signal"><Icon size={44} /></div><span className="notice-line" /><span className="notice-endpoint" /></div>
<div className="notice-graphic" aria-hidden="true"><div className="notice-signal"><Icon size={32} /></div></div>
<div className="notice-content">
<div className="notice-meta"><span className="notice-type">{label}</span>{period && <span className="notice-period">{alert.endAt ? 'До' : 'С'} {period}</span>}</div>
{!!alert.routeNumbers.length && <div className="notice-routes" aria-label="Затронутые маршруты">{alert.routeNumbers.map(number => <RouteBadge key={number} number={number} filled />)}</div>}
<h3>{alert.title}</h3><p>{alert.message}</p>
{!alert.routeNumbers.length && <div className="notice-scope"><ArrowRight size={18} /><span>На этой остановке</span></div>}
</div>
<span className="notice-watermark" aria-hidden="true"><Icon size={180} /></span>
</article>;
}

View File

@@ -82,7 +82,7 @@ function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fr
</div>;
}
function InfoScreen({ data, routeDepartures, alerts, fresh, seconds, paused, page, onPage, config, weather, now }: { data: BoardData | null; routeDepartures: Departure[]; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
config: Config; weather: ReturnType<typeof useWeather>; now: number }) {
const panel = useRef<HTMLElement>(null);
const grid = useRef<HTMLDivElement>(null);
@@ -104,14 +104,14 @@ function InfoScreen({ data, routeDepartures, alerts, fresh, seconds, paused, pag
return () => { clearTimeout(start); clearInterval(timer); };
}, [seconds, paused, page]);
return <section ref={panel} className="info-screen" data-compact={compact} data-cards={!alerts.length} aria-label="Информация для пассажиров">
<div className="info-intro"><h2>{alerts.length ? 'Изменения движения' : 'Пока вы ждёте.'}</h2>
<div className="info-intro"><h2>{alerts.length ? 'Изменения движения' : 'Информация'}</h2>
{!alerts.length && <nav className="information-tabs" aria-label="Информация на вертикальном экране">{['Маршруты', 'Погода', 'RideBus'].map((name, index) => <button key={name} aria-current={page % 3 === index ? 'page' : undefined} onClick={() => onPage(index)}>{name}</button>)}</nav>}</div>
{alerts.length ? <div className="info-notices">{alerts.map(alert => <AlertCard key={alert.id} alert={alert} />)}</div> : <div ref={grid} className="info-grid">
<section className="info-card routes-info" data-active={page % 3 === 0}><h3>Маршруты остановки</h3><RouteCloud routes={data?.routes || []} departures={routeDepartures} now={now} paused={paused} wide={compact} /></section>
<section className="info-card routes-info" data-active={page % 3 === 0}><h3>Маршруты</h3><RouteCloud routes={data?.routes || []} paused={paused} /></section>
<section className="info-card weather-info" data-active={page % 3 === 1}><WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} /></section>
<section className="info-card qr-info" data-active={page % 3 === 2}><PassengerQr config={config} stop={data?.stop} /></section>
</div>}
{!alerts.length && <div className="info-status"><Shield /><span>{data?.alertsAvailable === false || !fresh ? 'Актуальные предупреждения сейчас недоступны' : 'Нет активных предупреждений для этой остановки'}</span></div>}
{!alerts.length && (data?.alertsAvailable === false || !fresh) && <div className="info-status"><Shield /><span>Предупреждения сейчас недоступны</span></div>}
</section>;
}
@@ -210,7 +210,7 @@ export default function App() {
{!fresh && !settings.demo && <div className="offline-banner" role="status"><WifiOff /><span>{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}</span><button onClick={refresh} aria-label="Повторить подключение"><Refresh /></button></div>}
<main className="scene-container"><div className="scene" key={scene} data-scene={scene}>
{scene === 'departures' && <DeparturesScreen data={data} departures={departures} now={now} alerts={alerts} loading={loading} rowCount={settings.rowCount} fresh={fresh} error={error} paused={paused || settingsOpen} />}
{scene === 'info' && <InfoScreen data={data} routeDepartures={fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now) : upcomingDepartures(data, now)} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} weather={weather} now={now} />}
{scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} weather={weather} now={now} />}
{scene === 'ad' && <section className="ad-screen"><span className="eyebrow">РЕКЛАМА · ПРЕДПРОСМОТР</span><h2>{settings.adTitle}</h2><p>{settings.adText}</p><div className="ad-signature">Время для нового.<ArrowUpRight /></div></section>}
</div></main>
{scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{departures[0] ? <><RouteBadge number={departures[0].routeNumber} filled /><div className="persistent-destination"><strong>{departures[0].destination}</strong><ScheduledTime departure={departures[0]} now={now} /></div><Countdown departure={departures[0]} now={now} /></> : <span>Нет доступных рейсов</span>}</div>}

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { ArrowUpRight, Camera, Smartphone } from './Icons';
import { ArrowUpRight } from './Icons';
import type { Config, Stop } from './types';
import { createPassengerQr, passengerLink } from './lib/passenger.mjs';
@@ -8,14 +8,11 @@ export function PassengerQr({ config, stop }: { config: Config; stop: Stop | und
const qr = useMemo(() => createPassengerQr(url), [url]);
const stopName = stop?.name || config.stopName;
return <><h3>Расписание в телефоне</h3><div className="qr-content"><div className="qr-stage">
<div className="qr-route-backdrop" aria-hidden="true"><svg className="qr-route-art" viewBox="0 0 500 500"><path d="M-50 400H75Q130 400 130 340V140Q130 80 190 80H310Q370 80 370 140V340Q370 400 430 400H550" /><circle cx="130" cy="230" r="12" /><circle cx="370" cy="230" r="12" /></svg></div>
<a className="qr-paper" href={url} target="_blank" rel="noreferrer" aria-label={`Открыть ${stopName} в RideBus`}>
<svg className="passenger-qr" viewBox={`0 0 ${qr.size} ${qr.size}`} role="img" aria-label="QR-код расписания этой остановки">
<rect width={qr.size} height={qr.size} fill="#ffffff" />{qr.rectangles.map((rectangle, index) => <rect key={index} {...rectangle} />)}
</svg></a><span className="qr-stage-corner" aria-hidden="true"><ArrowUpRight size={30} /></span></div>
<div className="qr-description"><div className="qr-intro"><span className="qr-eyebrow">С ВАМИ ПО ПУТИ</span><h4 className="qr-stop">{stopName}</h4><p className="qr-summary">Заберите расписание этой остановки с собой.</p></div>
<ol className="qr-steps"><li><span className="qr-step-icon"><Camera size={32} /></span><div><span className="qr-step-number">01</span><strong>Наведите камеру</strong><span className="qr-step-detail">на QR-код слева</span></div></li>
<li><span className="qr-step-icon"><Smartphone size={32} /></span><div><span className="qr-step-number">02</span><strong>Откройте RideBus</strong><span className="qr-step-detail">в браузере телефона</span></div></li></ol>
</svg></a></div>
<div className="qr-description"><p>Наведите камеру на QR-код</p>
<a className="passenger-link" href={url} target="_blank" rel="noreferrer"><span>{new URL(url).host}</span><ArrowUpRight size={24} /></a>
</div></div></>;
}

View File

@@ -1,36 +1,22 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import type { Departure, Route } from './types';
import { BusFront } from './Icons';
import type { Route } from './types';
import { RouteBadge } from './RouteBadge';
import { FittedDestination } from './FittedDestination';
import { NumericText } from './NumericText';
import { localDate } from './lib/timetable.mjs';
type Props = { routes: Route[]; departures: Departure[]; now: number; paused: boolean; wide: boolean };
export function RouteCloud({ routes, departures, now, paused, wide }: Props) {
export function RouteCloud({ routes, paused }: { routes: Route[]; paused: boolean }) {
const element = useRef<HTMLDivElement>(null);
const [epoch] = useState(() => Date.now() / 1000);
const [page, setPage] = useState(() => Math.floor(Date.now() / 6500));
const [area, setArea] = useState({ width: 300, height: 300 });
const carousel = wide && area.width >= 650;
// Opposite inclines need extra clearance at the edges of a wide display.
const laneGap = Math.max(36, Math.ceil(area.width * .016) + 24);
const edge = Math.ceil(area.width * .008) + 8;
const lanes = Math.min(3, Math.max(2, Math.floor((area.height + laneGap) / (155 + laneGap))), Math.max(1, routes.length));
const cardHeight = Math.max(1, (area.height - (lanes - 1) * laneGap) / lanes);
const cardWidth = cardHeight < 150 ? 420 : Math.max(300, Math.min(380, area.width * .32));
const capacity = Math.min(Math.max(1, routes.length), Math.max(1, Math.floor(area.height / 128)));
const columns = area.width >= 1000 ? 3 : area.width >= 600 ? 2 : 1;
const capacity = columns * Math.max(1, Math.floor((area.height - 28) / 96));
const pages = Math.max(1, Math.ceil(routes.length / capacity));
const pageIndex = page % pages;
const baseSize = Math.floor(routes.length / pages), extra = routes.length % pages;
const pageSize = Math.max(1, baseSize + (pageIndex < extra ? 1 : 0));
const pageSize = baseSize + (pageIndex < extra ? 1 : 0);
const pageStart = pageIndex * baseSize + Math.min(pageIndex, extra);
const nextByRoute = useMemo(() => {
const result = new Map<number, Departure>();
for (const departure of departures) if (!result.has(departure.routeId)) result.set(departure.routeId, departure);
return result;
}, [departures]);
const rows = Math.max(1, Math.ceil(pageSize / columns));
useLayoutEffect(() => {
if (!element.current) return;
const observer = new ResizeObserver(entries => {
@@ -42,35 +28,13 @@ export function RouteCloud({ routes, departures, now, paused, wide }: Props) {
return () => observer.disconnect();
}, []);
useEffect(() => {
if (carousel || paused || pages < 2) return;
if (paused || pages < 2) return;
const timer = setInterval(() => setPage(value => value + 1), 6500);
return () => clearInterval(timer);
}, [carousel, paused, pages]);
const tile = (route: Route, key: string) => {
const next = nextByRoute.get(route.id);
return <article className="route-tile" key={key}>
<span className="route-watermark"><BusFront size={100} /></span><RouteBadge number={route.number} filled />
<div className="route-card-name"><FittedDestination text={route.name} rowHeight={carousel ? cardHeight : area.height / pageSize} />
<div className="route-card-next">{next ? <><time dateTime={next.scheduledAt}><NumericText value={next.scheduledTime} /></time>{localDate(Date.parse(next.scheduledAt)) > localDate(now) && <span>завтра</span>}</> : <span>Расписание уточняется</span>}
<span className="route-kind">{route.type === 'minibus' ? 'Маршрутка' : 'Автобус'}</span>
</div>
</div>
</article>;
};
return <div ref={element} className="route-cloud" data-carousel={carousel} data-horizontal={cardHeight < 150} style={{ '--lane-height': `${cardHeight}px`, '--route-card-width': `${cardWidth}px`, '--lane-gap': `${laneGap}px`, '--lane-edge': `${edge}px`, '--route-rows': pageSize } as CSSProperties}>
{carousel ? <>
<ul className="sr-only" aria-label="Все маршруты остановки">{routes.map(route => <li key={route.id}>№{route.number} · {route.name}</li>)}</ul>
{Array.from({ length: lanes }, (_, lane) => {
const items = routes.filter((_, index) => index % lanes === lane);
const group = items.length ? Array.from({ length: Math.max(1, Math.ceil(4 / items.length)) }, () => items).flat() : [];
const duration = Math.max(12, group.length * (cardWidth + 16) / 24);
return <div className="route-lane" key={lane} aria-hidden="true" style={{ '--lane-angle': `${lane % 2 ? .9 : -.9}deg` } as CSSProperties}>
<div className="route-track" style={{ animationDuration: `${duration}s`, animationDelay: `-${epoch % duration}s`, animationDirection: lane % 2 ? 'reverse' : 'normal', animationPlayState: paused ? 'paused' : 'running' }}>
{[0, 1].map(copy => <div className="route-group" key={copy}>{group.map((route, index) => tile(route, `${route.id}:${index}`))}</div>)}
</div>
</div>;
})}</> : <><div className="route-list">{routes.slice(pageStart, pageStart + pageSize).map((route, index) => tile(route, `position-${index}`))}</div>
{pages > 1 && <div className="route-list-pages" aria-label={`Страница маршрутов ${pageIndex + 1} из ${pages}`}><span>Через эту остановку</span><NumericText value={`${pageIndex + 1}/${pages}`} /></div>}
</>}
}, [paused, pages]);
return <div ref={element} className="route-cloud" style={{ '--route-columns': columns, '--route-rows': rows } as CSSProperties}>
{routes.length ? <><div className="route-list">{routes.slice(pageStart, pageStart + pageSize).map((route, index) => <article className="route-tile" key={`position-${index}`}>
<RouteBadge number={route.number} filled /><div className="route-card-name"><FittedDestination text={route.name} rowHeight={area.height / rows} /></div>
</article>)}</div>{pages > 1 && <div className="route-list-pages" aria-label={`Страница маршрутов ${pageIndex + 1} из ${pages}`}><NumericText value={`${pageIndex + 1}/${pages}`} /></div>}</> : <p className="info-caption">Маршруты недоступны</p>}
</div>;
}

View File

@@ -1,4 +1,4 @@
import { Cloud, Moon, Rain, Snow, Storm, Sun, Wind } from './Icons';
import { Cloud, Moon, Rain, Snow, Storm, Sun } from './Icons';
import { NumericText } from './NumericText';
import { weatherAvailable, weatherCondition } from './lib/weather.mjs';
import type { Weather } from './types';
@@ -19,12 +19,10 @@ export function WeatherSummary({ data, now, failed }: { data: Weather | null; no
}
export function WeatherCard({ data, now, loading, failed, demo }: { data: Weather | null; now: number; loading: boolean; failed: boolean; demo: boolean }) {
const available = weatherAvailable(data, now);
if (!available || !data) return <><div className="card-icon"><Cloud /></div><h3>Погода</h3><p>{demo ? 'Погода доступна с реальными данными остановки.' : loading ? 'Получаем прогноз…' : 'Прогноз сейчас недоступен.'}</p><div className="info-caption">{data ? 'Сохранённый прогноз устарел' : 'Погода обновится при подключении'}</div></>;
if (!available || !data) return <><h3>Погода</h3><p className="weather-empty">{demo ? 'Прогноз доступен для реальной остановки' : loading ? 'Получаем прогноз…' : 'Прогноз недоступен'}</p></>;
const hours = data.hourly.filter(hour => Date.parse(hour.at) > now).slice(0, 3);
const probability = hours.length ? Math.max(...hours.map(hour => hour.precipitationProbability)) : null;
return <><h3>Погода</h3><div className="weather-body"><div className="weather-now"><span className="weather-now-label">СЕЙЧАС</span><div className="weather-current"><WeatherIcon code={data.code} isDay={data.isDay} size={60} /><strong><NumericText value={degrees(data.temperature)} /></strong></div>
<p className="weather-description">{weatherCondition(data.code, data.isDay).label}</p><div className="weather-details"><span>Ощущается как <NumericText value={degrees(data.apparentTemperature)} /></span><span><Wind size={20} /><NumericText value={Math.round(data.windSpeed)} /> м/с</span></div></div><div className="weather-future">
{!!hours.length && <div className="weather-hours">{hours.map(hour => <div key={hour.at}><time dateTime={hour.at}><NumericText value={hourFormat.format(Date.parse(hour.at))} /></time><WeatherIcon code={hour.code} size={40} /><strong><NumericText value={degrees(hour.temperature)} /></strong><small className="weather-hour-rain"><NumericText value={hour.precipitationProbability} />%<span>осадки</span></small></div>)}</div>}
{probability !== null && <div className="precipitation-chance">Осадки в ближайшие 3 часа: <NumericText value={probability} />%</div>}</div></div>
<div className="weather-attribution"><span>{failed ? 'Сохранено' : 'Обновлено'} в {hourFormat.format(Date.parse(data.updatedAt))}</span><a href="https://open-meteo.com/" target="_blank" rel="noreferrer">Open-Meteo · CC BY 4.0</a></div></>;
return <><h3>Погода</h3><div className="weather-body" data-forecast={!!hours.length}><div className="weather-now"><div className="weather-current"><WeatherIcon code={data.code} isDay={data.isDay} size={72} /><strong><NumericText value={degrees(data.temperature)} /></strong></div>
<p className="weather-description">{weatherCondition(data.code, data.isDay).label}</p></div>
{!!hours.length && <div className="weather-hours" aria-label="Прогноз на следующие три часа">{hours.map(hour => <div key={hour.at}><time dateTime={hour.at}><NumericText value={hourFormat.format(Date.parse(hour.at))} /></time><WeatherIcon code={hour.code} size={48} /><strong><NumericText value={degrees(hour.temperature)} /></strong></div>)}</div>}</div>
<div className="weather-attribution">{failed && <span>Сохранённый прогноз</span>}<a href="https://open-meteo.com/" target="_blank" rel="noreferrer">Open-Meteo · CC BY 4.0</a></div></>;
}

View File

@@ -95,122 +95,66 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.info-card p { color: var(--secondary); font-size: clamp(18px, 1.75vw, 32px); line-height: 1.4; margin: 0 0 16px; }
.info-caption { display: flex; align-items: center; gap: 9px; font-size: clamp(14px, 1.3vw, 23px); color: var(--secondary); margin-top: auto; line-height: 1.4; }
.route-cloud { flex: 1 1 0; min-height: 0; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
.route-cloud[data-carousel='true'] { justify-content: center; gap: var(--lane-gap); padding: var(--lane-edge) 0; background-image: radial-gradient(color-mix(in srgb, var(--accent) 18%, transparent) 1px, transparent 1px); background-size: 26px 26px; mask-image: linear-gradient(to right, transparent, #000 4%, #000 96%, transparent); }
.route-lane { height: var(--lane-height, 180px); flex-shrink: 0; width: 100%; transform: rotate(var(--lane-angle, -.9deg)); }
.route-track { display: flex; width: max-content; animation: routes-scroll 60s linear infinite; will-change: transform; }
.route-group { display: flex; gap: 16px; padding-right: 16px; }
.route-tile { position: relative; overflow: hidden; width: var(--route-card-width, 300px); height: var(--lane-height, 180px); padding: 14px 18px; display: flex; flex-direction: column; justify-content: center; flex-shrink: 0; border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); border-radius: 22px; background: color-mix(in srgb, var(--accent) 8%, var(--background)); }
.route-cloud .route-badge { --row-height: 100px; width: 105px; height: clamp(38px, calc(var(--lane-height, 180px) * .30), 72px); font-size: clamp(30px, calc(var(--lane-height, 180px) * .28), 54px); border-radius: 16px; padding: 0 10px; margin-bottom: 12px; }
.route-card-name { width: 100%; flex: 1 1 0; min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: center; }
.route-tile .destination-heading { width: 100%; font-size: clamp(17px, calc(var(--lane-height, 180px) * .14), 26px); line-height: 1.12; margin: 0; font-weight: 550; letter-spacing: -.025em; flex-shrink: 0; overflow-wrap: anywhere; }
.route-card-next { display: flex; align-items: center; flex-wrap: wrap; gap: 5px 8px; margin-top: 10px; font-size: 12px; color: var(--secondary); flex-shrink: 0; }
.route-card-next time { display: inline-flex; color: var(--accent); font-size: clamp(21px, calc(var(--lane-height, 180px) * .13), 28px); font-weight: 650; }
.route-kind { font-size: 11px; color: var(--muted); margin-left: auto; }
.route-cloud[data-carousel='true'][data-horizontal='true'] .route-tile { flex-direction: row; align-items: center; gap: 14px; padding: 10px 14px; }
.route-cloud[data-carousel='true'][data-horizontal='true'] .route-badge { width: 72px; height: min(56px, calc(var(--lane-height) - 20px)); font-size: 36px; padding: 0 8px; margin-bottom: 0; }
.route-cloud[data-carousel='true'][data-horizontal='true'] .route-card-name { height: 100%; }
.route-cloud[data-carousel='true'][data-horizontal='true'] .route-kind { display: none; }
.route-cloud[data-carousel='true'][data-horizontal='true'] .route-card-next { margin-top: 6px; }
.route-list { flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: repeat(var(--route-rows), minmax(0, 1fr)); gap: 12px; }
.route-list .route-tile { width: 100%; height: 100%; flex-direction: row; align-items: center; gap: 14px; padding: 14px; border-radius: 18px; }
.route-list .route-badge { width: 70px; height: 62px; font-size: 35px; border-radius: 16px; margin: 0; }
.route-list .route-card-name { height: 100%; }
.route-list .destination-heading { font-size: clamp(18px, 5cqw, 25px); }
.route-list .route-card-next time { font-size: clamp(23px, 6cqw, 30px); }
.route-list-pages { display: flex; justify-content: space-between; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; padding-top: 12px; }
.route-list-pages > .numeric-text { color: var(--accent); }
.route-watermark { position: absolute; right: -18px; bottom: -22px; color: var(--accent); opacity: .06; transform: rotate(-12deg); }
@keyframes routes-scroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
.weather-body { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-height: 0; }
.weather-current { display: flex; align-items: center; gap: 18px; color: var(--accent); }
.weather-current strong { font-size: clamp(60px, 5.4vw, 100px); font-weight: 550; line-height: 1; }
.weather-info .weather-description { margin: 12px 0; font-size: clamp(20px, 1.7vw, 32px); line-height: 1.2; }
.weather-details { display: flex; flex-wrap: wrap; gap: 8px 18px; color: var(--secondary); font-size: clamp(15px, 1.15vw, 23px); }
.weather-details > span { display: flex; align-items: center; gap: 6px; }
.weather-hours { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; border-top: 1px solid var(--line); padding-top: 14px; }
.weather-hours > div { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.weather-hours time { color: var(--secondary); font-size: clamp(14px, 1vw, 22px); }
.weather-hours svg { color: var(--accent); width: 40px !important; height: 40px !important; }
.weather-hours strong { font-size: clamp(22px, 1.8vw, 34px); font-weight: 550; }
.precipitation-chance { font-size: clamp(14px, 1vw, 21px); color: var(--secondary); margin-top: 12px; line-height: 1.35; }
.weather-attribution { margin-top: auto; padding-top: 16px; display: flex; flex-wrap: wrap; gap: 5px 12px; font-size: clamp(12px, .8vw, 17px); color: var(--muted); }
.route-list { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: repeat(var(--route-columns), minmax(0, 1fr)); grid-template-rows: repeat(var(--route-rows), minmax(0, 1fr)); gap: 8px 24px; }
.route-tile { min-width: 0; min-height: 0; display: flex; align-items: center; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--line); }
.route-list .route-badge { width: 72px; height: 60px; font-size: 35px; border-radius: 16px; margin: 0; padding: 0 8px; }
.route-card-name { flex: 1 1 0; min-width: 0; height: 100%; display: flex; flex-direction: column; justify-content: center; }
.route-list .destination-heading { width: 100%; font-size: clamp(22px, 3cqw, 30px); line-height: 1.2; margin: 0; font-weight: 550; letter-spacing: -.025em; overflow-wrap: anywhere; }
.route-list-pages { display: flex; justify-content: flex-end; color: var(--muted); font-size: 16px; padding-top: 10px; }
.weather-info { container-name: weather-card; }
.weather-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); align-content: center; align-items: center; justify-items: center; gap: 24px; }
.weather-now { min-width: 0; display: flex; flex-direction: column; align-items: center; }
.weather-current { display: flex; align-items: center; gap: .18em; color: var(--accent); font-size: clamp(64px, min(22cqw, 32cqh), 144px); }
.weather-current strong { font-size: 1em; font-weight: 550; line-height: 1; }
.weather-current svg { width: .8em !important; height: .8em !important; }
.weather-info .weather-description { margin: 12px 0 0; font-size: clamp(22px, 3cqw, 34px); line-height: 1.2; text-align: center; }
.weather-hours { width: 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; border-top: 1px solid var(--line); padding-top: 20px; }
.weather-hours > div { min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 10px; }
.weather-hours time { color: var(--secondary); font-size: clamp(16px, 3cqw, 22px); }
.weather-hours svg { color: var(--accent); width: clamp(40px, min(12cqw, 16cqh), 64px) !important; height: clamp(40px, min(12cqw, 16cqh), 64px) !important; }
.weather-hours strong { font-size: clamp(24px, 6cqw, 40px); font-weight: 550; }
.weather-attribution { flex-shrink: 0; padding-top: 16px; display: flex; flex-wrap: wrap; justify-content: center; gap: 5px 12px; font-size: 12px; color: var(--muted); }
.weather-attribution a { color: inherit; text-underline-offset: 3px; }
.weather-now-label, .weather-hour-rain { display: none; }
.qr-info { text-align: left; container-name: qr-card; }
.qr-content { flex: 1 1 0; min-height: 0; width: 100%; display: grid; grid-template-columns: minmax(0, .95fr) minmax(0, 1.05fr); gap: clamp(16px, 2cqw, 36px); }
.qr-stage { position: relative; isolation: isolate; min-width: 0; min-height: 0; display: grid; place-items: center; padding: clamp(16px, 2cqw, 32px); border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); border-radius: 24px; background: color-mix(in srgb, var(--accent) 8%, var(--background)); overflow: hidden; container-type: size; }
.qr-route-backdrop { position: absolute; inset: 0; overflow: hidden; z-index: -1; }
.qr-route-art { position: absolute; inset: 0; z-index: -1; width: 100%; height: 100%; color: var(--accent); opacity: .22; transform: rotate(-12deg) scale(1.3); }
.qr-route-art path { fill: none; stroke: currentColor; stroke-width: 3; stroke-dasharray: 3 12; stroke-linecap: round; animation: qr-route-flow 12s linear infinite; }
.qr-route-art circle { fill: var(--background); stroke: currentColor; stroke-width: 3; }
.qr-stage-corner { position: absolute; right: 14px; top: 14px; color: var(--accent); }
.qr-paper { display: block; position: relative; z-index: 1; width: min(100cqw, 100cqh, 440px); aspect-ratio: 1; flex-shrink: 0; padding: 8px; border: 1px solid var(--outline); border-radius: 24px; background: #fff; box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); }
.weather-info .weather-empty { margin-block: auto; text-align: center; }
@container weather-card (min-width: 600px) {
.weather-body[data-forecast='true'] { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 32px; }
.weather-hours { border: 0; padding: 0; }
}
@container weather-card (max-height: 300px) {
.weather-body { gap: 12px; }
.weather-current { font-size: clamp(44px, min(18cqw, 20cqh), 84px); }
.weather-info .weather-description { font-size: 20px; margin-top: 8px; }
.weather-hours { gap: 8px; padding-top: 12px; }
.weather-hours > div { display: grid; grid-template-columns: auto auto; align-items: center; justify-content: center; gap: 6px; }
.weather-hours time { grid-column: 1 / -1; text-align: center; }
.weather-hours svg { width: 40px !important; height: 40px !important; }
.weather-hours strong { font-size: 24px; }
.weather-attribution { padding-top: 10px; }
}
@container weather-card (max-width: 599px) and (max-height: 380px) {
.weather-body { gap: 16px; }
.weather-hours { padding-top: 14px; gap: 8px; }
.weather-hours > div { display: grid; grid-template-columns: auto auto; align-items: center; justify-content: center; gap: 6px; }
.weather-hours time { grid-column: 1 / -1; text-align: center; }
.weather-hours svg { width: 40px !important; height: 40px !important; }
.weather-hours strong { font-size: 24px; }
}
.qr-info { container-name: qr-card; }
.qr-content { flex: 1 1 0; min-height: 0; min-width: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 16px; }
.qr-stage { min-width: 0; min-height: 0; display: grid; place-items: center; padding: 4px; container-type: size; }
.qr-paper { display: block; width: min(100cqw, 100cqh, 440px); aspect-ratio: 1; padding: 6px; border-radius: 20px; background: #fff; border: 1px solid var(--outline); }
.passenger-qr { width: 100%; height: 100%; aspect-ratio: 1; }
.qr-description { position: relative; min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: space-between; gap: 16px; padding: clamp(8px, 1cqw, 18px) 0; }
.qr-eyebrow { display: block; color: var(--accent); font-size: clamp(12px, 1.4cqw, 20px); letter-spacing: .12em; margin-bottom: 12px; }
.qr-stop { margin: 0; font-size: clamp(26px, 4.3cqw, 70px); line-height: 1.08; letter-spacing: -.04em; font-weight: 600; overflow-wrap: anywhere; }
.qr-info .qr-summary { font-size: clamp(18px, 2.2cqw, 32px); line-height: 1.3; margin: 14px 0 0; }
.qr-steps { margin: 0; padding: 0; display: grid; gap: clamp(12px, 1.5cqw, 22px); list-style: none; }
.qr-steps li { display: flex; align-items: center; gap: 14px; }
.qr-step-icon { display: grid; place-items: center; width: clamp(42px, 5cqw, 68px); height: clamp(42px, 5cqw, 68px); color: var(--accent); border: 1px solid var(--line); border-radius: 16px; background: color-mix(in srgb, var(--accent) 5%, var(--background)); flex-shrink: 0; }
.qr-steps li > div { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 5px 9px; }
.qr-step-number { font-size: clamp(12px, 1.4cqw, 20px); color: var(--accent); align-self: center; }
.qr-steps strong { font-size: clamp(17px, 2cqw, 29px); font-weight: 550; line-height: 1.15; }
.qr-step-detail { grid-column: 2; color: var(--muted); font-size: clamp(13px, 1.5cqw, 21px); }
.passenger-link { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--accent); text-decoration: none; font-size: clamp(18px, 2.1cqw, 30px); font-weight: 550; border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); background: color-mix(in srgb, var(--accent) 8%, var(--background)); border-radius: 16px; padding: 14px 18px; }
.qr-description { min-width: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 12px; text-align: center; }
.qr-info .qr-description p { font-size: clamp(18px, 3cqw, 40px); line-height: 1.25; margin: 0; }
.passenger-link { display: inline-flex; align-items: center; gap: 8px; color: var(--accent); text-decoration: none; font-size: clamp(18px, 2.5cqw, 30px); font-weight: 550; }
.passenger-link span { min-width: 0; overflow-wrap: anywhere; }
@keyframes qr-route-flow { to { stroke-dashoffset: -150; } }
@container qr-card (max-width: 600px) {
.qr-content { display: flex; flex-direction: column; gap: 12px; }
.qr-stage { flex: 1 1 0; min-height: 0; padding: 12px; border-radius: 20px; }
.qr-stage-corner { display: none; }
.qr-paper { max-width: 100%; border-radius: 20px; padding: 6px; }
.qr-description { padding: 0; gap: 12px; flex-shrink: 0; }
.qr-eyebrow { font-size: 11px; margin-bottom: 6px; }
.qr-stop { font-size: clamp(22px, 6cqw, 30px); }
.qr-info .qr-summary, .qr-step-detail { display: none; }
.qr-steps { gap: 8px; }
.qr-steps li { gap: 9px; }
.qr-step-icon { width: 30px; height: 30px; border: 0; border-radius: 9px; }
.qr-step-icon svg { width: 24px; height: 24px; }
.qr-steps strong { font-size: 15px; }
.qr-step-number { font-size: 11px; }
.passenger-link { font-size: 18px; padding: 10px 12px; border-radius: 12px; }
@container qr-card (min-width: 601px) {
.qr-content { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); gap: 32px; }
.qr-description { align-items: flex-start; text-align: left; }
}
@container qr-card (min-width: 601px) and (max-height: 420px) {
.qr-description { gap: 12px; padding: 0; }
.qr-eyebrow, .qr-step-detail { display: none; }
.qr-stop { font-size: clamp(26px, 3.5cqw, 40px); }
.qr-info .qr-summary { font-size: 18px; margin-top: 10px; }
.qr-steps { gap: 10px; }
.qr-step-icon { width: 42px; height: 42px; }
.qr-steps strong { font-size: 20px; }
.passenger-link { font-size: 22px; padding: 10px 14px; }
}
@container qr-card (min-width: 601px) and (max-height: 320px) {
.qr-description { gap: 10px; padding: 0; }
.qr-eyebrow, .qr-info .qr-summary, .qr-step-detail { display: none; }
.qr-stop { font-size: clamp(26px, 3.5cqw, 40px); }
.qr-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.qr-steps li { gap: 8px; }
.qr-step-icon { width: 36px; height: 36px; border: 0; }
.qr-step-icon svg { width: 28px; height: 28px; }
.qr-steps li > div { display: flex; flex-direction: column; gap: 3px; }
.qr-steps strong { font-size: 17px; }
.qr-step-number { align-self: flex-start; font-size: 12px; }
.passenger-link { font-size: 22px; padding: 10px 14px; }
}
@container qr-card (max-width: 600px) and (max-height: 380px) {
.qr-content { display: flex; flex-direction: column; }
.qr-stage { padding: 10px; }
.qr-paper { padding: 5px; border-radius: 16px; }
.qr-intro, .qr-steps li:nth-child(2) { display: none; }
.qr-description { flex-direction: row; align-items: center; justify-content: space-between; gap: 10px; }
.qr-step-number { display: none; }
.qr-steps li > div { display: block; }
.qr-step-icon { display: none; }
.qr-steps strong { font-size: 14px; }
.passenger-link { font-size: 14px; padding: 8px; gap: 4px; }
@container qr-card (max-width: 600px) and (max-height: 300px) {
.qr-description { gap: 6px; }
.qr-info .qr-description p, .passenger-link { font-size: 16px; }
.passenger-link svg { width: 18px; height: 18px; }
}
.info-screen[data-compact='true'] .info-grid { grid-template-columns: minmax(0, 1fr); }
@@ -220,14 +164,10 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.info-status { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: var(--secondary); font-size: clamp(14px, 1.2vw, 22px); padding-top: 14px; line-height: 1.3; }
.info-status svg { color: var(--accent); }
.info-notices { display: grid; gap: 18px; }
.notice { --notice-color: var(--accent); position: relative; isolation: isolate; display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 24px; padding: clamp(20px, 2vw, 36px); border: 1px solid color-mix(in srgb, var(--notice-color) 35%, var(--line)); border-radius: 26px; background: color-mix(in srgb, var(--notice-color) 5%, var(--background)); overflow: hidden; }
.notice { --notice-color: var(--accent); display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 20px; padding: clamp(20px, 2vw, 32px); border: 1px solid var(--line); border-radius: 24px; background: var(--background); overflow: hidden; }
.notice[data-kind='cancellation'], .notice[data-kind='delay'] { --notice-color: var(--warning); }
.notice::after { content: ''; position: absolute; z-index: -1; width: 36%; top: -50%; right: -5%; height: 200%; transform: rotate(16deg); background: repeating-linear-gradient(0deg, transparent 0 24px, color-mix(in srgb, var(--notice-color) 5%, transparent) 24px 25px); pointer-events: none; }
.notice-graphic { display: flex; flex-direction: column; align-items: center; color: var(--notice-color); padding: 2px 0; }
.notice-signal { display: grid; place-items: center; width: 76px; height: 76px; border-radius: 23px; background: color-mix(in srgb, var(--notice-color) 16%, var(--background)); border: 1px solid color-mix(in srgb, var(--notice-color) 45%, var(--line)); }
.notice-line { width: 2px; flex: 1 1 0; min-height: 30px; margin: 10px 0; background: linear-gradient(var(--notice-color), color-mix(in srgb, var(--notice-color) 20%, transparent)); }
.notice[data-kind='cancellation'] .notice-line { background: repeating-linear-gradient(to bottom, var(--notice-color) 0 5px, transparent 5px 11px); }
.notice-endpoint { width: 12px; height: 12px; border: 2px solid var(--notice-color); border-radius: 50%; }
.notice-signal { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 14px; background: color-mix(in srgb, var(--notice-color) 10%, var(--background)); }
.notice-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px 18px; margin-bottom: 16px; }
.notice-type { color: var(--notice-color); font-size: clamp(14px, 1.2vw, 22px); font-weight: 650; }
.notice-period { color: var(--secondary); font-size: clamp(13px, 1vw, 18px); }
@@ -235,8 +175,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.notice-routes .route-badge { width: auto; min-width: 54px; height: 42px; padding: 0 14px; font-size: 26px; border-radius: 12px; background: var(--notice-color); border-color: var(--notice-color); }
.notice h3 { font-size: clamp(25px, 2.6vw, 46px); font-weight: 600; line-height: 1.12; margin: 0 0 14px; letter-spacing: -.035em; overflow-wrap: anywhere; }
.notice p { font-size: clamp(18px, 1.65vw, 30px); line-height: 1.45; color: var(--secondary); margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.notice-scope { display: flex; align-items: center; gap: 8px; margin-top: 18px; font-size: 14px; color: var(--notice-color); }
.notice-watermark { position: absolute; right: -30px; bottom: -50px; color: var(--notice-color); opacity: .04; transform: rotate(-15deg); z-index: -1; }
.persistent-next { display: flex; align-items: center; gap: 18px; border-top: 1px solid var(--line); padding: 14px 0; flex: 0 0 auto; --row-height: 72px; }
.persistent-next .route-badge { width: 78px; height: 50px; font-size: 31px; padding: 0 10px; border-radius: 14px; }
.persistent-destination { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
@@ -346,33 +284,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.settings-scroll-button { min-width: 78px; padding-inline: 8px; }
}
@container (max-height: 380px) {
.weather-current strong { font-size: 56px; }
.weather-info .weather-description { font-size: 20px; margin: 10px 0; }
.weather-body { gap: 12px; }
.weather-hours > div { display: grid; grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 7px; }
.weather-hours time { grid-column: 1 / -1; text-align: center; }
.weather-hours strong { font-size: 22px; }
.weather-attribution { padding-top: 12px; }
}
@media (min-width: 701px) {
.info-screen[data-compact='true'] .weather-body { flex: 1 1 0; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: 32px; }
.info-screen[data-compact='true'] .weather-now { height: 100%; min-height: 0; display: flex; flex-direction: column; justify-content: center; padding: clamp(12px, 1.5cqw, 24px); border: 1px solid var(--line); border-radius: 22px; background: color-mix(in srgb, var(--accent) 5%, var(--background)); }
.info-screen[data-compact='true'] .weather-now-label { display: block; font-size: clamp(12px, 1.5cqw, 20px); color: var(--muted); letter-spacing: .1em; margin-bottom: 12px; }
.info-screen[data-compact='true'] .weather-current { font-size: clamp(48px, min(13cqw, 28cqh), 180px); gap: .16em; }
.info-screen[data-compact='true'] .weather-current strong { font-size: 1em; }
.info-screen[data-compact='true'] .weather-current svg { width: .8em !important; height: .8em !important; }
.info-screen[data-compact='true'] .weather-description { font-size: clamp(20px, 3cqw, 36px); margin: 12px 0; }
.info-screen[data-compact='true'] .weather-details { font-size: clamp(16px, 2cqw, 23px); }
.info-screen[data-compact='true'] .weather-future { height: 100%; display: flex; flex-direction: column; min-height: 0; }
.info-screen[data-compact='true'] .weather-hours { flex: 1 1 0; min-height: 0; padding-top: 0; border: 0; }
.info-screen[data-compact='true'] .weather-hours > div { display: flex; justify-content: space-between; min-height: 0; padding: clamp(8px, 1cqw, 18px) 6px; gap: 8px; border: 1px solid var(--line); border-radius: 18px; background: color-mix(in srgb, var(--accent) 5%, var(--background)); }
.info-screen[data-compact='true'] .weather-hours time { font-size: clamp(15px, 2cqw, 26px); }
.info-screen[data-compact='true'] .weather-hours svg { width: clamp(36px, 5cqw, 64px) !important; height: clamp(36px, 5cqw, 64px) !important; }
.info-screen[data-compact='true'] .weather-hours strong { font-size: clamp(26px, 3.5cqw, 48px); }
.info-screen[data-compact='true'] .weather-hour-rain { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 2px 4px; font-size: clamp(12px, 1.5cqw, 20px); color: var(--secondary); }
.info-screen[data-compact='true'] .precipitation-chance { text-align: center; font-size: 17px; }
}
@media (max-width: 1100px) {
.brand-word { display: none; }
.header-right { column-gap: 18px; }
@@ -465,10 +376,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.information-tabs button { font-size: 12px; padding: 7px 10px; }
.info-card { padding: 18px; }
.info-card h3 { font-size: 24px; margin-bottom: 12px; }
.weather-current strong { font-size: 58px; }
.weather-info .weather-description { font-size: 20px; }
.weather-hours { padding-top: 12px; }
.weather-body { gap: 12px; }
.weather-attribution { padding-top: 12px; }
.info-status { font-size: 12px; padding-top: 10px; }
.notice { grid-template-columns: 44px minmax(0, 1fr); gap: 14px; padding: 18px 14px; border-radius: 20px; }
@@ -498,13 +405,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.info-screen[data-compact='true'][data-cards='true'] .info-intro { position: absolute; top: 27px; right: 20px; z-index: 1; margin: 0; }
.info-screen[data-compact='true'][data-cards='true'] .info-intro h2 { display: none; }
.info-screen[data-compact='true'][data-cards='true'] .info-card > h3 { padding-right: 290px; min-height: 36px; text-align: left; display: flex; align-items: center; }
.info-screen[data-compact='true'] .weather-now-label { margin-bottom: 8px; }
.info-screen[data-compact='true'] .weather-description { font-size: 20px; margin: 8px 0; }
.info-screen[data-compact='true'] .weather-details { font-size: 15px; }
.info-screen[data-compact='true'] .weather-hours strong { font-size: 26px; }
.info-screen[data-compact='true'] .weather-hours time { font-size: 15px; }
.info-screen[data-compact='true'] .weather-hours svg { width: 36px !important; height: 36px !important; }
.info-screen[data-compact='true'] .weather-hour-rain { font-size: 12px; }
.info-status { font-size: 13px; padding-top: 10px; }
.persistent-next > .eyebrow { display: none; }
.persistent-next { padding: 10px 0; }
@@ -512,13 +412,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
@media (max-width: 700px) and (max-height: 740px) {
.info-intro h2 { font-size: 25px; }
.info-intro { gap: 8px; }
.weather-current strong { font-size: 46px; }
.weather-current svg { width: 44px !important; height: 44px !important; }
.weather-info .weather-description { font-size: 18px; margin: 8px 0; }
.weather-body { gap: 8px; }
.weather-hours { padding-top: 8px; }
.weather-hours > div { gap: 6px; }
.precipitation-chance { margin-top: 8px; }
.weather-attribution { padding-top: 8px; font-size: 10px; }
.weather-info { padding: 16px; }
}
@@ -530,8 +423,4 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.numeric-current { transform: none !important; }
.destination-old { visibility: hidden; }
.destination-current { transform: none !important; }
.route-track { animation: none !important; }
.qr-route-art path { animation: none !important; }
.route-lane { transform: none; overflow: auto; }
.route-group:nth-child(2) { display: none; }
}