feat: enhance departure status display and loading state handling

This commit is contained in:
2026-10-25 20:28:56 +03:00
parent 6811c0406a
commit 6deac42033
4 changed files with 26 additions and 6 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ dist/
test-results/ test-results/
playwright-report/ playwright-report/
.DS_Store .DS_Store
.impeccable/

View File

@@ -52,7 +52,11 @@ function ScheduledTime({ departure, now, className = '', showDay = true }: { dep
function DepartureStatus({ departure, now, large = false }: { departure: DisplayDeparture; now: number; large?: boolean }) { function DepartureStatus({ departure, now, large = false }: { departure: DisplayDeparture; now: number; large?: boolean }) {
return <div className={`departure-status ${large ? 'departure-status-large' : ''}`}> return <div className={`departure-status ${large ? 'departure-status-large' : ''}`}>
{departure.status === 'cancelled' ? <span className="cancelled-label"><Close size={22} />Отменён</span> : <Countdown departure={departure} now={now} large={large} />} {departure.status === 'cancelled' ? <span className="cancelled-label"><Close size={22} />Отменён</span> : <Countdown departure={departure} now={now} large={large} />}
{departure.status === 'notice' && <span className="departure-change">Есть изменения</span>} {/* The large countdown must not read as a live arrival estimate. One line
with the change flag keeps the hero's height unchanged. */}
{large && departure.status !== 'cancelled' ? <span className="countdown-meta"><span className="countdown-source" aria-hidden="true">по расписанию</span>
{departure.status === 'notice' && <span className="departure-change">Есть изменения</span>}</span>
: departure.status === 'notice' && <span className="departure-change">Есть изменения</span>}
</div>; </div>;
} }
@@ -64,6 +68,7 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo
const next = settings.showNextSameRoute ? nextSameRoute(departures, first) : undefined; const next = settings.showNextSameRoute ? nextSameRoute(departures, first) : undefined;
const waiting = longWait(settings, first, now); const waiting = longWait(settings, first, now);
const reading = readability(settings); const reading = readability(settings);
const lastTrip = Boolean(first && first.id === lastTripId);
const table = useRef<HTMLDivElement>(null); const table = useRef<HTMLDivElement>(null);
const [tableHeight, setTableHeight] = useState(360); const [tableHeight, setTableHeight] = useState(360);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@@ -83,7 +88,7 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo
return () => clearInterval(timer); return () => clearInterval(timer);
}, [paused, visible.pages, reading.calm]); }, [paused, visible.pages, reading.calm]);
return <div className="departure-layout"> return <div className="departure-layout">
<section className="departure-content" data-long-wait={waiting} data-next={Boolean(next)} aria-label="Ближайшие рейсы"> <section className="departure-content" data-long-wait={waiting} data-next={Boolean(next)} data-last-trip={lastTrip} aria-label="Ближайшие рейсы">
<div className="section-heading"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span><span className="source-label"><Clock /> По расписанию{data?.mode === 'demo' ? ' · демо' : ''}</span></div> <div className="section-heading"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span><span className="source-label"><Clock /> По расписанию{data?.mode === 'demo' ? ' · демо' : ''}</span></div>
{first ? <div className="hero-departure" data-long-wait={waiting}> {first ? <div className="hero-departure" data-long-wait={waiting}>
<RouteBadge number={first.routeNumber} large /> <RouteBadge number={first.routeNumber} large />
@@ -94,7 +99,7 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo
{waiting ? <div className="long-wait-time"><span>{localDate(Date.parse(first.scheduledAt)) > localDate(now) ? 'Завтра в' : 'Сегодня в'}</span><ScheduledTime departure={first} now={now} showDay={false} />{first.status === 'notice' && <span className="departure-change">Есть изменения</span>}</div> : <DepartureStatus departure={first} now={now} large />} {waiting ? <div className="long-wait-time"><span>{localDate(Date.parse(first.scheduledAt)) > localDate(now) ? 'Завтра в' : 'Сегодня в'}</span><ScheduledTime departure={first} now={now} showDay={false} />{first.status === 'notice' && <span className="departure-change">Есть изменения</span>}</div> : <DepartureStatus departure={first} now={now} large />}
</div> : <div className="empty-departures" data-cancelled={departures.length > 0}><BusFront size={46} /><h2>{departures.length ? 'Ближайшие рейсы отменены' : loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}</h2> </div> : <div className="empty-departures" data-cancelled={departures.length > 0}><BusFront size={46} /><h2>{departures.length ? 'Ближайшие рейсы отменены' : loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}</h2>
<p>{departures.length ? 'Подробности — в информации о движении' : loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}</p></div>} <p>{departures.length ? 'Подробности — в информации о движении' : loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}</p></div>}
{first && first.id === lastTripId && <LastTripNotice number={first.routeNumber} />} {first && lastTrip && <LastTripNotice number={first.routeNumber} />}
<div className="next-heading"><span className="eyebrow">{!first && departures.length ? 'ОТМЕНЁННЫЕ РЕЙСЫ' : remaining.some(item => item.status === 'cancelled') ? 'ДРУГИЕ РЕЙСЫ' : 'СЛЕДОМ ПРИБУДУТ'}</span>{visible.pages > 1 ? <span className="page-indicator">{visible.start + 1}{visible.items.length > 1 ? `–${visible.start + visible.items.length}` : ''} из {visible.count}</span> : <ArrowDown size={22} />}</div> <div className="next-heading"><span className="eyebrow">{!first && departures.length ? 'ОТМЕНЁННЫЕ РЕЙСЫ' : remaining.some(item => item.status === 'cancelled') ? 'ДРУГИЕ РЕЙСЫ' : 'СЛЕДОМ ПРИБУДУТ'}</span>{visible.pages > 1 ? <span className="page-indicator">{visible.start + 1}{visible.items.length > 1 ? `–${visible.start + visible.items.length}` : ''} из {visible.count}</span> : <ArrowDown size={22} />}</div>
<div ref={table} className="departure-table" data-rows={rowCount} data-paged={visible.pages > 1} style={{ '--rows': rows, '--row-height': `${tableHeight / rows}px` } as CSSProperties}> <div ref={table} className="departure-table" data-rows={rowCount} data-paged={visible.pages > 1} style={{ '--rows': rows, '--row-height': `${tableHeight / rows}px` } as CSSProperties}>
{visible.items.map((departure, index) => <article className="departure-row" data-status={departure.status} key={`position-${index}`}> {visible.items.map((departure, index) => <article className="departure-row" data-status={departure.status} key={`position-${index}`}>
@@ -309,7 +314,9 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
<div className="stop-meta">{direction && <div className="stop-direction"><ArrowRight /><span>{direction}</span></div>}<WeatherSummary data={weather.data} now={now} failed={weather.failed} /></div></div> <div className="stop-meta">{direction && <div className="stop-direction"><ArrowRight /><span>{direction}</span></div>}<WeatherSummary data={weather.data} now={now} failed={weather.failed} /></div></div>
<div className="header-right"><div className="brand" aria-label="RideBus"><span className="official-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: logo }} /><span className="brand-word">ridebus</span></div><div className="clock" aria-label={`Текущее время ${night.active ? `${timeParts[0]}:${timeParts[1]}` : clockFormat.format(now)}`}><NumericText value={`${timeParts[0]}:${timeParts[1]}`} />{!night.active && <small><NumericText value={timeParts[2]} /></small>}</div><div className="today">{dateFormat.format(now)}</div></div> <div className="header-right"><div className="brand" aria-label="RideBus"><span className="official-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: logo }} /><span className="brand-word">ridebus</span></div><div className="clock" aria-label={`Текущее время ${night.active ? `${timeParts[0]}:${timeParts[1]}` : clockFormat.format(now)}`}><NumericText value={`${timeParts[0]}:${timeParts[1]}`} />{!night.active && <small><NumericText value={timeParts[2]} /></small>}</div><div className="today">{dateFormat.format(now)}</div></div>
</header> </header>
{!fresh && !settings.demo && <div className="offline-banner" role="status"><WifiOff /><span>{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}</span><button onClick={refresh} aria-label="Повторить подключение"><Refresh /></button></div>} {!fresh && !settings.demo && (loading
? <div className="offline-banner" data-state="loading" role="status"><Refresh /><span>{data ? 'Обновляем расписание…' : 'Подключаемся к RideBus…'}</span></div>
: <div className="offline-banner" role="status"><WifiOff /><span>{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : 'Не удалось загрузить расписание. Проверьте подключение.'}</span><button onClick={refresh} aria-label="Повторить подключение"><Refresh /></button></div>)}
{management?.failed && <div className="managed-offline" role="status">Нет связи с управлением · сохранённые настройки</div>} {management?.failed && <div className="managed-offline" role="status">Нет связи с управлением · сохранённые настройки</div>}
<main className="scene-container"><div className="scene" key={scene} data-scene={scene}> <main className="scene-container"><div className="scene" key={scene} data-scene={scene}>
{night.active && night.first && <NightScreen first={night.first} completedToday={night.completedToday} now={now} routeFiltered={Boolean(settings.routeFilter)} alerts={alerts} paused={paused || settingsOpen} />} {night.active && night.first && <NightScreen first={night.first} completedToday={night.completedToday} now={now} routeFiltered={Boolean(settings.routeFilter)} alerts={alerts} paused={paused || settingsOpen} />}
@@ -321,7 +328,7 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
{!night.active && scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{first ? <><RouteBadge number={first.routeNumber} filled /><div className="persistent-destination"><strong>{first.destination}</strong><ScheduledTime departure={first} now={now} />{guide.lastTripId === first.id && <LastTripNotice number={first.routeNumber} compact />}</div><Countdown departure={first} now={now} /></> : <span>Нет доступных рейсов</span>}</div>} {!night.active && scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{first ? <><RouteBadge number={first.routeNumber} filled /><div className="persistent-destination"><strong>{first.destination}</strong><ScheduledTime departure={first} now={now} />{guide.lastTripId === first.id && <LastTripNotice number={first.routeNumber} compact />}</div><Countdown departure={first} now={now} /></> : <span>Нет доступных рейсов</span>}</div>}
<footer className="board-footer"><div className="cycle-progress"><span style={{ transform: `scaleX(${held || paused || settingsOpen || scenes.length < 2 ? 0 : progress})` }} /></div> <footer className="board-footer"><div className="cycle-progress"><span style={{ transform: `scaleX(${held || paused || settingsOpen || scenes.length < 2 ? 0 : progress})` }} /></div>
<div className="footer-main"><nav className="scene-navigation" aria-label="Экраны табло">{scenes.map((item, index) => <button key={item} onClick={() => goTo(item)} className={scene === item ? 'active' : ''} aria-label={`0${index + 1} ${night.active ? 'Ночной режим' : sceneNames[item]}`} title={night.active ? 'Ночной режим' : sceneNames[item]} aria-current={scene === item ? 'page' : undefined}><span className="scene-index">0{index + 1}</span><span>{night.active ? 'Ночной режим' : sceneNames[item]}</span></button>)}</nav> <div className="footer-main"><nav className="scene-navigation" aria-label="Экраны табло">{scenes.map((item, index) => <button key={item} onClick={() => goTo(item)} className={scene === item ? 'active' : ''} aria-label={`0${index + 1} ${night.active ? 'Ночной режим' : sceneNames[item]}`} title={night.active ? 'Ночной режим' : sceneNames[item]} aria-current={scene === item ? 'page' : undefined}><span className="scene-index">0{index + 1}</span><span>{night.active ? 'Ночной режим' : sceneNames[item]}</span></button>)}</nav>
<div className="footer-status">{!fresh && !settings.demo && <WifiOff />}<span>{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}</span></div> <div className="footer-status">{!fresh && !settings.demo && !loading && <WifiOff />}<span>{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}</span></div>
<div className="board-controls"><button className="icon-button" onClick={() => { setPaused(value => !value); setStartedAt(Date.now()); }} aria-label={paused ? 'Продолжить смену экранов' : 'Приостановить смену экранов'} title="Пауза · пробел">{paused ? <Play /> : <Pause />}</button><button className="icon-button" onClick={nextScene} aria-label="Следующий экран" title="Следующий экран · →"><ArrowRight /></button><button className="icon-button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? 'Выйти из полного экрана' : 'На весь экран'} title="Полный экран · F"><Fullscreen /></button><button className="icon-button" onClick={() => setSettingsOpen(true)} aria-label="Настройки табло" title="Настройки · S"><SettingsIcon /></button></div></div> <div className="board-controls"><button className="icon-button" onClick={() => { setPaused(value => !value); setStartedAt(Date.now()); }} aria-label={paused ? 'Продолжить смену экранов' : 'Приостановить смену экранов'} title="Пауза · пробел">{paused ? <Play /> : <Pause />}</button><button className="icon-button" onClick={nextScene} aria-label="Следующий экран" title="Следующий экран · →"><ArrowRight /></button><button className="icon-button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? 'Выйти из полного экрана' : 'На весь экран'} title="Полный экран · F"><Fullscreen /></button><button className="icon-button" onClick={() => setSettingsOpen(true)} aria-label="Настройки табло" title="Настройки · S"><SettingsIcon /></button></div></div>
</footer> </footer>
{notice && <div className="toast" role="status">{notice}</div>} {notice && <div className="toast" role="status">{notice}</div>}

View File

@@ -62,8 +62,10 @@ export function useBoard(settings: Settings, config: Config) {
const demoTimer = setInterval(() => setData(createDemo()), 60000); const demoTimer = setInterval(() => setData(createDemo()), 60000);
return () => clearInterval(demoTimer); return () => clearInterval(demoTimer);
} }
// A saved snapshot fills the screen while the first request is still in
// flight; that is not a connection failure until the request fails.
readSnapshot(config.cityId, settings.stopId).then(cached => { readSnapshot(config.cityId, settings.stopId).then(cached => {
if (!stopped && !received && cached) { setData(cached); setFailed(true); setLoading(false); setOfflineSaved(true); } if (!stopped && !received && cached) { setData(cached); setOfflineSaved(true); }
}).catch(() => { /* Offline storage may be unavailable in private mode. */ }); }).catch(() => { /* Offline storage may be unavailable in private mode. */ });
async function load() { async function load() {
if (running || stopped) return; if (running || stopped) return;

View File

@@ -33,6 +33,8 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.scene { width: 100%; height: 100%; min-height: 0; animation: scene-in 400ms ease-out both; } .scene { width: 100%; height: 100%; min-height: 0; animation: scene-in 400ms ease-out both; }
.departure-layout { display: flex; flex-direction: column; height: 100%; min-height: 0; padding: clamp(12px, 1.8vh, 24px) 0; } .departure-layout { display: flex; flex-direction: column; height: 100%; min-height: 0; padding: clamp(12px, 1.8vh, 24px) 0; }
.departure-content { flex: 1 1 0; min-height: 0; min-width: 0; display: grid; grid-template-rows: auto calc(var(--hero-height) + var(--hero-gap)) auto minmax(0, 1fr); } .departure-content { flex: 1 1 0; min-height: 0; min-width: 0; display: grid; grid-template-rows: auto calc(var(--hero-height) + var(--hero-gap)) auto minmax(0, 1fr); }
/* The last-trip notice is an extra child; give it its own row so the table keeps the flexible one. */
.departure-content[data-last-trip='true'] { grid-template-rows: auto calc(var(--hero-height) + var(--hero-gap)) auto auto minmax(0, 1fr); }
.section-heading, .next-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; min-width: 0; } .section-heading, .next-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; min-width: 0; }
.eyebrow { font-size: clamp(12px, 1.05vw, 21px); font-weight: 650; letter-spacing: .12em; color: var(--muted); } .eyebrow { font-size: clamp(12px, 1.05vw, 21px); font-weight: 650; letter-spacing: .12em; color: var(--muted); }
.source-label { display: flex; align-items: center; gap: 8px; color: var(--secondary); font-size: clamp(13px, 1.15vw, 23px); white-space: nowrap; } .source-label { display: flex; align-items: center; gap: 8px; color: var(--secondary); font-size: clamp(13px, 1.15vw, 23px); white-space: nowrap; }
@@ -78,6 +80,7 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.quiet-placeholder { display: none; } .quiet-placeholder { display: none; }
.offline-banner { display: flex; align-items: center; gap: 9px; color: var(--warning); font-size: clamp(13px, 1.2vw, 22px); padding-top: 9px; flex: 0 0 auto; } .offline-banner { display: flex; align-items: center; gap: 9px; color: var(--warning); font-size: clamp(13px, 1.2vw, 22px); padding-top: 9px; flex: 0 0 auto; }
.offline-banner button { color: inherit; border: 0; background: none; display: flex; padding: 3px; margin-left: auto; } .offline-banner button { color: inherit; border: 0; background: none; display: flex; padding: 3px; margin-left: auto; }
.offline-banner[data-state='loading'] { color: var(--secondary); }
/* Cards switch to one panel when the actual available area is too small. */ /* Cards switch to one panel when the actual available area is too small. */
.info-screen { height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 16px 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--outline) var(--background); } .info-screen { height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 16px 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--outline) var(--background); }
@@ -564,6 +567,9 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.hero-next time { color: var(--accent); display: inline-flex; align-items: baseline; gap: 6px; font-weight: 650; } .hero-next time { color: var(--accent); display: inline-flex; align-items: baseline; gap: 6px; font-weight: 650; }
.departure-status { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 6px; } .departure-status { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 6px; }
.departure-change { color: var(--warning); font-size: clamp(12px, calc(1.1vw * var(--reading-scale)), 20px); line-height: 1.2; } .departure-change { color: var(--warning); font-size: clamp(12px, calc(1.1vw * var(--reading-scale)), 20px); line-height: 1.2; }
.countdown-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: baseline; gap: 2px .7em; text-align: right; font-size: clamp(14px, calc(var(--hero-height) * .12), calc(24px * var(--reading-scale))); line-height: 1.2; }
.countdown-meta .departure-change { font-size: 1em; }
.countdown-source { color: var(--secondary); font-weight: 500; white-space: nowrap; }
.cancelled-label { display: inline-flex; align-items: center; gap: 8px; color: var(--warning); font-size: min(calc(26px * var(--reading-scale)), calc(var(--row-height, 96px) * .31), 5.5vw); font-weight: 650; white-space: nowrap; } .cancelled-label { display: inline-flex; align-items: center; gap: 8px; color: var(--warning); font-size: min(calc(26px * var(--reading-scale)), calc(var(--row-height, 96px) * .31), 5.5vw); font-weight: 650; white-space: nowrap; }
.cancelled-label svg { width: .9em; height: .9em; } .cancelled-label svg { width: .9em; height: .9em; }
.departure-row[data-status='cancelled'] .route-badge { color: var(--warning); border-color: var(--warning); } .departure-row[data-status='cancelled'] .route-badge { color: var(--warning); border-color: var(--warning); }
@@ -597,6 +603,9 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.hero-next { font-size: 15px; gap: 4px 7px; margin-top: 6px; } .hero-next { font-size: 15px; gap: 4px 7px; margin-top: 6px; }
.departure-status { gap: 4px; } .departure-status { gap: 4px; }
.departure-change { font-size: 12px; } .departure-change { font-size: 12px; }
/* Wrap within the countdown's width rather than squeezing the destination. */
.countdown-meta { font-size: 13px; width: min-content; min-width: 100%; }
.countdown-source { white-space: normal; }
.hero-departure[data-long-wait='true'] { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; gap: 8px 12px; } .hero-departure[data-long-wait='true'] { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; gap: 8px 12px; }
.hero-departure[data-long-wait='true'] > .route-badge { grid-row: 1 / 3; width: min(calc(var(--hero-height) * .7), 110px); height: min(calc(var(--hero-height) - 10px), 160px); } .hero-departure[data-long-wait='true'] > .route-badge { grid-row: 1 / 3; width: min(calc(var(--hero-height) * .7), 110px); height: min(calc(var(--hero-height) - 10px), 160px); }
.hero-departure[data-long-wait='true'] .transport-kind { display: none; } .hero-departure[data-long-wait='true'] .transport-kind { display: none; }
@@ -608,6 +617,7 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.board[data-distance='far'] .hero-departure:not([data-long-wait='true']) > .route-badge { grid-row: 1 / 3; width: 110px; height: min(calc(var(--hero-height) - 10px), 160px); } .board[data-distance='far'] .hero-departure:not([data-long-wait='true']) > .route-badge { grid-row: 1 / 3; width: 110px; height: min(calc(var(--hero-height) - 10px), 160px); }
.board[data-distance='far'] .hero-departure:not([data-long-wait='true']) .transport-kind { display: none; } .board[data-distance='far'] .hero-departure:not([data-long-wait='true']) .transport-kind { display: none; }
.board[data-distance='far'] .hero-departure > .departure-status { grid-column: 2; align-items: flex-start; } .board[data-distance='far'] .hero-departure > .departure-status { grid-column: 2; align-items: flex-start; }
.board[data-distance='far'] .hero-departure .countdown-meta { justify-content: flex-start; text-align: left; }
.board[data-distance='far'] .hero-departure .countdown-large .countdown-value { font-size: 64px; } .board[data-distance='far'] .hero-departure .countdown-large .countdown-value { font-size: 64px; }
} }
::view-transition { pointer-events: none; } ::view-transition { pointer-events: none; }