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 ; } 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 && }
- {alerts.length ?
{alerts.map(alert => )}
:
-

Маршруты остановки

{[...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 ?
{alerts.map(alert => )}
:
+

Маршруты остановки

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

{direction &&
{direction}
}
{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}
: Нет доступных рейсов}
}
{!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 && } + 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}
-

Цветовая схема

-
Контрастный цвет на чёрном фоне - {colorSchemes.map(scheme => )} -

Фон остаётся чёрным. Меняются акценты, номера маршрутов и логотип.

+

Оформление

+
Тема табло{([{ id: 'dark', name: 'Тёмная' }, { id: 'light', name: 'Светлая' }] as const).map(theme => )}
+

Цветовая схема

Акцент для {draft.theme === 'light' ? 'белого' : 'чёрного'} фона + {schemesForTheme(draft.theme).map(scheme => )} +

У каждой темы своя контрастная палитра. Выбранные цвета сохраняются отдельно.

Экраны и переходы

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 &&
{hours.map(hour =>
)}
} - {probability !== null &&
Осадки в ближайшие 3 часа: %
} + return <>

Погода

СЕЙЧАС
+

{weatherCondition(data.code, data.isDay).label}

Ощущается как м/с
+ {!!hours.length &&
{hours.map(hour =>
%осадки
)}
} + {probability !== null &&
Осадки в ближайшие 3 часа: %
}
{failed ? 'Сохранено' : 'Обновлено'} в {hourFormat.format(Date.parse(data.updatedAt))}Open-Meteo · CC BY 4.0
; } 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'); +});