diff --git a/README.md b/README.md
index 9b5439d..5c5cf68 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# RideBus Board
-Табло остановки: React + TypeScript + Vite, сервер Node.js / Express. Фон строго `#000000`, локальный шрифт Manrope, контрастная белая типографика, настраиваемый акцент, иконки `@vkontakte/icons` и официальный SVG-логотип из RideBus PWA. По умолчанию — **Полоцк, Швейная фабрика, stopId=1**. Реальный API подтверждает эту остановку и 22 маршрута.
+Табло остановки: React + TypeScript + Vite, сервер Node.js / Express. Тёмная тема с фоном `#000000` и светлая с `#FFFFFF`, локальный шрифт Manrope, контрастная типографика, отдельные акцентные палитры, иконки `@vkontakte/icons` и официальный SVG-логотип из RideBus PWA. По умолчанию — **Полоцк, Швейная фабрика, stopId=1**. Реальный API подтверждает эту остановку и 22 маршрута.
## Запуск
@@ -18,17 +18,19 @@ npm start
## Возможности
-- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Даже 6 следующих отправлений помещаются на 1280×720 и 390×667. На очень низких экранах список сменяется равными страницами каждые 7 секунд с явным счётчиком; рейсы не обрезаются.
+- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами каждые 7 секунд с явным счётчиком. На 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием.
- В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки.
-- Информационный экран: маршруты и статус полного кеша, погода, QR-код и предупреждения. Часы и ближайший рейс остаются видимыми. В вертикальной компоновке показывается одна карточка; при следующих посещениях информационного экрана карточки сменяются. Их также можно выбрать кнопками «Маршруты», «Погода», «RideBus».
+- Информационный экран: маршруты, погода, QR-код и предупреждения. Часы и ближайший рейс с цветным номером остаются видимыми. В вертикальной и короткой компоновке показывается одна карточка; при следующих посещениях информационного экрана карточки сменяются. Их также можно выбрать кнопками «Маршруты», «Погода», «RideBus».
+- В широком блоке маршрутов — несколько слегка наклонённых дорожек с плавным встречным движением, номерами, названиями, типом транспорта и заметным временем следующего отправления. Зазоры учитывают наклон на краях. В узком блоке, включая компоновку с тремя карточками, — вертикальные карточки, сменяющиеся каждые 6,5 секунды с прокруткой названий и чисел. Пауза останавливает оба варианта. Технический статус полного кеша пассажирам не показывается.
+- Уведомления об отменах, объездах, задержках и другие сообщения оформлены с крупными иконками, цветными номерами маршрутов, сроками действия и ненавязчивой графикой. Исходные заголовки, тексты и детали отмен сохраняются.
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
- Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят.
- Настройки остановки, маршрута, числа строк, интервалов и деморежима сохраняются на устройстве. Новая остановка загружает свои данные и свой кеш.
- Полный экран, пауза, ручная смена сцен. Клавиши: `S` — настройки; `F` — полный экран; `→` — следующий экран; `пробел` — пауза. `Esc` закрывает настройки.
-- Кнопки управления и курсор скрываются после 5 секунд без взаимодействия. Движение мыши, касание или клавиатура возвращают кнопки; открытые настройки и работа с клавиатурным фокусом не скрываются. Место панели сохраняется, поэтому список рейсов не скачет.
-- Изменившиеся цифры часов, времени отправления, минут ожидания и номеров маршрутов прокручиваются вертикально. Неизменившиеся позиции остаются на месте; увеличение идёт вверх, уменьшение — вниз. При `prefers-reduced-motion` движение выключено.
-- В настройках есть 6 контрастных схем: лайм, янтарь, голубой, мята, лаванда и белый. Акценты и официальный логотип меняются вместе, фон остаётся чёрным. Все цвета имеют контраст с чёрным не меньше 7:1. Схема сохраняется на устройстве.
-- Длинные номера, включая «16тк», автоматически вписываются с отступами; текст не касается рамок.
+- Кнопки управления и курсор скрываются после 5 секунд без взаимодействия. Движение мыши, касание или клавиатура возвращают кнопки; открытые настройки и работа с клавиатурным фокусом не скрываются. Высота панели сохраняется, а освободившуюся ширину занимает подпись «Обновлено» справа.
+- Изменившиеся цифры часов, времени отправления, ожидания и номеров маршрутов прокручиваются вертикально. Названия рейсов при смене страниц плавно сдвигаются тем же движением. Неизменившиеся позиции чисел остаются на месте; увеличение идёт вверх, уменьшение — вниз. При `prefers-reduced-motion` движение выключено.
+- Для чёрного фона доступны лайм, янтарь, голубой, мята, лаванда и белый; для белого — синий, зелёный, кармин, фиолетовый, бирюзовый и графит. Акцент и логотип меняются вместе. Контраст каждой палитры с её фоном не меньше 7:1. Тема и выбранные цвета сохраняются отдельно.
+- Длинные номера, включая «16тк», автоматически вписываются с отступами. Центрирование и нижнее выравнивание чисел рассчитаны по границам глифов шрифта. Ожидание от часа отображается как «2 ч 1 мин»; единицы увеличены и выровнены снизу. Завтрашние отправления имеют одну подпись времени с пометкой «завтра».
## Данные и работа без связи
@@ -42,7 +44,7 @@ npm start
При сбое появляется пометка сохранённых данных. Истёкшие рейсы удаляются. Если сохранённые рейсы закончились, табло честно сообщает об этом и продолжает переподключение. Предупреждения фильтруются по городу, маршрутам, срокам действия; сохраняются полный текст и детали отмены. Полный кеш расписания не подтверждает актуальность предупреждений — без свежего ответа API это явно указано.
-QR-код создаётся локально, без стороннего сервиса генерации, и открывает `https://pwa.ridebus.by/?city=1&stop=1` для текущей остановки (параметры меняются вместе с площадкой). Адрес PWA можно переопределить серверной переменной `BOARD_PWA_URL`. QR использует чёрные модули, светлый фон текущей схемы и свободное поле шириной 4 модуля.
+QR-код создаётся локально, без стороннего сервиса генерации, и открывает `https://pwa.ridebus.by/?city=1&stop=1` для текущей остановки (параметры меняются вместе с площадкой). Адрес PWA можно переопределить серверной переменной `BOARD_PWA_URL`. QR использует скруглённые чёрные модули, белый фон и свободное поле шириной 4 модуля; рамка и внешняя подсветка следуют акценту темы. Декодирование нескольких ссылок проверено автоматически.
Погода запрашивается независимо через `/api/weather` у [Open-Meteo](https://open-meteo.com/en/docs) по публичным координатам остановки из API. В шапке — температура и состояние; на информационном экране — ощущаемая температура, ветер в м/с и три следующих часа. Процент осадков — максимальная почасовая вероятность в этих трёх часах. Это модельные погодные данные. Обновление раз в 10 минут; сохранённая погода помечается и показывается не дольше часа, затем скрывается. При отсутствии координат или сбое погоды транспорт продолжает работать. Данные сохраняются по городу и остановке; RideBus-ключ не передаётся поставщику погоды. Указаны источник и CC BY 4.0.
diff --git a/artifacts/alert-preview.config.mts b/artifacts/alert-preview.config.mts
new file mode 100644
index 0000000..234cea5
--- /dev/null
+++ b/artifacts/alert-preview.config.mts
@@ -0,0 +1,3 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+export default defineConfig({ plugins: [react()], base: '/__visual/', build: { outDir: 'dist/__visual', rollupOptions: { input: 'artifacts/alert-preview.html' } } });
diff --git a/artifacts/alert-preview.html b/artifacts/alert-preview.html
new file mode 100644
index 0000000..65a6c73
--- /dev/null
+++ b/artifacts/alert-preview.html
@@ -0,0 +1 @@
+
RideBus — примеры уведомлений
diff --git a/artifacts/alert-preview.tsx b/artifacts/alert-preview.tsx
new file mode 100644
index 0000000..b845506
--- /dev/null
+++ b/artifacts/alert-preview.tsx
@@ -0,0 +1,14 @@
+// Browser verification fixture. These sample notices are never loaded by the board.
+import { createRoot } from 'react-dom/client';
+import type { CSSProperties } from 'react';
+import '@fontsource-variable/manrope';
+import '../src/styles.css';
+import { AlertCard } from '../src/AlertCard';
+import type { Alert } from '../src/types';
+const examples: Alert[] = [
+ { id: 'preview-cancellation', type: 'cancellation', routeNumbers: ['2', '12тк'], routeIds: [], title: 'Вечерние отправления отменены', message: 'Сегодня рейсы в 20:15 и 21:05 от остановки «Швейная фабрика» не выполняются. Выберите другое отправление.', startAt: null, endAt: '2026-10-09T20:59:00Z' },
+ { id: 'preview-detour', type: 'detour', routeNumbers: ['6', '11'], routeIds: [], title: 'Временный объезд', message: 'Транспорт следует через улицу Калинина. Остановка «Площадь Свободы» временно не обслуживается.', startAt: null, endAt: '2026-10-10T15:00:00Z' },
+ { id: 'preview-info', type: 'info', routeNumbers: [], routeIds: [], title: 'Посадка на соседней площадке', message: 'На время дорожных работ посадка перенесена на 50 метров по направлению движения.', startAt: null, endAt: null },
+];
+const light = new URLSearchParams(location.search).get('theme') === 'light';
+createRoot(document.getElementById('root')!).render({examples.map(alert =>
)}
);
diff --git a/artifacts/board-alerts-dark.jpg b/artifacts/board-alerts-dark.jpg
new file mode 100644
index 0000000..e5c859c
Binary files /dev/null and b/artifacts/board-alerts-dark.jpg differ
diff --git a/artifacts/board-alerts-light.jpg b/artifacts/board-alerts-light.jpg
new file mode 100644
index 0000000..61120ed
Binary files /dev/null and b/artifacts/board-alerts-light.jpg differ
diff --git a/artifacts/board-dark-879x593.jpg b/artifacts/board-dark-879x593.jpg
new file mode 100644
index 0000000..e49f8f1
Binary files /dev/null and b/artifacts/board-dark-879x593.jpg differ
diff --git a/artifacts/board-info-three-cards.jpg b/artifacts/board-info-three-cards.jpg
new file mode 100644
index 0000000..0fb7706
Binary files /dev/null and b/artifacts/board-info-three-cards.jpg differ
diff --git a/artifacts/board-light-855x723.jpg b/artifacts/board-light-855x723.jpg
new file mode 100644
index 0000000..fddc521
Binary files /dev/null and b/artifacts/board-light-855x723.jpg differ
diff --git a/artifacts/board-next-day-times.jpg b/artifacts/board-next-day-times.jpg
new file mode 100644
index 0000000..8ebc66a
Binary files /dev/null and b/artifacts/board-next-day-times.jpg differ
diff --git a/artifacts/board-routes-lanes-1079x591.jpg b/artifacts/board-routes-lanes-1079x591.jpg
new file mode 100644
index 0000000..ed0a9f5
Binary files /dev/null and b/artifacts/board-routes-lanes-1079x591.jpg differ
diff --git a/artifacts/board-weather-1079x591.jpg b/artifacts/board-weather-1079x591.jpg
new file mode 100644
index 0000000..22d9d74
Binary files /dev/null and b/artifacts/board-weather-1079x591.jpg differ
diff --git a/artifacts/verification-layout-themes.md b/artifacts/verification-layout-themes.md
new file mode 100644
index 0000000..93d6fe8
--- /dev/null
+++ b/artifacts/verification-layout-themes.md
@@ -0,0 +1,23 @@
+# Макет, темы и информационные карточки — 9 октября 2026
+
+Проверено в браузере на production-сборке. Основное табло использовало реальные маршруты и расписание Швейной фабрики. Уведомления проверены отдельно на явно отмеченных тестовых данных: во время проверки активных уведомлений для остановки не было.
+
+- `npm run build`: TypeScript и Vite проходят. `npm test`: 45/45, включая контраст светлых палитр, границы часового ожидания, миграцию настроек и декодирование округлённого QR для трёх разных ссылок.
+- 879×593, 6 рейсов: страницы по 3, минимальный промежуток между названием и ожиданием 16 px. Последняя строка заканчивается на y=520, нижняя панель начинается на y=532.
+- 855×723, 4 рейса, светлая тема: все четыре видны, промежуток 16 px. Последняя строка y≈649, панель y=662. Фон `rgb(255,255,255)`, акцент `#154a9c`. Разница нижних границ чисел и «ч/мин» — 0 px; шрифт единиц в строках — 18 px. Отступ под ближайшим отправлением ≈18,8 px.
+- Центры числовых меток и рамок совпадают: горизонтальная разница 0, вертикальная <0,01 px. Длинные «12тк», «16тк», «29тк» также проверялись при разработке.
+- После бездействия ширина управления 0, правая граница статуса совпадает с правой границей панели: x≈829,35 при ширине 855.
+- 390×667, 6 рейсов: страницы по три, последний рейс заканчивается на y≈602, панель на y=614. Промежуток между колонками 10 px. Отступ ближайшего отправления 18 px.
+- Времена следующего дня проверены на реальном маршруте «29тк» при 1280×900: `08:38 завтра` и `10:23 завтра` показаны однократно, ожидание отдельно в часах/минутах (`board-next-day-times.jpg`).
+- 1280×900: три карточки, каждая `clientHeight = scrollHeight = 497`. Маршруты показаны вертикальными карточками, следующий час — 23 px, без подписи «Рейс». При смене названий в DOM есть предыдущий текст и `destination-up-in/out`.
+- 879×593 и 1079×591: широкая карточка маршрутов имеет две дорожки с направлениями `normal/reverse`. Минимальный вертикальный зазор после наклона ≈25 px; названия и времена не переполняют своих контейнеров. Время в маленьких дорожках 21 px.
+- Погода на 879×593 и 1079×591: текущие условия и почасовой прогноз занимают отдельные заполненные панели. Карточки не имеют внутреннего переполнения. Иконки будущих часов 36 px на коротком экране, 40 px в трёх карточках, до 64 px в широком высоком блоке.
+- Погода на 390×667: `clientHeight = scrollHeight = 320`, погодное содержимое помещается. Вертикальные маршруты показываются без горизонтального обрезания.
+- Уведомления проверены в обеих темах на 1280×900 и на 390×667: ширина текстового содержимого совпадает с его scrollWidth, исходный текст сохранён. Несколько длинных уведомлений допускают вертикальную прокрутку информационной сцены.
+- Браузерных ошибок и предупреждений при итоговой проверке основного табло и примеров уведомлений нет.
+
+Примеры уведомлений воспроизводятся командой `npm exec vite build -- --config artifacts/alert-preview.config.mts`, после чего доступны локально по `/__visual/artifacts/alert-preview.html` и `?theme=light`. Эта сборка отдельная: обычный `npm run build` не включает примеры в табло и удаляет временную папку из dist.
+
+Снимки: `board-dark-879x593.jpg`, `board-light-855x723.jpg`, `board-info-three-cards.jpg`, `board-routes-lanes-1079x591.jpg`, `board-weather-1079x591.jpg`, `board-alerts-dark.jpg`, `board-alerts-light.jpg`.
+
+Физический дисплей и читаемость с конкретного расстояния не проверялись. Docker runtime ранее проверен импортом из изолированной структуры образа; полный Docker build локально не выполнялся из-за недоступного daemon.
diff --git a/package-lock.json b/package-lock.json
index 26e20a0..7629469 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
+ "jsqr": "^1.4.0",
"typescript": "^7.0.2",
"vite": "^8.3.3"
},
@@ -1383,6 +1384,13 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
+ "node_modules/jsqr": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
+ "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
diff --git a/package.json b/package.json
index b441129..c05bb14 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
+ "jsqr": "^1.4.0",
"typescript": "^7.0.2",
"vite": "^8.3.3"
}
diff --git a/src/AlertCard.tsx b/src/AlertCard.tsx
new file mode 100644
index 0000000..c35e383
--- /dev/null
+++ b/src/AlertCard.tsx
@@ -0,0 +1,24 @@
+import { ArrowRight, Clock, Close, Info, Reset } from './Icons';
+import { RouteBadge } from './RouteBadge';
+import type { Alert } from './types';
+
+const periodFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
+export function AlertCard({ alert }: { alert: Alert }) {
+ const cancelled = alert.type === 'cancellation';
+ const detour = alert.type === 'detour';
+ const delayed = alert.type === 'delay';
+ const Icon = cancelled ? Close : detour ? Reset : delayed ? Clock : Info;
+ const label = cancelled ? 'Отмена рейсов' : detour ? 'Изменение маршрута' : delayed ? 'Задержка движения' : alert.type === 'info' ? 'Обратите внимание' : 'Изменения движения';
+ const periodAt = alert.endAt || alert.startAt;
+ const period = periodAt && Number.isFinite(Date.parse(periodAt)) ? periodFormat.format(Date.parse(periodAt)) : null;
+ return
+
+
+
{label} {period && {alert.endAt ? 'До' : 'С'} {period} }
+ {!!alert.routeNumbers.length &&
{alert.routeNumbers.map(number => )}
}
+
{alert.title} {alert.message}
+ {!alert.routeNumbers.length &&
}
+
+
+ ;
+}
diff --git a/src/App.tsx b/src/App.tsx
index c8f78e5..09c2ed9 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,15 +1,17 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
-import { ArrowDown, ArrowRight, ArrowUpRight, BusFront, Check, Clock, Fullscreen, Info, Pause, Play, Refresh, SettingsIcon, Shield, WifiOff } from './Icons';
+import { ArrowDown, ArrowRight, ArrowUpRight, BusFront, Clock, Fullscreen, Info, Pause, Play, Refresh, SettingsIcon, Shield, WifiOff } from './Icons';
import { NumericText } from './NumericText';
import { RouteBadge } from './RouteBadge';
+import { RouteCloud } from './RouteCloud';
+import { AlertCard } from './AlertCard';
import { FittedDestination } from './FittedDestination';
import { useIdleControls } from './useIdleControls';
import { PassengerQr } from './PassengerQr';
import { WeatherCard, WeatherSummary } from './WeatherBlock';
import { localDate, timetableAvailable, timetableDepartures } from './lib/timetable.mjs';
import logo from './assets/ridebus-logo.svg?raw';
-import { colorScheme, departurePage, stopDirection } from './lib/presentation.mjs';
+import { colorScheme, departurePage, durationParts, stopDirection } from './lib/presentation.mjs';
import type { CSSProperties } from 'react';
import type { Alert, BoardData, Config, Departure, Scene, Settings } from './types';
import { activeAlerts, isFresh, minutesUntil, shouldHoldScene, upcomingDepartures } from './lib/board.mjs';
@@ -24,18 +26,13 @@ const sceneNames: Record = { departures: 'Ближайшие ре
function Countdown({ departure, now, large = false }: { departure: Departure; now: number; large?: boolean }) {
const minutes = minutesUntil(departure, now);
- if (localDate(Date.parse(departure.scheduledAt)) > localDate(now)) return завтра
;
- return 0 ? `Через ${minutes} минут по расписанию` : 'Сейчас по расписанию'}>
-
{minutes > 0 ? : 'сейчас'} {minutes > 0 &&
мин }
+ const parts = durationParts(minutes);
+ return
= 60} aria-label={minutes > 0 ? `Через ${parts.map(part => `${part.value} ${part.unit}`).join(' ')} по расписанию` : 'Сейчас по расписанию'}>
+ {minutes > 0 ? {parts.map(part => )} : сейчас }
;
}
-
-function AlertCard({ alert, compact = false }: { alert: Alert; compact?: boolean }) {
- return
- {alert.type === 'cancellation' ? 'Отмена рейсов' : 'Изменения движения'}
- {!!alert.routeNumbers.length && {alert.routeNumbers.map(number => `№${number}`).join(' · ')}
}
- {alert.title} {alert.message}
- ;
+function ScheduledTime({ departure, now, className = '' }: { departure: Departure; now: number; className?: string }) {
+ return
{localDate(Date.parse(departure.scheduledAt)) > localDate(now) && завтра };
}
function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused }: {
@@ -45,7 +42,7 @@ function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fr
const table = useRef
(null);
const [tableHeight, setTableHeight] = useState(360);
const [page, setPage] = useState(0);
- const capacity = Math.max(2, Math.floor(tableHeight / 50));
+ const capacity = Math.max(2, Math.floor(tableHeight / 62));
const visible: { items: Departure[]; start: number; count: number; pages: number; size: number } = departurePage(departures.slice(1), rowCount, capacity, page);
const rows = Math.max(1, Math.min(rowCount, visible.size));
useLayoutEffect(() => {
@@ -64,8 +61,8 @@ function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fr
БЛИЖАЙШИЙ РЕЙС По расписанию{data?.mode === 'demo' ? ' · демо' : ''}
{first ?
-
{first.type === 'minibus' ? 'Маршрутное такси' : 'Автобус'}
{first.destination}
-
{first.note || first.routeName}
+
{first.type === 'minibus' ? 'Маршрутное такси' : 'Автобус'}
+
{first.note || first.routeName}
: {loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}
{loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}
}
@@ -73,8 +70,8 @@ function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fr
1} style={{ '--rows': rows, '--row-height': `${tableHeight / rows}px` } as CSSProperties}>
{visible.items.map((departure, index) =>
- {departure.note || (departure.type === 'minibus' ? 'Маршрутное такси' : 'Автобус')}
-
+ {departure.note || (departure.type === 'minibus' ? 'Маршрутное такси' : 'Автобус')}
+
)}
{first && departures.length === 1 &&
Это последний доступный рейс
}
@@ -85,9 +82,17 @@ function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fr
;
}
-function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, timetable, weather, now }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
- config: Config; timetable: ReturnType; weather: ReturnType; now: number }) {
+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;
+ config: Config; weather: ReturnType; now: number }) {
const panel = useRef(null);
+ const grid = useRef(null);
+ const [compact, setCompact] = useState(false);
+ useLayoutEffect(() => {
+ if (!grid.current) return;
+ const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; setCompact(width < 1000 || innerHeight > innerWidth || innerHeight < 650 || height < 330); });
+ observer.observe(grid.current);
+ return () => observer.disconnect();
+ }, [alerts.length]);
useEffect(() => {
const element = panel.current;
if (!element || paused || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
@@ -98,11 +103,11 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config
}, 3500);
return () => { clearTimeout(start); clearInterval(timer); };
}, [seconds, paused, page]);
- return
+ return
{alerts.length ? 'Изменения движения' : 'Пока вы ждёте.'}
{!alerts.length && {['Маршруты', 'Погода', 'RideBus'].map((name, index) => onPage(index)}>{name} )} }
- {alerts.length ? :
-
Маршруты остановки {[...new Set(data?.routes.map(route => route.number))].map(number => {number} )}
{timetableAvailable(timetable.data, now) && timetable.saved ? `Полное расписание сохранено · маршрутов: ${timetable.data?.routes.length}` : timetable.loading ? 'Сохраняем полное расписание…' : timetable.saved === false ? 'Не удалось сохранить полный кеш' : 'Полный кеш сейчас недоступен'}
Время по расписанию. Фактическое прибытие зависит от дорожной обстановки.
+ {alerts.length ?
:
}
@@ -197,7 +202,7 @@ export default function App() {
const timeParts = clockFormat.format(now).split(':');
const updateLabel = fullAvailable && timetable.data ? timeFormat.format(Date.parse(timetable.data.updatedAt)) : data ? timeFormat.format(Date.parse(data.updatedAt)) : '—';
const direction = stopDirection(data?.stop);
- return
+ return
{data?.stop.name || config.stopName}
ridebus
{dateFormat.format(now)}
@@ -205,10 +210,10 @@ export default function App() {
{!fresh && !settings.demo && {data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}
}
{scene === 'departures' &&
}
- {scene === 'info' &&
}
+ {scene === 'info' &&
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 === 'ad' && РЕКЛАМА · ПРЕДПРОСМОТР {settings.adTitle} {settings.adText}
}
- {scene !== 'departures' && БЛИЖАЙШИЙ РЕЙС {departures[0] ? <>{departures[0].destination} По расписанию > : Нет доступных рейсов }
}
+ {scene !== 'departures' && БЛИЖАЙШИЙ РЕЙС {departures[0] ? <>
{departures[0].destination}
> :
Нет доступных рейсов }
}
{scenes.map((item, index) => goTo(item)} className={scene === item ? 'active' : ''} aria-label={`0${index + 1} ${sceneNames[item]}`} title={sceneNames[item]} aria-current={scene === item ? 'page' : undefined}>0{index + 1} {sceneNames[item]} )}
{!fresh && !settings.demo && }{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}
diff --git a/src/FittedDestination.tsx b/src/FittedDestination.tsx
index f01ef1e..f358ef5 100644
--- a/src/FittedDestination.tsx
+++ b/src/FittedDestination.tsx
@@ -1,22 +1,27 @@
-import { useLayoutEffect, useRef } from 'react';
+import { useLayoutEffect, useRef, useState } from 'react';
-export function FittedDestination({ text, rowHeight }: { text: string; rowHeight: number }) {
+export function FittedDestination({ text, rowHeight, as: Tag = 'h3' }: { text: string; rowHeight?: number; as?: 'h2' | 'h3' }) {
const heading = useRef
(null);
+ const [frame, setFrame] = useState({ current: text, previous: text, revision: 0 });
+ if (frame.current !== text) setFrame({ current: text, previous: frame.current, revision: frame.revision + 1 });
useLayoutEffect(() => {
const element = heading.current, container = element?.parentElement;
if (!element || !container) return;
const fit = () => {
element.style.fontSize = '';
const base = parseFloat(getComputedStyle(element).fontSize);
- const details = container.querySelector('p');
- const detailsHeight = details && getComputedStyle(details).display !== 'none' ? details.getBoundingClientRect().height + 5 : 0;
- const available = Math.max(1, container.getBoundingClientRect().height - 6 - detailsHeight);
- if (element.getBoundingClientRect().height <= available) return;
+ const detailsHeight = Array.from(container.children).filter(child => child !== element).reduce((height, child) => {
+ const css = getComputedStyle(child);
+ return height + (css.display === 'none' || css.position === 'absolute' ? 0 : child.clientHeight + parseFloat(css.marginTop) + parseFloat(css.marginBottom));
+ }, 0);
+ const css = getComputedStyle(container);
+ const available = Math.max(1, container.clientHeight - parseFloat(css.paddingTop) - parseFloat(css.paddingBottom) - detailsHeight);
+ if (element.clientHeight <= available) return;
let low = Math.min(16, base), high = base;
for (let attempt = 0; attempt < 7; attempt++) {
const size = (low + high) / 2;
element.style.fontSize = `${size}px`;
- if (element.getBoundingClientRect().height > available) high = size; else low = size;
+ if (element.clientHeight > available) high = size; else low = size;
}
element.style.fontSize = `${low}px`;
};
@@ -25,6 +30,10 @@ export function FittedDestination({ text, rowHeight }: { text: string; rowHeight
let active = true;
void document.fonts.ready.then(() => { if (active) fit(); });
return () => { active = false; observer.disconnect(); };
- }, [text, rowHeight]);
- return {text} ;
+ }, [text, rowHeight, Tag]);
+ return
+ {frame.current}
+ {frame.revision > 0 && {frame.previous} }
+ 0} aria-hidden="true">{frame.current}
+ ;
}
diff --git a/src/NumericText.tsx b/src/NumericText.tsx
index ebc4a57..aa3f647 100644
--- a/src/NumericText.tsx
+++ b/src/NumericText.tsx
@@ -1,14 +1,39 @@
-import { useState } from 'react';
+import { useLayoutEffect, useRef, useState } from 'react';
+import type { CSSProperties } from 'react';
import { numericFrame } from './lib/presentation.mjs';
type Props = { value: string | number; numericValue?: number; className?: string };
export function NumericText({ value, numericValue, className = '' }: Props) {
const text = String(value);
+ const element = useRef(null);
+ const [metrics, setMetrics] = useState({ height: .8, offset: -.13 });
+ const symbols = text.replace(/\d/g, '');
+ useLayoutEffect(() => {
+ const node = element.current;
+ if (!node) return;
+ const context = document.createElement('canvas').getContext('2d');
+ if (!context) return;
+ let active = true;
+ function measure() {
+ if (!active || !context) return;
+ const css = getComputedStyle(node!), size = parseFloat(css.fontSize);
+ context.font = `${css.fontStyle} ${css.fontWeight} ${css.fontSize} ${css.fontFamily}`;
+ const bounds = context.measureText(`0123456789${symbols}`);
+ const baseline = (size - bounds.fontBoundingBoxAscent - bounds.fontBoundingBoxDescent) / 2 + bounds.fontBoundingBoxAscent;
+ const next = { height: (bounds.actualBoundingBoxAscent + bounds.actualBoundingBoxDescent) / size + .04,
+ offset: (bounds.actualBoundingBoxAscent - baseline) / size + .02 };
+ if (!Number.isFinite(next.height) || !Number.isFinite(next.offset)) return;
+ setMetrics(previous => Math.abs(previous.height - next.height) < .001 && Math.abs(previous.offset - next.offset) < .001 ? previous : next);
+ }
+ const observer = new ResizeObserver(measure); observer.observe(node); measure();
+ void document.fonts.ready.then(measure);
+ return () => { active = false; observer.disconnect(); };
+ }, [symbols]);
const number = numericValue ?? Number(text.replace(/\D/g, ''));
const [frame, setFrame] = useState({ current: text, previous: text, number, previousNumber: number, revision: 0 });
if (frame.current !== text) setFrame({ current: text, previous: frame.current, number, previousNumber: frame.number, revision: frame.revision + 1 });
const transition = numericFrame(frame.previous, frame.current, frame.previousNumber, frame.number);
- return
+ return
{text}
{transition.glyphs.map(glyph => {
diff --git a/src/PassengerQr.tsx b/src/PassengerQr.tsx
index ec075cb..b136825 100644
--- a/src/PassengerQr.tsx
+++ b/src/PassengerQr.tsx
@@ -1,19 +1,13 @@
import { useMemo } from 'react';
-import QRCode from 'qrcode';
import { ArrowUpRight } from './Icons';
import type { Config, Stop } from './types';
-import { passengerLink } from './lib/passenger.mjs';
+import { createPassengerQr, passengerLink } from './lib/passenger.mjs';
export function PassengerQr({ config, stop }: { config: Config; stop: Stop | undefined }) {
const url = passengerLink(config.passengerBaseUrl, config.cityId, stop?.id || config.defaultStopId);
- const qr = useMemo(() => {
- const code = QRCode.create(url, { errorCorrectionLevel: 'M' }), size = code.modules.size;
- let path = '';
- for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (code.modules.get(y, x)) path += `M${x + 4} ${y + 4}h1v1h-1z`;
- return { path, size: size + 8 };
- }, [url]);
- return <>Расписание в телефоне
-
-
- Наведите камеру на QR-код
{new URL(url).host} >;
+ const qr = useMemo(() => createPassengerQr(url), [url]);
+ return <>Расписание в телефоне >;
}
diff --git a/src/RouteBadge.tsx b/src/RouteBadge.tsx
index 9653a9b..b81804a 100644
--- a/src/RouteBadge.tsx
+++ b/src/RouteBadge.tsx
@@ -1,7 +1,7 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { NumericText } from './NumericText';
-export function RouteBadge({ number, large = false }: { number: string; large?: boolean }) {
+export function RouteBadge({ number, large = false, filled = false }: { number: string; large?: boolean; filled?: boolean }) {
const badge = useRef(null);
const label = useRef(null);
const [scale, setScale] = useState(1);
@@ -20,7 +20,7 @@ export function RouteBadge({ number, large = false }: { number: string; large?:
fit(); void document.fonts.ready.then(fit);
return () => { mounted = false; observer.disconnect(); };
}, [number]);
- return
+ return
;
}
diff --git a/src/RouteCloud.tsx b/src/RouteCloud.tsx
new file mode 100644
index 0000000..6f6af06
--- /dev/null
+++ b/src/RouteCloud.tsx
@@ -0,0 +1,76 @@
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import type { CSSProperties } from 'react';
+import type { Departure, Route } from './types';
+import { BusFront } from './Icons';
+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) {
+ const element = useRef(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 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 pageStart = pageIndex * baseSize + Math.min(pageIndex, extra);
+ const nextByRoute = useMemo(() => {
+ const result = new Map();
+ for (const departure of departures) if (!result.has(departure.routeId)) result.set(departure.routeId, departure);
+ return result;
+ }, [departures]);
+ useLayoutEffect(() => {
+ if (!element.current) return;
+ const observer = new ResizeObserver(entries => {
+ const { width, height } = entries[0].contentRect;
+ if (width <= 0 || height <= 0) return;
+ setArea(previous => Math.abs(previous.width - width) < .5 && Math.abs(previous.height - height) < .5 ? previous : { width, height });
+ });
+ observer.observe(element.current);
+ return () => observer.disconnect();
+ }, []);
+ useEffect(() => {
+ if (carousel || 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
+
+
+
{next ? <> {localDate(Date.parse(next.scheduledAt)) > localDate(now) && завтра }> : Расписание уточняется }
+ {route.type === 'minibus' ? 'Маршрутка' : 'Автобус'}
+
+
+ ;
+ };
+ return
+ {carousel ? <>
+
{routes.map(route => №{route.number} · {route.name} )}
+ {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
+
+ {[0, 1].map(copy =>
{group.map((route, index) => tile(route, `${route.id}:${index}`))}
)}
+
+
;
+ })}> : <>
{routes.slice(pageStart, pageStart + pageSize).map((route, index) => tile(route, `position-${index}`))}
+ {pages > 1 &&
Через эту остановку
}
+ >}
+
;
+}
diff --git a/src/SettingsPanel.tsx b/src/SettingsPanel.tsx
index c2d5494..96b1064 100644
--- a/src/SettingsPanel.tsx
+++ b/src/SettingsPanel.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { ArrowUpRight, Check, Fullscreen, Reset, Close } from './Icons';
-import { colorSchemes } from './lib/presentation.mjs';
+import { schemesForTheme } from './lib/presentation.mjs';
import type { BoardData, Config, Settings, Stop } from './types';
export function SettingsPanel({ settings, config, data, onSave, onClose, onFullscreen }: {
@@ -35,10 +35,11 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
Демонстрационные данныеДля проверки оформления без API update('demo', event.target.checked)} />
- Цветовая схема
- Контрастный цвет на чёрном фоне
- {colorSchemes.map(scheme => update('colorScheme', scheme.id)} />{scheme.name} )}
- Фон остаётся чёрным. Меняются акценты, номера маршрутов и логотип.
+
Экраны и переходы
Главный экран, сек update('mainSeconds', Number(event.target.value))} />
diff --git a/src/WeatherBlock.tsx b/src/WeatherBlock.tsx
index beb9d9f..10d3f68 100644
--- a/src/WeatherBlock.tsx
+++ b/src/WeatherBlock.tsx
@@ -22,9 +22,9 @@ export function WeatherCard({ data, now, loading, failed, demo }: { data: Weathe
if (!available || !data) return <>
Погода {demo ? 'Погода доступна с реальными данными остановки.' : loading ? 'Получаем прогноз…' : 'Прогноз сейчас недоступен.'}
{data ? 'Сохранённый прогноз устарел' : 'Погода обновится при подключении'}
>;
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 <>
Погода
-
{weatherCondition(data.code, data.isDay).label}
Ощущается как м/с
- {!!hours.length &&
}
- {probability !== null &&
Осадки в ближайшие 3 часа: %
}
+ return <>
Погода СЕЙЧАС
+
{weatherCondition(data.code, data.isDay).label}
Ощущается как м/с
+ {!!hours.length &&
{hours.map(hour =>
%осадки
)}
}
+ {probability !== null &&
Осадки в ближайшие 3 часа: %
}
>;
}
diff --git a/src/assets/ridebus-logo.svg b/src/assets/ridebus-logo.svg
index ab599c5..3eb61a3 100644
--- a/src/assets/ridebus-logo.svg
+++ b/src/assets/ridebus-logo.svg
@@ -6,7 +6,7 @@
.st0{clip-path:url(#SVGID_2_);}
.st1{fill:var(--accent, #d5ff70);}
.st2{display:none;fill:none;stroke:#FFFFFF;stroke-width:1.9625;}
- .st3{fill:#000000;}
+ .st3{fill:var(--accent-ink, #000000);}
diff --git a/src/lib/passenger.mjs b/src/lib/passenger.mjs
index 7f84635..c6b5702 100644
--- a/src/lib/passenger.mjs
+++ b/src/lib/passenger.mjs
@@ -1,3 +1,17 @@
+import QRCode from 'qrcode';
+
+export function createPassengerQr(url) {
+ const code = QRCode.create(url, { errorCorrectionLevel: 'M' }), size = code.modules.size;
+ const rectangles = [];
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (code.modules.get(y, x)
+ && !(x < 7 && y < 7 || x >= size - 7 && y < 7 || x < 7 && y >= size - 7)) rectangles.push({ x: x + 4, y: y + 4, width: 1, height: 1, rx: .18, fill: '#101010' });
+ for (const { x, y } of [{ x: 4, y: 4 }, { x: size - 3, y: 4 }, { x: 4, y: size - 3 }]) {
+ rectangles.push({ x, y, width: 7, height: 7, rx: 1.25, fill: '#101010' },
+ { x: x + 1, y: y + 1, width: 5, height: 5, rx: .7, fill: '#ffffff' },
+ { x: x + 2, y: y + 2, width: 3, height: 3, rx: .55, fill: '#101010' });
+ }
+ return { size: size + 8, rectangles };
+}
export function passengerLink(baseUrl, cityId, stopId) {
let url;
try { url = new URL(baseUrl || 'https://pwa.ridebus.by/'); if (!['https:', 'http:'].includes(url.protocol)) throw new Error(); }
diff --git a/src/lib/presentation.mjs b/src/lib/presentation.mjs
index 342eb2a..0fc87a0 100644
--- a/src/lib/presentation.mjs
+++ b/src/lib/presentation.mjs
@@ -6,7 +6,21 @@ export const colorSchemes = [
{ id: 'lavender', name: 'Лаванда', color: '#d3bdff' },
{ id: 'white', name: 'Белый', color: '#ffffff' },
];
-export function colorScheme(id) { return colorSchemes.find(scheme => scheme.id === id) || colorSchemes[0]; }
+export const lightColorSchemes = [
+ { id: 'blue', name: 'Синий', color: '#154a9c' },
+ { id: 'forest', name: 'Зелёный', color: '#195c35' },
+ { id: 'crimson', name: 'Кармин', color: '#a31d39' },
+ { id: 'violet', name: 'Фиолетовый', color: '#5d329e' },
+ { id: 'teal', name: 'Бирюзовый', color: '#005d64' },
+ { id: 'graphite', name: 'Графит', color: '#303744' },
+];
+export const schemesForTheme = theme => theme === 'light' ? lightColorSchemes : colorSchemes;
+export function colorScheme(id, theme = 'dark') { const schemes = schemesForTheme(theme); return schemes.find(scheme => scheme.id === id) || schemes[0]; }
+export function durationParts(minutes) {
+ const value = Math.max(0, Math.ceil(minutes));
+ if (value < 60) return [{ value, unit: 'мин' }];
+ return [{ value: Math.floor(value / 60), unit: 'ч' }, ...(value % 60 ? [{ value: value % 60, unit: 'мин' }] : [])];
+}
export function numericFrame(previous, current, previousNumber, currentNumber) {
const width = Math.max(previous.length, current.length);
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
index 82590b7..64fdd25 100644
--- a/src/lib/storage.ts
+++ b/src/lib/storage.ts
@@ -7,7 +7,7 @@ import type { Timetable, Weather } from '../types';
const SETTINGS_KEY = 'ridebus-board-settings-v1';
export const defaultSettings: Settings = { stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true,
- rowCount: 4, routeFilter: '', colorScheme: 'lime', adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
+ rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue', adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
export function readSettings(): Settings {
try {
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
@@ -15,7 +15,8 @@ export function readSettings(): Settings {
return { ...defaultSettings, stopId: bound(saved.stopId, 1, 1, 1000000), demo: saved.demo === true,
mainSeconds: bound(saved.mainSeconds, 25, 5, 120), infoSeconds: bound(saved.infoSeconds, 8, 5, 60), rowCount: bound(saved.rowCount, 4, 2, 6),
holdNear: saved.holdNear !== false, routeFilter: typeof saved.routeFilter === 'string' && /^\d*$/.test(saved.routeFilter) ? saved.routeFilter : '',
- colorScheme: colorScheme(saved.colorScheme).id, adsEnabled: saved.adsEnabled === true, adSeconds: bound(saved.adSeconds, 7, 5, 30),
+ theme: saved.theme === 'light' ? 'light' : 'dark', colorScheme: colorScheme(saved.colorScheme).id, lightColorScheme: colorScheme(saved.lightColorScheme, 'light').id,
+ adsEnabled: saved.adsEnabled === true, adSeconds: bound(saved.adSeconds, 7, 5, 30),
adTitle: typeof saved.adTitle === 'string' ? saved.adTitle.slice(0, 80) : defaultSettings.adTitle,
adText: typeof saved.adText === 'string' ? saved.adText.slice(0, 200) : defaultSettings.adText };
} catch { return defaultSettings; }
diff --git a/src/styles.css b/src/styles.css
index 3ad973f..3068918 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1,6 +1,6 @@
-:root { font-family: 'Manrope Variable', Arial, sans-serif; color: #f5f5f5; background: #000000; font-synthesis: none; --accent: #d5ff70; --muted: #b7b7b7; --line: #303030; --warning: #ffd479; color-scheme: dark; }
+:root { font-family: 'Manrope Variable', Arial, sans-serif; background: #000; font-synthesis: none; color-scheme: dark; }
* { box-sizing: border-box; }
-html, body, #root { margin: 0; width: 100%; height: 100%; background: #000000; }
+html, body, #root { margin: 0; width: 100%; height: 100%; }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; color: inherit; }
button, a { touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
@@ -9,15 +9,15 @@ svg { display: block; flex-shrink: 0; }
.vkuiIcon { display: inline-flex; flex-shrink: 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
-/* The main track alone can shrink. The header and footer always own their space. */
-.board { --hero-height: clamp(100px, 18vh, 230px); height: 100dvh; width: 100%; display: flex; flex-direction: column; overflow: hidden; padding: clamp(14px, 2vh, 28px) clamp(18px, 3vw, 64px) 0; background: #000000; }
-.board[data-row-count='5'], .board[data-row-count='6'] { --hero-height: clamp(82px, 14vh, 175px); }
+.board { --background: #000000; --foreground: #f5f5f5; --muted: #b7b7b7; --secondary: #c3c3c3; --line: #303030; --outline: #595959; --surface: #0a0a0a; --input: #141414; --warning: #ffd479; --accent-ink: #000; --hero-height: clamp(110px, 18vh, 230px); --hero-gap: clamp(18px, 2.6vh, 32px); --control-size: 40px; --control-gap: 6px; color-scheme: dark; color: var(--foreground); background: var(--background); height: 100dvh; width: 100%; display: flex; flex-direction: column; overflow: hidden; padding: clamp(14px, 2vh, 28px) clamp(18px, 3vw, 64px) 0; }
+.board[data-theme='light'] { --background: #FFFFFF; --foreground: #171b23; --muted: #535b68; --secondary: #535b68; --line: #d7dbe2; --outline: #939ba8; --surface: #fff; --input: #f2f4f7; --warning: #8a4300; --accent-ink: #fff; color-scheme: light; }
+.board[data-row-count='5'], .board[data-row-count='6'] { --hero-height: clamp(100px, 15vh, 180px); }
.board-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; flex: 0 0 auto; border-bottom: 1px solid var(--line); padding-bottom: clamp(12px, 1.8vh, 24px); view-transition-name: board-header; }
.stop-heading { min-width: 0; }
h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1.08; margin: 0; font-weight: 650; overflow-wrap: anywhere; }
+.stop-meta { display: flex; align-items: center; gap: 10px 24px; flex-wrap: wrap; }
.stop-direction { color: var(--muted); display: flex; align-items: center; gap: 10px; font-size: clamp(18px, 1.6vw, 32px); margin-top: 10px; line-height: 1.2; }
.stop-direction svg { color: var(--accent); }
-.stop-meta { display: flex; align-items: center; gap: 12px 24px; flex-wrap: wrap; }
.weather-summary { display: flex; align-items: center; gap: 9px; margin-top: 10px; color: var(--muted); font-size: clamp(16px, 1.3vw, 26px); line-height: 1.2; }
.weather-summary svg, .weather-summary strong { color: var(--accent); }
.weather-summary strong { font-size: 1.25em; font-weight: 600; }
@@ -26,166 +26,185 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.brand { grid-row: 1 / 3; display: flex; align-items: center; gap: 12px; font-size: clamp(24px, 2.3vw, 44px); font-weight: 800; letter-spacing: -.07em; white-space: nowrap; }
.official-mark { display: block; width: clamp(40px, 3.5vw, 70px); height: auto; flex-shrink: 0; }
.official-mark svg { width: 100%; height: auto; }
-.brand-dot { color: var(--accent); }
-.clock { display: flex; gap: 10px; align-items: baseline; font-size: clamp(38px, 4.6vw, 90px); font-weight: 550; line-height: 1; font-variant-numeric: tabular-nums; letter-spacing: -.035em; white-space: nowrap; }
-.clock small { font-size: .42em; color: #bdbdbd; font-weight: 500; }
-.today { grid-column: 2; color: #bdbdbd; text-align: right; font-size: clamp(13px, 1.15vw, 22px); margin-top: 4px; white-space: nowrap; }
+.clock { display: flex; gap: 10px; align-items: flex-end; font-size: clamp(38px, 4.6vw, 90px); font-weight: 550; line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; }
+.clock small { font-size: .42em; color: var(--secondary); font-weight: 500; }
+.today { grid-column: 2; color: var(--secondary); text-align: right; font-size: clamp(13px, 1.15vw, 22px); margin-top: 8px; white-space: nowrap; }
.scene-container { flex: 1 1 0; min-height: 0; min-width: 0; overflow: hidden; }
.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(10px, 1.6vh, 24px) 0; }
-.departure-content { flex: 1 1 0; min-height: 0; min-width: 0; display: grid; grid-template-rows: auto var(--hero-height) auto minmax(0, 1fr); }
+.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); }
.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); }
-.source-label { display: flex; align-items: center; gap: 8px; color: #c3c3c3; 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; }
.source-label svg { width: 1.2em; height: 1.2em; }
-.hero-departure { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr); align-items: center; gap: clamp(18px, 2.4vw, 50px); min-height: 0; padding: 6px 0; }
-.route-badge { display: inline-flex; justify-content: center; align-items: center; flex-shrink: 0; border: 1.5px solid #595959; border-radius: 12px; width: clamp(64px, 8vw, 150px); height: min(82px, calc(var(--row-height, 96px) * .76)); padding: 0 clamp(10px, 1vw, 18px); font-size: min(50px, calc(var(--row-height, 96px) * .48)); font-weight: 650; line-height: 1.12; font-variant-numeric: tabular-nums; letter-spacing: -.025em; overflow: hidden; }
-.route-badge-label { display: inline-block; flex-shrink: 0; white-space: nowrap; transform-origin: center; }
-.route-badge-large { width: calc(var(--hero-height) * 1.05); height: calc(var(--hero-height) - 12px); border: none; background: var(--accent); color: #000; border-radius: clamp(14px, 1.5vw, 28px); padding: 0 clamp(16px, 1.4vw, 28px); font-size: min(130px, calc(var(--hero-height) * .68)); }
-.hero-destination { min-width: 0; }
-.transport-kind { display: flex; align-items: center; gap: 9px; color: var(--accent); font-size: min(30px, calc(var(--hero-height) * .17)); line-height: 1.2; margin-bottom: 5px; }
-.hero-destination h2 { font-size: min(5vw, calc(var(--hero-height) * .34)); line-height: 1.08; letter-spacing: -.035em; font-weight: 650; margin: 0; overflow-wrap: anywhere; }
-.hero-details { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 13px; font-size: min(27px, calc(var(--hero-height) * .12)); color: #bdbdbd; margin-top: 4px; line-height: 1.25; }
-.schedule-time { border: 1px solid #505050; border-radius: 6px; padding: 1px 6px; font-variant-numeric: tabular-nums; white-space: nowrap; }
-.countdown { display: flex; align-items: baseline; justify-content: flex-end; gap: 9px; font-variant-numeric: tabular-nums; white-space: nowrap; }
-.countdown > span { font-size: min(68px, calc(var(--row-height, 92px) * .64)); font-weight: 550; line-height: 1.1; letter-spacing: -.035em; }
-.countdown small { color: #c3c3c3; font-size: min(27px, calc(var(--row-height, 92px) * .29)); }
-.countdown-large > span { color: var(--accent); font-size: min(154px, calc(var(--hero-height) * .86)); }
-.countdown-large small { font-size: clamp(19px, 2vw, 38px); }
-.countdown .countdown-now { font-size: min(40px, calc(var(--row-height, 92px) * .4)); }
-.countdown-large .countdown-now { font-size: min(72px, calc(var(--hero-height) * .44)); }
-.countdown-future { flex-direction: column; align-items: flex-end; gap: 1px; }
-.countdown-future > span { font-size: min(40px, calc(var(--row-height, 92px) * .4)); }
-.countdown-large.countdown-future > span { font-size: min(70px, calc(var(--hero-height) * .45)); }
-.next-heading { border-top: 1px solid var(--line); padding: clamp(8px, 1.2vh, 16px) 0; }
-.next-heading svg { color: #bdbdbd; }
+.hero-departure { height: var(--hero-height); display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr); align-items: center; gap: clamp(18px, 2.4vw, 50px); min-height: 0; padding: 10px 0 0; }
+.route-badge { display: inline-flex; justify-content: center; align-items: center; flex-shrink: 0; border: 1.5px solid var(--outline); border-radius: 14px; width: clamp(64px, 8vw, 150px); height: min(82px, calc(var(--row-height, 96px) * .74)); padding: 0 clamp(10px, 1vw, 18px); font-size: min(50px, calc(var(--row-height, 96px) * .48)); font-weight: 650; line-height: 1; font-variant-numeric: tabular-nums; overflow: hidden; }
+.route-badge-label { display: flex; align-items: center; flex-shrink: 0; white-space: nowrap; transform-origin: center; }
+.route-badge-large { width: calc(var(--hero-height) * 1.05); height: calc(var(--hero-height) - 10px); border: 0; background: var(--accent); color: var(--accent-ink); border-radius: calc(var(--hero-height) * .27); padding: 0 clamp(16px, 1.4vw, 28px); font-size: min(130px, calc(var(--hero-height) * .68)); }
+.route-badge-filled { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
+.hero-destination { min-width: 0; height: 100%; display: flex; flex-direction: column; justify-content: center; }
+.transport-kind { display: flex; align-items: center; gap: 9px; color: var(--accent); font-size: min(30px, calc(var(--hero-height) * .16)); line-height: 1.2; margin-bottom: 7px; }
+.hero-destination h2 { flex-shrink: 0; font-size: min(4.7vw, calc(var(--hero-height) * .34)); line-height: 1.08; letter-spacing: -.035em; font-weight: 650; margin: 0; overflow-wrap: anywhere; }
+.hero-details { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: min(27px, calc(var(--hero-height) * .12)); color: var(--secondary); margin-top: 8px; line-height: 1.25; }
+.hero-details > span { min-width: 0; }
+.schedule-time { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--outline); border-radius: 6px; padding: 4px 6px; white-space: nowrap; }
+.departure-day { font-size: .6em; color: var(--muted); white-space: nowrap; }
+.countdown { display: flex; align-items: flex-end; justify-content: flex-end; min-width: 0; font-variant-numeric: tabular-nums; white-space: nowrap; }
+.countdown-value { display: inline-flex; align-items: flex-end; gap: .22em; font-size: min(68px, calc(var(--row-height, 92px) * .64)); font-weight: 550; line-height: 1; }
+.countdown-part { display: inline-flex; align-items: flex-end; gap: .12em; }
+.countdown-part small { color: var(--secondary); font-size: max(18px, .42em); font-weight: 500; display: flex; align-items: flex-end; line-height: 1; }
+.countdown[data-long='true'] .countdown-value { font-size: min(54px, calc(var(--row-height, 92px) * .48)); }
+.countdown-large .countdown-value { color: var(--accent); font-size: min(150px, calc(var(--hero-height) * .9)); }
+.countdown-large[data-long='true'] .countdown-value { font-size: min(105px, calc(var(--hero-height) * .66)); }
+.countdown-now { font-size: min(40px, calc(var(--row-height, 92px) * .4)); font-weight: 550; }
+.countdown-large .countdown-now { color: var(--accent); font-size: min(72px, calc(var(--hero-height) * .44)); }
+.next-heading { border-top: 1px solid var(--line); padding: clamp(10px, 1.4vh, 18px) 0; }
+.next-heading svg { color: var(--muted); }
.page-indicator { color: var(--accent); font-size: 15px; }
.departure-table { min-height: 0; display: grid; grid-template-rows: repeat(var(--rows), minmax(0, 1fr)); overflow: hidden; }
-.departure-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto minmax(105px, .17fr); align-items: center; gap: clamp(15px, 2.4vw, 48px); min-height: 0; border-bottom: 1px solid #303030; }
+.departure-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: clamp(16px, 2.2vw, 44px); min-height: 0; padding: 5px 0; border-bottom: 1px solid var(--line); }
.departure-row:last-child { border-bottom: 0; }
-.row-destination { min-width: 0; min-height: 0; height: 100%; padding: 3px 0; display: flex; flex-direction: column; justify-content: center; }
-.row-destination h3 { flex-shrink: 0; font-size: min(54px, 3.5vw, calc(var(--row-height) * .43)); font-weight: 600; line-height: 1.1; letter-spacing: -.025em; margin: 0; overflow-wrap: anywhere; }
-.row-destination p { margin: 5px 0 0; font-size: min(24px, 1.35vw, calc(var(--row-height) * .2)); line-height: 1.2; color: #bcbcbc; }
+.row-destination { min-width: 0; min-height: 0; height: 100%; display: flex; flex-direction: column; justify-content: center; }
+.row-destination h3 { flex-shrink: 0; font-size: min(54px, 3.5vw, calc(var(--row-height) * .4)); font-weight: 600; line-height: 1.1; letter-spacing: -.025em; margin: 0; overflow-wrap: anywhere; }
+.row-destination p { margin: 6px 0 0; font-size: min(24px, 1.35vw, calc(var(--row-height) * .2)); line-height: 1.2; color: var(--secondary); }
.departure-table[data-rows='5'] .row-destination p, .departure-table[data-rows='6'] .row-destination p { display: none; }
-.row-time { color: #c3c3c3; font-size: min(46px, calc(var(--row-height) * .43)); font-variant-numeric: tabular-nums; letter-spacing: -.025em; }
-.transport-notice { display: flex; align-items: center; gap: 10px; color: var(--warning); padding: 10px 0 0; font-size: clamp(15px, 1.4vw, 26px); flex: 0 0 auto; }
+.row-time { display: flex; flex-direction: column; align-items: flex-end; gap: 7px; color: var(--secondary); font-size: min(42px, calc(var(--row-height) * .36)); font-variant-numeric: tabular-nums; }
+.row-time-compact { display: none; }
+.transport-notice { display: flex; align-items: center; gap: 10px; color: var(--warning); padding-top: 10px; font-size: clamp(15px, 1.4vw, 26px); flex: 0 0 auto; }
.empty-departures { display: flex; flex-direction: column; justify-content: center; min-height: 0; }
.empty-departures > svg { color: var(--accent); }
.empty-departures h2 { font-size: clamp(24px, 3vw, 54px); font-weight: 550; margin: 10px 0; letter-spacing: -.04em; }
-.empty-departures p, .empty-following { color: #bdbdbd; font-size: clamp(14px, 1.5vw, 28px); margin: 0; line-height: 1.4; }
+.empty-departures p, .empty-following { color: var(--secondary); font-size: clamp(14px, 1.5vw, 28px); margin: 0; line-height: 1.4; }
.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 button { color: inherit; border: 0; background: none; display: flex; padding: 3px; margin-left: auto; }
-/* Secondary content occupies the same bounded screen area. */
-.info-screen { height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 18px 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #555 #000; }
-.info-intro { flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 18px; }
-.info-intro h2 { margin: 0; font-size: clamp(28px, 3.5vw, 62px); letter-spacing: -.045em; font-weight: 550; }
-.information-tabs { display: none; gap: 10px; }
-.information-tabs button { font-size: 14px; border: 1px solid #484848; background: transparent; padding: 8px 12px; border-radius: 8px; color: #bdbdbd; }
-.information-tabs button[aria-current='page'] { color: #000; border-color: var(--accent); background: var(--accent); }
-.info-grid { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 24px; }
-.info-card { min-height: 0; border: 1px solid #3b3b3b; border-radius: 18px; padding: clamp(18px, 2vw, 38px); display: flex; flex-direction: column; overflow-y: auto; container-type: size; }
-.card-icon { color: var(--accent); margin-bottom: 18px; }
+/* 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-intro { flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
+.info-intro h2 { margin: 0; font-size: clamp(28px, 3vw, 56px); letter-spacing: -.045em; font-weight: 550; }
+.information-tabs { display: none; gap: 8px; }
+.information-tabs button { font-size: 14px; border: 1px solid var(--outline); background: transparent; padding: 8px 12px; border-radius: 10px; color: var(--secondary); }
+.information-tabs button[aria-current='page'] { color: var(--accent-ink); border-color: var(--accent); background: var(--accent); }
+.info-grid { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
+.info-card { min-width: 0; min-height: 0; border: 1px solid var(--line); border-radius: 24px; padding: clamp(18px, 1.7vw, 32px); display: flex; flex-direction: column; overflow: auto; container-type: size; }
+.info-card > * { flex-shrink: 0; }
+.info-card h3 { font-size: clamp(23px, 2vw, 38px); line-height: 1.15; font-weight: 600; letter-spacing: -.03em; margin: 0 0 18px; }
+.card-icon { color: var(--accent); margin-bottom: 16px; }
.card-icon svg { width: 32px; height: 32px; }
-.info-card h3 { font-size: clamp(23px, 2vw, 38px); line-height: 1.2; font-weight: 600; letter-spacing: -.03em; margin: 0 0 16px; }
-.info-card p { color: #c8c8c8; font-size: clamp(18px, 1.75vw, 32px); line-height: 1.5; margin: 0 0 20px; }
-.info-caption { display: flex; align-items: center; gap: 9px; font-size: clamp(14px, 1.3vw, 23px); color: #bdbdbd; margin-top: auto; line-height: 1.4; }
-.info-caption svg { color: var(--accent); }
-.route-cloud { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 16px; }
-.route-cloud > span { display: inline-flex; align-items: center; justify-content: center; min-width: 48px; height: 42px; padding: 0 10px; border: 1px solid #555; border-radius: 8px; font-size: clamp(20px, 1.5vw, 28px); font-weight: 550; }
-.routes-info p { margin: auto 0 0; font-size: clamp(16px, 1.4vw, 25px); }
-.ridebus-link { display: flex; gap: 10px; align-items: center; color: var(--accent); font-size: clamp(28px, 3vw, 54px); letter-spacing: -.045em; text-decoration: none; margin: auto 0 20px; }
-.cache-caption { display: flex; gap: 9px; align-items: center; color: var(--accent); font-size: clamp(15px, 1.25vw, 24px); line-height: 1.35; margin-top: auto; }
-.routes-info .schedule-explanation { margin: 12px 0 0; font-size: clamp(14px, 1.1vw, 21px); line-height: 1.35; }
-.weather-current { display: flex; align-items: center; gap: 20px; color: var(--accent); }
-.weather-current strong { font-size: clamp(64px, 5.5vw, 106px); line-height: 1.15; font-weight: 550; }
-.weather-info .weather-description { margin: 6px 0 12px; font-size: clamp(20px, 1.7vw, 32px); line-height: 1.2; }
-.weather-details { display: flex; flex-wrap: wrap; gap: 6px 18px; color: #c3c3c3; font-size: clamp(15px, 1.15vw, 23px); }
+.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, 1fr); gap: 8px; border-top: 1px solid var(--line); margin-top: 18px; padding-top: 14px; }
-.weather-hours > div { display: flex; flex-direction: column; align-items: center; gap: 8px; }
-.weather-hours time { color: #bdbdbd; font-size: clamp(14px, 1vw, 22px); }
-.weather-hours svg { color: var(--accent); }
+.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: #c3c3c3; margin-top: 14px; line-height: 1.35; }
-.weather-attribution { margin-top: auto; padding-top: 14px; display: flex; flex-wrap: wrap; gap: 5px 12px; font-size: clamp(12px, .8vw, 17px); color: #aaa; }
+.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); }
.weather-attribution a { color: inherit; text-underline-offset: 3px; }
+.weather-now-label, .weather-hour-rain { display: none; }
.qr-info { align-items: center; text-align: center; }
-.qr-content { margin: auto 0; min-height: 0; display: flex; align-items: center; flex-direction: column; }
-.passenger-qr { width: clamp(130px, 23vh, 248px); max-width: 100%; aspect-ratio: 1; }
-.qr-content p { font-size: clamp(16px, 1.25vw, 24px); line-height: 1.3; margin: 14px 0; }
-.passenger-link { display: flex; align-items: center; gap: 9px; color: var(--accent); text-decoration: none; font-size: clamp(17px, 1.4vw, 27px); margin-top: auto; }
-@media (max-height: 850px) {
- .info-screen { padding: 12px 0; }
- .info-intro { margin-bottom: 12px; }
- .info-intro h2 { font-size: 32px; }
- .info-status { padding-top: 12px; font-size: 16px; }
- .persistent-next .countdown > span { font-size: 40px; }
- .routes-info .card-icon { display: none; }
- .routes-info .route-cloud { gap: 7px; }
- .routes-info .route-cloud > span { height: 32px; min-width: 40px; padding: 0 7px; font-size: 18px; }
- .info-card { padding: 20px; }
-}
-@container (max-height: 370px) {
- .info-card h3 { margin-bottom: 12px; font-size: 24px; }
- .weather-current strong { font-size: 56px; }
- .weather-current svg { width: 44px; height: 44px; }
- .weather-info .weather-description { font-size: 20px; margin: 4px 0 10px; }
- .weather-details { font-size: 14px; }
- .weather-hours { margin-top: 12px; padding-top: 10px; }
- .weather-hours > div { display: grid; grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 6px; }
- .weather-hours time { grid-column: 1 / -1; text-align: center; font-size: 14px; }
- .weather-hours strong { font-size: 21px; }
- .precipitation-chance { font-size: 14px; margin-top: 10px; }
- .weather-attribution { padding-top: 10px; font-size: 11px; }
- .routes-info .schedule-explanation { display: none; }
-}
-.info-status { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: #c3c3c3; font-size: clamp(14px, 1.4vw, 25px); padding-top: 16px; line-height: 1.3; }
+.qr-info h3 { width: 100%; }
+.qr-content { flex: 1 1 0; min-height: 0; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; }
+.qr-paper { display: block; 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); }
+.passenger-qr { width: clamp(108px, calc(100cqh - 175px), 248px); max-width: 100%; aspect-ratio: 1; }
+.qr-description { display: flex; flex-direction: column; align-items: center; gap: 12px; }
+.qr-description p { font-size: clamp(16px, 1.25vw, 24px); line-height: 1.3; margin: 0; }
+.passenger-link { display: flex; align-items: center; gap: 9px; color: var(--accent); text-decoration: none; font-size: clamp(17px, 1.4vw, 27px); }
+.info-screen[data-compact='true'] .info-grid { grid-template-columns: minmax(0, 1fr); }
+.info-screen[data-compact='true'] .information-tabs { display: flex; }
+.info-screen[data-compact='true'] .info-card:not([data-active='true']) { display: none; }
+.info-screen[data-compact='true'] .card-icon { display: none; }
+.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 { padding: 24px; border: 1px solid #586447; border-radius: 16px; }
-.notice-warning { border-color: #76603a; }
-.notice-title { color: var(--accent); display: flex; align-items: center; gap: 10px; font-size: 20px; }
-.notice-warning .notice-title, .notice-warning .notice-routes { color: var(--warning); }
-.notice-routes { margin-top: 15px; font-size: 24px; }
-.notice h3 { font-size: clamp(24px, 2.4vw, 42px); font-weight: 600; line-height: 1.2; margin: 15px 0; }
-.notice p { font-size: clamp(18px, 1.8vw, 32px); line-height: 1.5; color: #d1d1d1; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
-.persistent-next { display: flex; align-items: center; gap: 20px; border-top: 1px solid var(--line); padding: 12px 0; flex: 0 0 auto; --row-height: 72px; }
-.persistent-next .route-badge { width: 78px; height: 48px; font-size: 31px; padding: 0 10px; border-radius: 9px; }
-.persistent-next strong { min-width: 0; font-weight: 600; font-size: clamp(21px, 2.4vw, 42px); line-height: 1.15; }
+.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[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-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); }
+.notice-routes { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
+.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; }
+.persistent-destination strong { font-weight: 600; font-size: clamp(21px, 2.2vw, 40px); line-height: 1.1; }
+.persistent-destination time { display: flex; align-items: center; gap: 8px; font-size: clamp(14px, 1.2vw, 23px); color: var(--secondary); }
.persistent-next .countdown { margin-left: auto; }
-.persistent-next .countdown-future { flex-direction: row; align-items: baseline; }
+.persistent-next .countdown-value { font-size: 40px; }
+.persistent-next .countdown[data-long='true'] .countdown-value { font-size: 34px; }
.ad-screen { padding: 4vh 0; height: 100%; overflow-y: auto; }
.ad-screen h2 { font-size: clamp(44px, 7vw, 135px); color: var(--accent); line-height: 1.06; letter-spacing: -.055em; font-weight: 550; margin: 4vh 0 3vh; overflow-wrap: anywhere; }
-.ad-screen p { font-size: clamp(22px, 2.7vw, 50px); color: #ccc; line-height: 1.4; margin: 0; }
-.ad-signature { display: flex; align-items: center; gap: 15px; color: #bbb; margin-top: 4vh; font-size: 22px; }
+.ad-screen p { font-size: clamp(22px, 2.7vw, 50px); color: var(--secondary); line-height: 1.4; margin: 0; }
+.ad-signature { display: flex; align-items: center; gap: 15px; color: var(--muted); margin-top: 4vh; font-size: 22px; }
-/* Footer is a normal flex item, never an overlay. */
.board-footer { flex: 0 0 auto; position: relative; border-top: 1px solid var(--line); view-transition-name: board-footer; }
.cycle-progress { position: absolute; top: -1px; width: 100%; height: 1px; }
.cycle-progress > span { display: block; width: 100%; height: 1px; background: var(--accent); transform-origin: left; transition: transform 1s linear; }
-.footer-main { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 58px; padding: 8px 0; }
-.scene-navigation { display: flex; align-items: center; gap: 22px; }
-.scene-navigation button { border: 0; padding: 4px 0; background: none; color: #bdbdbd; display: flex; align-items: center; gap: 9px; font-size: clamp(13px, 1.05vw, 20px); white-space: nowrap; }
-.scene-navigation button.active { color: #fff; }
-.scene-index { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid #555; border-radius: 50%; font-size: 13px; }
-.active .scene-index { color: #000; background: var(--accent); border-color: var(--accent); }
-.footer-status { display: flex; align-items: center; gap: 8px; color: #aaa; margin-left: auto; font-size: clamp(12px, .95vw, 18px); }
-.board-controls { display: flex; gap: 6px; flex-shrink: 0; opacity: 1; transition: opacity 250ms ease, transform 250ms ease; }
+.footer-main { display: flex; align-items: center; min-height: 60px; padding: 8px 0; }
+.scene-navigation { display: flex; align-items: center; gap: 22px; flex-shrink: 0; }
+.scene-navigation button { border: 0; padding: 4px 0; background: none; color: var(--secondary); display: flex; align-items: center; gap: 9px; font-size: clamp(13px, 1.05vw, 20px); white-space: nowrap; }
+.scene-navigation button.active { color: var(--foreground); }
+.scene-index { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid var(--outline); border-radius: 50%; font-size: 13px; }
+.active .scene-index { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); }
+.footer-status { display: flex; align-items: center; justify-content: flex-end; gap: 8px; color: var(--muted); margin-left: auto; padding-left: 16px; text-align: right; font-size: clamp(12px, .95vw, 18px); min-width: 0; }
+.board-controls { display: flex; gap: var(--control-gap); width: calc(4 * var(--control-size) + 3 * var(--control-gap)); margin-left: 14px; flex-shrink: 0; min-width: 0; overflow: hidden; opacity: 1; transition: width 280ms ease, margin-left 280ms ease, opacity 200ms ease; }
.controls-idle, .controls-idle * { cursor: none; }
-.controls-idle .board-controls { opacity: 0; transform: translateY(3px); pointer-events: none; }
-.icon-button { display: inline-flex; justify-content: center; align-items: center; width: 40px; height: 40px; color: #c0c0c0; border: 1px solid transparent; border-radius: 9px; background: none; transition: color 180ms, border-color 180ms; }
-.icon-button:hover { color: #fff; border-color: #555; }
-.toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); max-width: 90vw; z-index: 20; padding: 15px 22px; background: #171717; border: 1px solid #555; border-radius: 12px; font-size: 15px; }
+.controls-idle .board-controls { width: 0; margin-left: 0; opacity: 0; pointer-events: none; }
+.icon-button { display: inline-flex; justify-content: center; align-items: center; flex-shrink: 0; width: var(--control-size, 40px); height: var(--control-size, 40px); color: var(--secondary); border: 1px solid transparent; border-radius: 9px; background: none; }
+.icon-button:hover { color: var(--foreground); border-color: var(--outline); }
+.toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); max-width: 90vw; z-index: 20; padding: 15px 22px; background: var(--surface); color: var(--foreground); border: 1px solid var(--outline); border-radius: 12px; font-size: 15px; }
-/* Rolling digits keep tabular widths; only changed positions animate. */
-.numeric-text { display: inline-block; vertical-align: middle; line-height: 1.12; letter-spacing: 0; }
-.numeric-track { display: inline-flex; align-items: center; height: 1.12em; white-space: nowrap; }
-.numeric-glyph { position: relative; display: inline-block; width: .62em; height: 1.12em; overflow: hidden; line-height: 1.12; text-align: center; flex-shrink: 0; }
-.numeric-symbol { display: inline-block; line-height: 1.12; }
-.numeric-current { display: block; height: 1.12em; }
-.numeric-old { position: absolute; left: 0; top: 0; width: 100%; height: 1.12em; }
+/* Measured ink bounds remove font leading from digits and share one baseline with units. */
+.numeric-text { display: inline-flex; vertical-align: baseline; height: var(--numeric-height, .8em); line-height: 1; letter-spacing: 0; }
+.numeric-track { display: inline-flex; align-items: flex-start; height: 100%; overflow: hidden; white-space: nowrap; }
+.numeric-glyph { position: relative; display: inline-block; width: .62em; height: 100%; overflow: hidden; text-align: center; flex-shrink: 0; }
+.numeric-symbol { display: block; position: relative; top: var(--numeric-offset, -.13em); line-height: 1em; }
+.numeric-current { display: block; position: relative; top: var(--numeric-offset, -.13em); height: 1em; line-height: 1em; }
+.numeric-old { position: absolute; left: 0; top: var(--numeric-offset, -.13em); width: 100%; height: 1em; line-height: 1em; }
.numeric-glyph[data-rolling='true'] .numeric-current { animation: digit-up-in 380ms cubic-bezier(.22,.8,.22,1) both; }
.numeric-glyph[data-rolling='true'] .numeric-old { animation: digit-up-out 380ms cubic-bezier(.22,.8,.22,1) both; }
.numeric-glyph[data-direction='down'][data-rolling='true'] .numeric-current { animation-name: digit-down-in; }
@@ -197,174 +216,226 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
@keyframes digit-down-out { from { transform: translateY(0); } to { transform: translateY(105%); } }
@keyframes digit-collapse { from { width: .62em; } to { width: 0; } }
@keyframes scene-in { from { opacity: 0; } to { opacity: 1; } }
+.destination-heading { position: relative; overflow: hidden; }
+.destination-current { display: block; }
+.destination-current[data-changing='true'] { animation: destination-up-in 380ms cubic-bezier(.22,.8,.22,1) both; }
+.destination-old { position: absolute; inset: 0 0 auto; display: block; animation: destination-up-out 380ms cubic-bezier(.22,.8,.22,1) both; }
+@keyframes destination-up-in { from { transform: translateY(105%); } to { transform: translateY(0); } }
+@keyframes destination-up-out { from { transform: translateY(0); } to { transform: translateY(-105%); } }
-/* Operator settings may scroll independently of the board. */
-.settings-dialog { width: min(600px, calc(100vw - 28px)); max-height: calc(100dvh - 32px); padding: 0; color: #eee; background: #0a0a0a; border: 1px solid #444; border-radius: 20px; overflow: auto; box-shadow: 0 25px 90px #000; }
-.settings-dialog::backdrop { background: #000c; backdrop-filter: blur(8px); }
+.settings-dialog { width: min(600px, calc(100vw - 28px)); max-height: calc(100dvh - 32px); padding: 0; color: var(--foreground); background: var(--surface); border: 1px solid var(--outline); border-radius: 20px; overflow: auto; box-shadow: 0 25px 90px #0006; }
+.settings-dialog::backdrop { background: #0009; backdrop-filter: blur(8px); }
.settings-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 25px 28px; border-bottom: 1px solid var(--line); }
.settings-heading h2 { margin: 8px 0 0; font-size: 27px; font-weight: 600; letter-spacing: -.04em; }
.settings-heading .eyebrow { font-size: 11px; }
.settings-body { padding: 0 28px 20px; }
-.settings-body section { border-bottom: 1px solid #303030; padding: 21px 0 14px; }
+.settings-body section { border-bottom: 1px solid var(--line); padding: 21px 0 14px; }
.settings-body h3 { margin: 0 0 17px; font-size: 18px; font-weight: 650; }
-.settings-note { color: #b5b5b5; font-size: 13px; margin-bottom: 18px; }
-.settings-body label { display: flex; flex-direction: column; gap: 9px; font-size: 14px; color: #c8c8c8; margin: 17px 0; }
-.settings-body select, .settings-body input:not([type='checkbox']):not([type='radio']), .settings-body textarea { width: 100%; color: #f5f5f5; background: #141414; border: 1px solid #555; border-radius: 8px; padding: 12px; font-size: 15px; }
+.settings-body h4 { margin: 20px 0 14px; font-size: 14px; font-weight: 600; }
+.settings-note { color: var(--secondary); font-size: 13px; margin-bottom: 18px; }
+.settings-body label { display: flex; flex-direction: column; gap: 9px; font-size: 14px; color: var(--secondary); margin: 17px 0; }
+.settings-body select, .settings-body input:not([type='checkbox']):not([type='radio']), .settings-body textarea { width: 100%; color: var(--foreground); background: var(--input); border: 1px solid var(--outline); border-radius: 8px; padding: 12px; font-size: 15px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
-.settings-body .switch-row { flex-direction: row; align-items: center; justify-content: space-between; gap: 15px; color: #eee; }
-.switch-row small { display: block; color: #b3b3b3; margin-top: 5px; font-size: 12px; line-height: 1.5; }
-.switch-row input { appearance: none; position: relative; flex-shrink: 0; margin: 0; width: 40px; height: 24px; background: #303030; border: 1px solid #606060; border-radius: 20px; transition: background 180ms; cursor: pointer; }
-.switch-row input::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #ccc; transition: transform 180ms; }
+.settings-body .switch-row { flex-direction: row; align-items: center; justify-content: space-between; gap: 15px; color: var(--foreground); }
+.switch-row small { display: block; color: var(--muted); margin-top: 5px; font-size: 12px; line-height: 1.5; }
+.switch-row input { appearance: none; position: relative; flex-shrink: 0; margin: 0; width: 40px; height: 24px; background: var(--line); border: 1px solid var(--outline); border-radius: 20px; transition: background 180ms; cursor: pointer; }
+.switch-row input::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: var(--secondary); transition: transform 180ms; }
.switch-row input:checked { background: var(--accent); border-color: var(--accent); }
-.switch-row input:checked::after { transform: translateX(16px); background: #000; }
+.switch-row input:checked::after { transform: translateX(16px); background: var(--accent-ink); }
.theme-picker { border: 0; padding: 0; margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
-.settings-body .theme-option { flex-direction: row; align-items: center; gap: 10px; padding: 12px; margin: 0; border: 1px solid #414141; border-radius: 9px; cursor: pointer; }
-.theme-option:has(input:checked) { border-color: var(--accent); color: #fff; }
+.settings-body .theme-option { flex-direction: row; align-items: center; gap: 10px; padding: 12px; margin: 0; border: 1px solid var(--outline); border-radius: 9px; cursor: pointer; }
+.theme-option:has(input:checked) { border-color: var(--accent); color: var(--foreground); }
.theme-option input { accent-color: var(--accent); margin: 0; width: 16px; height: 16px; }
.theme-swatch { width: 22px; height: 22px; border-radius: 50%; flex-shrink: 0; }
-.tiny-tag { font-size: 9px; color: var(--accent); border: 1px solid #555; border-radius: 4px; padding: 3px 5px; margin-left: 7px; letter-spacing: .07em; }
-.text-button { display: inline-flex; align-items: center; gap: 9px; border: 0; background: none; color: #c3c3c3; font-size: 14px; padding: 10px 0; }
+.tiny-tag { font-size: 9px; color: var(--accent); border: 1px solid var(--outline); border-radius: 4px; padding: 3px 5px; margin-left: 7px; letter-spacing: .07em; }
+.text-button { display: inline-flex; align-items: center; gap: 9px; border: 0; background: none; color: var(--secondary); font-size: 14px; padding: 10px 0; }
.settings-body > .text-button { margin-top: 15px; }
-.form-hint { font-size: 12px; color: #b3b3b3; line-height: 1.6; }
+.form-hint { font-size: 12px; color: var(--muted); line-height: 1.6; }
.settings-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 20px 28px; border-top: 1px solid var(--line); }
-.primary-button { display: inline-flex; align-items: center; gap: 8px; background: var(--accent); color: #000; border: 0; border-radius: 9px; padding: 13px 22px; font-size: 15px; font-weight: 650; }
+.primary-button { display: inline-flex; align-items: center; gap: 8px; background: var(--accent); color: var(--accent-ink); border: 0; border-radius: 9px; padding: 13px 22px; font-size: 15px; font-weight: 650; }
-@media (max-width: 1050px) { .brand-word { display: none; } .header-right { column-gap: 18px; } .row-destination p { display: none; } .footer-status { max-width: 180px; font-size: 12px; } }
-@media (orientation: portrait), (max-width: 850px) {
- .board { padding: 16px 22px 0; }
- h1 { font-size: clamp(30px, 5.7vw, 62px); }
- .stop-direction { font-size: clamp(16px, 3vw, 28px); }
+@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; }
+ .info-screen[data-compact='true'] .qr-content { flex-direction: row; gap: 40px; }
+ .info-screen[data-compact='true'] .passenger-qr { width: clamp(110px, calc(100cqh - 85px), 230px); }
+ .info-screen[data-compact='true'] .qr-description { align-items: flex-start; text-align: left; }
+ .info-screen[data-compact='true'] .qr-description p { font-size: clamp(22px, 2.5vw, 32px); }
+}
+@media (max-width: 1100px) {
.brand-word { display: none; }
- .clock { font-size: clamp(35px, 6.2vw, 68px); }
- .today { font-size: clamp(12px, 2.1vw, 20px); }
- .hero-destination h2 { font-size: min(6vw, calc(var(--hero-height) * .33)); }
- .transport-kind { font-size: clamp(14px, 2.5vw, 24px); }
- .hero-details { font-size: clamp(12px, 2.4vw, 22px); }
- .hero-departure { gap: 18px; }
- .countdown-large small { font-size: clamp(17px, 3vw, 28px); }
- .countdown-large .countdown-now { font-size: min(5.2vw, calc(var(--hero-height) * .4)); }
- .row-destination h3 { font-size: min(46px, 5.7vw, calc(var(--row-height) * .43)); }
- .row-time { font-size: min(36px, calc(var(--row-height) * .35)); }
- .departure-row { grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 18px; }
- .departure-row .route-badge { width: clamp(68px, 12vw, 126px); }
- .info-intro { flex-direction: column; align-items: flex-start; gap: 12px; }
- .information-tabs { display: flex; }
- .info-grid { grid-template-columns: 1fr; }
+ .header-right { column-gap: 18px; }
+ .scene-navigation button > span:last-child { display: none; }
+ .hero-details > span { display: none; }
+ .hero-departure { gap: 20px; }
+ .hero-destination h2 { font-size: min(4.5vw, calc(var(--hero-height) * .34)); }
+}
+@media (max-width: 1000px) {
+ .departure-row { grid-template-columns: auto minmax(0, 1fr) auto; gap: 16px; }
+ .row-time, .row-destination p { display: none; }
+ .row-time-compact { display: flex; align-items: center; gap: 6px; margin-top: 8px; color: var(--secondary); font-size: min(20px, calc(var(--row-height) * .21)); flex-shrink: 0; }
+ .row-time-compact .departure-day { font-size: .8em; }
+ .row-destination h3 { font-size: min(36px, 4vw, calc(var(--row-height) * .36)); }
+ .countdown[data-long='true'] .countdown-value { font-size: min(40px, calc(var(--row-height) * .42)); }
+ .countdown-large[data-long='true'] .countdown-value { font-size: calc(var(--hero-height) * .62); }
+}
+@media (max-height: 750px) {
+ .info-screen { padding: 12px 0; }
+ .info-intro { margin-bottom: 12px; }
+ .info-intro h2 { font-size: 30px; }
+ .info-card { padding: 18px; }
+ .card-icon { display: none; }
+ .info-card h3 { font-size: 26px; margin-bottom: 14px; }
+ .info-status { font-size: 14px; padding-top: 12px; }
+ .persistent-next { padding: 11px 0; }
+ .weather-attribution { font-size: 11px; }
+}
+@media (orientation: portrait), (max-width: 700px) {
+ .board { padding: 16px 20px 0; }
+ h1 { font-size: clamp(28px, 5.7vw, 62px); }
+ .stop-direction { font-size: clamp(15px, 3vw, 28px); }
+ .clock { font-size: clamp(32px, 6.2vw, 68px); }
+ .today { font-size: clamp(12px, 2vw, 20px); }
+ .brand-word { display: none; }
+ .info-intro { flex-direction: column; align-items: flex-start; gap: 10px; }
+ .info-grid { grid-template-columns: minmax(0, 1fr); }
.info-card:not([data-active='true']) { display: none; }
- .info-card { padding: 24px; }
- .info-card h3 { font-size: clamp(26px, 4vw, 40px); }
- .info-card p { font-size: clamp(20px, 3.2vw, 34px); }
- .route-cloud > span { height: 44px; min-width: 54px; font-size: 24px; }
- .info-caption, .info-status { font-size: clamp(15px, 2.4vw, 24px); }
- .persistent-next { gap: 13px; }
- .persistent-next > .eyebrow, .persistent-next > .source-label { display: none; }
+ .information-tabs { display: flex; }
+ .persistent-next > .eyebrow { display: none; }
}
@media (max-width: 700px) {
- .board { padding: 12px 16px 0; }
- .board-header { gap: 14px; padding-bottom: 12px; }
- .header-right { column-gap: 10px; }
- .official-mark { width: 32px; }
- .clock { font-size: clamp(28px, 6.8vw, 44px); gap: 4px; }
- h1 { font-size: clamp(27px, 6.5vw, 42px); }
- .today { font-size: 11px; }
- .stop-direction { font-size: 15px; margin-top: 7px; gap: 7px; }
- .stop-direction svg { width: 18px; height: 18px; }
+ .board { --control-size: 32px; --control-gap: 3px; --hero-height: clamp(100px, 16vh, 150px); --hero-gap: 18px; padding: 12px 16px 0; }
+ .board[data-row-count='5'], .board[data-row-count='6'] { --hero-height: 95px; }
+ .board-header { gap: 12px; padding-bottom: 12px; }
+ .header-right { column-gap: 9px; }
+ .official-mark { width: 30px; }
+ .clock { font-size: clamp(27px, 6.8vw, 44px); gap: 4px; }
+ h1 { font-size: clamp(25px, 6.5vw, 42px); }
+ .today { font-size: 10px; }
.stop-meta { gap: 5px 12px; }
+ .stop-direction { font-size: 14px; margin-top: 7px; gap: 7px; }
.weather-summary { font-size: 13px; gap: 6px; margin-top: 7px; }
.weather-summary svg { width: 20px; height: 20px; }
.weather-summary > span { display: none; }
.source-label { font-size: 12px; gap: 5px; }
- .eyebrow { font-size: 11px; }
- .hero-departure { gap: 13px; }
- .hero-destination h2 { font-size: min(6.3vw, calc(var(--hero-height) * .32)); }
- .transport-kind { font-size: 12px; gap: 5px; margin-bottom: 4px; }
- .transport-kind svg { width: 18px; height: 18px; }
- .hero-details { display: none; }
- .hero-details .schedule-time { display: none; }
- .route-badge-large { width: calc(var(--hero-height) * .72); padding: 0 12px; font-size: calc(var(--hero-height) * .55); }
- .countdown-large > span { font-size: calc(var(--hero-height) * .6); }
- .countdown-future > span { font-size: min(24px, calc(var(--row-height) * .36)); }
- .countdown-large.countdown-future > span { font-size: min(28px, calc(var(--hero-height) * .35)); }
+ .eyebrow { font-size: 10px; }
+ .hero-departure { gap: 12px; }
+ .transport-kind { font-size: 11px; gap: 5px; margin-bottom: 5px; }
+ .transport-kind svg { width: 16px; height: 16px; }
+ .hero-details { margin-top: 7px; font-size: 12px; }
+ .hero-destination h2 { font-size: min(6vw, calc(var(--hero-height) * .30)); }
+ .route-badge-large { width: calc(var(--hero-height) * .70); padding: 0 10px; font-size: calc(var(--hero-height) * .54); }
+ .countdown-large .countdown-value { font-size: calc(var(--hero-height) * .62); }
+ .countdown-large[data-long='true'] .countdown-value { font-size: min(34px, calc(var(--hero-height) * .36)); }
.countdown-large .countdown-now { font-size: 5vw; }
- .countdown-large small { font-size: 14px; }
- .countdown { gap: 5px; }
- .departure-row { gap: 12px; }
- .departure-row .route-badge { width: 65px; padding: 0 9px; }
- .row-time { display: none; }
- .departure-row { grid-template-columns: auto minmax(0, 1fr) auto; }
- .row-destination h3 { font-size: min(5.8vw, calc(var(--row-height) * .43)); }
- .countdown > span { font-size: min(36px, calc(var(--row-height) * .6)); }
- .countdown small { font-size: min(16px, calc(var(--row-height) * .25)); }
- .countdown-large > span { font-size: calc(var(--hero-height) * .6); }
+ .departure-row { gap: 10px; }
+ .departure-row .route-badge { width: 60px; padding: 0 8px; }
+ .row-destination h3 { font-size: min(5.7vw, calc(var(--row-height) * .36)); }
+ .row-time-compact { font-size: min(15px, calc(var(--row-height) * .19)); margin-top: 6px; }
+ .countdown .countdown-value { font-size: min(40px, calc(var(--row-height) * .6)); }
+ .countdown[data-long='true'] .countdown-value { font-size: min(27px, calc(var(--row-height) * .38)); }
+ .countdown-part small { font-size: max(14px, .42em); }
+ .countdown-large .countdown-value { font-size: calc(var(--hero-height) * .62); }
+ .countdown-large[data-long='true'] .countdown-value { font-size: 32px; }
.scene-navigation { gap: 12px; }
- .scene-navigation button > span:last-child { display: none; }
- .footer-main { min-height: 50px; gap: 8px; padding: 6px 0; }
- .footer-status { font-size: 10px; max-width: 95px; }
- .board-controls { gap: 3px; }
- .icon-button { width: 32px; height: 32px; }
+ .footer-main { min-height: 50px; padding: 6px 0; }
+ .footer-status { font-size: 10px; padding-left: 10px; }
+ .board-controls { margin-left: 8px; }
.board-controls svg { width: 21px; height: 21px; }
- .persistent-next { --row-height: 54px; gap: 10px; padding: 9px 0; }
- .persistent-next .route-badge { height: 38px; width: 64px; font-size: 26px; }
- .persistent-next strong { font-size: 18px; }
- .persistent-next .countdown > span { font-size: 29px; }
+ .persistent-next { --row-height: 54px; gap: 10px; padding: 10px 0; }
+ .persistent-next .route-badge { height: 42px; width: 60px; font-size: 27px; }
+ .persistent-destination strong { font-size: 17px; }
+ .persistent-destination time { font-size: 13px; }
+ .persistent-next .countdown-value { font-size: 29px; }
+ .persistent-next .countdown[data-long='true'] .countdown-value { font-size: 25px; }
.persistent-next .countdown-now { font-size: 19px; }
- .info-screen { padding: 12px 0; }
- .info-intro { gap: 10px; margin-bottom: 12px; }
- .info-intro h2 { font-size: 30px; }
+ .info-intro h2 { font-size: 28px; }
.information-tabs { gap: 7px; }
.information-tabs button { font-size: 12px; padding: 7px 10px; }
.info-card { padding: 18px; }
- .card-icon { margin-bottom: 10px; }
- .card-icon svg { width: 25px; height: 25px; }
.info-card h3 { font-size: 24px; margin-bottom: 12px; }
- .info-card p { font-size: 19px; }
- .weather-current strong { font-size: 68px; }
+ .weather-current strong { font-size: 58px; }
.weather-info .weather-description { font-size: 20px; }
- .weather-hours { margin-top: 14px; padding-top: 10px; }
+ .weather-hours { padding-top: 12px; }
+ .weather-body { gap: 12px; }
.weather-attribution { padding-top: 12px; }
- .routes-info .schedule-explanation { display: none; }
- .qr-content p { font-size: 16px; margin: 12px 0; }
- .passenger-qr { width: min(200px, 25vh); }
- .route-cloud { gap: 7px; }
- .route-cloud > span { height: 35px; min-width: 43px; padding: 0 7px; font-size: 19px; }
- .info-status { font-size: 13px; 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; }
+ .notice-signal { width: 44px; height: 44px; border-radius: 14px; }
+ .notice-signal svg { width: 28px; height: 28px; }
+ .notice-meta { margin-bottom: 12px; }
+ .notice-routes { margin-bottom: 12px; }
+ .notice-routes .route-badge { height: 35px; min-width: 42px; padding: 0 10px; font-size: 23px; }
+ .notice h3 { font-size: 25px; }
+ .notice p { font-size: 17px; }
.transport-notice, .offline-banner { font-size: 12px; }
.settings-heading, .settings-body, .settings-actions { padding-left: 20px; padding-right: 20px; }
}
-@media (max-height: 620px) {
- .board { --hero-height: 90px; padding-top: 10px; }
- .board[data-row-count='5'], .board[data-row-count='6'] { --hero-height: 76px; }
+@media (max-height: 620px) and (min-width: 701px) {
+ .board { --hero-height: 110px; --hero-gap: 18px; padding-top: 10px; }
+ .board[data-row-count='5'], .board[data-row-count='6'] { --hero-height: 95px; }
.board-header { padding-bottom: 10px; }
- .clock { font-size: 37px; }
+ .clock { font-size: 40px; }
h1 { font-size: 32px; }
- .stop-direction { font-size: 15px; }
.official-mark { width: 35px; }
- .brand { font-size: 26px; }
.today { font-size: 12px; }
- .info-card { padding: 16px; }
- .card-icon { display: none; }
+ .info-screen { padding: 10px 0; }
.info-intro h2 { font-size: 28px; }
+ .info-card { padding: 16px 20px; }
+ .info-card h3 { font-size: 25px; margin-bottom: 10px; }
+ .info-screen[data-compact='true'][data-cards='true'] { position: relative; }
+ .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; }
}
-@media (orientation: portrait) and (max-height: 850px) {
- .routes-info .card-icon, .routes-info p { display: none; }
- .routes-info .route-cloud { margin-bottom: 0; gap: 6px; }
- .routes-info .route-cloud > span { height: 32px; min-width: 40px; font-size: 18px; }
-}
-@media (orientation: portrait) and (max-height: 700px) {
- .weather-info { padding: 14px; }
- .info-intro h2 { font-size: 26px; }
+@media (max-width: 700px) and (max-height: 740px) {
+ .info-intro h2 { font-size: 25px; }
.info-intro { gap: 8px; }
- .info-status { font-size: 12px; padding-top: 8px; line-height: 1.25; }
- .weather-info h3 { margin-bottom: 6px; }
- .weather-current strong { font-size: 48px; }
- .weather-current svg { width: 40px; height: 40px; }
- .weather-info .weather-description { font-size: 18px; margin: 4px 0 8px; }
- .weather-hours { margin-top: 8px; padding-top: 6px; }
- .weather-hours > div { gap: 5px; }
+ .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: 6px; font-size: 11px; }
- .passenger-qr { width: 150px; }
- .qr-info h3 { font-size: 23px; }
+ .weather-attribution { padding-top: 8px; font-size: 10px; }
+ .weather-info { padding: 16px; }
+ .passenger-qr { width: clamp(108px, calc(100cqh - 130px), 190px); }
+ .qr-content { gap: 14px; }
+ .qr-description { gap: 9px; }
}
::view-transition { pointer-events: none; }
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 320ms; }
@@ -372,4 +443,9 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
*, *::before, *::after { animation-duration: .01ms !important; animation-delay: 0ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
.numeric-old { visibility: hidden; }
.numeric-current { transform: none !important; }
+ .destination-old { visibility: hidden; }
+ .destination-current { transform: none !important; }
+ .route-track { animation: none !important; }
+ .route-lane { transform: none; overflow: auto; }
+ .route-group:nth-child(2) { display: none; }
}
diff --git a/src/types.ts b/src/types.ts
index 6b42634..afcd87b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -9,7 +9,7 @@ export type BoardData = { cityId: number; cityName: string; stop: Stop; departur
alerts: Alert[]; alertsAvailable: boolean; partial: boolean; updatedAt: string; serverTime?: string; serviceDate: string; mode: 'live' | 'demo' };
export type Config = { cityId: number; cityName: string; stopName: string; defaultStopId: number; configured: boolean; refreshSeconds: number; timeZone: string; passengerBaseUrl?: string };
export type Settings = { stopId: number; demo: boolean; mainSeconds: number; infoSeconds: number; holdNear: boolean;
- rowCount: number; routeFilter: string; colorScheme: string; adsEnabled: boolean; adSeconds: number; adTitle: string; adText: string };
+ rowCount: number; routeFilter: string; theme: 'dark' | 'light'; colorScheme: string; lightColorScheme: string; adsEnabled: boolean; adSeconds: number; adTitle: string; adText: string };
export type Scene = 'departures' | 'info' | 'ad';
export type Timetable = { version: 1; cityId: number; cityName: string; stop: Stop; routes: (Route & { schedules: {
dayType: string; validFrom: string | null; validUntil: string | null; trips: { arrivalSeconds: number; destination: string; note: string; daysOfWeek: number[]; exceptDaysOfWeek: number[] }[];
diff --git a/test/presentation.test.mjs b/test/presentation.test.mjs
index b69c673..03d0f1c 100644
--- a/test/presentation.test.mjs
+++ b/test/presentation.test.mjs
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { colorScheme, colorSchemes, departurePage, numericFrame, stopDirection } from '../src/lib/presentation.mjs';
+import { colorScheme, colorSchemes, lightColorSchemes, departurePage, durationParts, numericFrame, stopDirection } from '../src/lib/presentation.mjs';
test('all accent schemes remain legible against black and with black route numbers', () => {
for (const scheme of colorSchemes) {
@@ -11,6 +11,23 @@ test('all accent schemes remain legible against black and with black route numbe
}
assert.equal(colorScheme('unknown').id, 'lime');
});
+test('light accents have at least 7:1 contrast against white and retain a separate default', () => {
+ for (const scheme of lightColorSchemes) {
+ const rgb = scheme.color.slice(1).match(/../g).map(value => parseInt(value, 16) / 255);
+ const channels = rgb.map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
+ const luminance = .2126 * channels[0] + .7152 * channels[1] + .0722 * channels[2];
+ assert.ok(1.05 / (luminance + .05) >= 7, scheme.id);
+ }
+ assert.equal(colorScheme('lime', 'light').id, 'blue');
+ assert.equal(colorScheme('cyan').id, 'cyan');
+});
+test('waiting time crosses hour boundaries without displaying hundreds of minutes', () => {
+ assert.deepEqual(durationParts(59), [{ value: 59, unit: 'мин' }]);
+ assert.deepEqual(durationParts(60), [{ value: 1, unit: 'ч' }]);
+ assert.deepEqual(durationParts(121), [{ value: 2, unit: 'ч' }, { value: 1, unit: 'мин' }]);
+ assert.deepEqual(durationParts(255), [{ value: 4, unit: 'ч' }, { value: 15, unit: 'мин' }]);
+ assert.deepEqual(durationParts(302), [{ value: 5, unit: 'ч' }, { value: 2, unit: 'мин' }]);
+});
test('clock carry rolls changed positions while keeping separators and unchanged digits still', () => {
const frame = numericFrame('09:59', '10:00', 959, 1000);
assert.equal(frame.direction, 'up');
diff --git a/test/qr.test.mjs b/test/qr.test.mjs
new file mode 100644
index 0000000..c3cd1f5
--- /dev/null
+++ b/test/qr.test.mjs
@@ -0,0 +1,23 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import jsQR from 'jsqr';
+import { createPassengerQr, passengerLink } from '../src/lib/passenger.mjs';
+
+test('rounded QR modules and finders remain decodable for multiple city and platform links', () => {
+ for (const [cityId, stopId] of [[1, 1], [1, 107], [2, 123456]]) {
+ const url = passengerLink('https://pwa.ridebus.by/', cityId, stopId), qr = createPassengerQr(url);
+ const scale = 6, width = qr.size * scale, pixels = new Uint8ClampedArray(width * width * 4).fill(255);
+ for (const rect of qr.rectangles) {
+ for (let y = rect.y * scale; y < (rect.y + rect.height) * scale; y++) for (let x = rect.x * scale; x < (rect.x + rect.width) * scale; x++) {
+ const px = (x + .5) / scale, py = (y + .5) / scale;
+ const cx = Math.max(rect.x + rect.rx, Math.min(rect.x + rect.width - rect.rx, px));
+ const cy = Math.max(rect.y + rect.rx, Math.min(rect.y + rect.height - rect.rx, py));
+ if ((px - cx) ** 2 + (py - cy) ** 2 > rect.rx ** 2) continue;
+ const index = (y * width + x) * 4, value = rect.fill === '#ffffff' ? 255 : 16;
+ pixels[index] = pixels[index + 1] = pixels[index + 2] = value;
+ }
+ }
+ assert.equal(jsQR(pixels, width, width)?.data, url);
+ assert.ok(qr.rectangles.every(rect => rect.x >= 4 && rect.y >= 4 && rect.x + rect.width <= qr.size - 4 && rect.y + rect.height <= qr.size - 4));
+ }
+});
diff --git a/test/storage.test.mjs b/test/storage.test.mjs
index 73be1a9..8a05ef4 100644
--- a/test/storage.test.mjs
+++ b/test/storage.test.mjs
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { readSnapshot, writeSnapshot, readTimetable, writeTimetable, readWeather, writeWeather } from '../src/lib/storage.ts';
+import { readSnapshot, writeSnapshot, readTimetable, writeTimetable, readWeather, writeWeather, readSettings, saveSettings } from '../src/lib/storage.ts';
const data = { cityId: 1, stop: { id: 1, name: 'Швейная фабрика' }, mode: 'live', serviceDate: '2026-10-08', updatedAt: '2026-10-08T12:00:00+03:00', routes: [], alerts: [],
departures: [{ id: '1:12:30', scheduledAt: '2026-10-08T12:30:00+03:00' }] };
@@ -30,3 +30,14 @@ test('full timetable and weather survive storage fallback and stay isolated by c
assert.deepEqual(await readTimetable(1, 1), timetable); assert.deepEqual(await readWeather(1, 1), weather); assert.deepEqual(await readSnapshot(1, 1), data);
assert.equal(await readTimetable(1, 2), null); assert.equal(await readWeather(2, 1), null);
});
+test('old theme preferences migrate safely and each theme retains its own accent', () => {
+ const values = new Map([['ridebus-board-settings-v1', JSON.stringify({ colorScheme: 'cyan' })]]);
+ globalThis.localStorage = { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
+ const old = readSettings();
+ assert.equal(old.theme, 'dark'); assert.equal(old.colorScheme, 'cyan'); assert.equal(old.lightColorScheme, 'blue');
+ saveSettings({ ...old, theme: 'light', lightColorScheme: 'crimson' });
+ const saved = readSettings();
+ assert.equal(saved.theme, 'light'); assert.equal(saved.lightColorScheme, 'crimson'); assert.equal(saved.colorScheme, 'cyan');
+ values.set('ridebus-board-settings-v1', JSON.stringify({ theme: 'light', lightColorScheme: 'lime' }));
+ assert.equal(readSettings().lightColorScheme, 'blue');
+});