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

@@ -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; }
}