feat: add passenger information and settings components

- Introduced `PassengerInfo.tsx` for displaying trip details, including upcoming trips and fare information.
- Added `PassengerSettings.tsx` for managing user-defined important places and fare settings.
- Implemented validation functions for passenger settings in `passenger-settings.mjs`.
- Created utility functions for transliterating stop names in `transliteration.ts`.
- Developed weather hint functionality in `weather-hint.ts` to provide precipitation probabilities.
- Established a mock server for testing passenger-related features in `passenger-server.mjs`.
- Added comprehensive tests for passenger guide logic and transliteration functionality.
This commit is contained in:
2026-10-12 20:30:49 +03:00
parent 823ab14367
commit e239de45dd
34 changed files with 687 additions and 68 deletions

View File

@@ -82,6 +82,8 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
/* 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[data-tabs-only=true] { display: none; }
.info-screen[data-compact=true] .info-intro[data-tabs-only=true] { display: flex; flex-direction: row; align-items: center; justify-content: flex-end; }
.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); }
@@ -103,13 +105,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.route-list-pages { display: flex; justify-content: flex-end; color: var(--muted); font-size: 16px; padding-top: 10px; }
/* A moving line and one large stop, inspired by over-door transit displays. */
.service-info { container-name: journey-card; overflow: hidden; }
.journey-route { display: flex; align-items: center; gap: 18px; min-width: 0; padding-bottom: 18px; border-bottom: 1px solid var(--line); }
.journey-route .route-badge { flex-shrink: 0; width: 80px; height: 62px; border-radius: 18px; font-size: 36px; }
.journey-trip { min-width: 0; flex: 1; display: flex; align-items: center; gap: 18px; }
.journey-route-name { min-width: 0; flex: 1; }
.service-info .journey-trip .destination-heading { margin: 0; font-size: clamp(22px, 3.8cqw, 36px); line-height: 1.2; overflow-wrap: anywhere; }
.journey-trip time { display: flex; gap: 8px; align-items: baseline; color: var(--accent); font-size: clamp(22px, 4.5cqw, 40px); font-weight: 600; }
.journey-trip .journey-day { color: var(--secondary); font-size: 17px; font-weight: 400; }
.service-info .journey-empty { margin-block: auto; padding: 20px 0; text-align: center; font-size: 22px; }
.journey-metro { --station-width: clamp(180px, 22cqw, 260px); --focused-width: min(clamp(300px, 60cqw, 560px), calc(100cqw - 48px)); flex: 1 1 0; min-height: 0; min-width: 0; display: flex; flex-direction: column; padding-top: clamp(16px, 4cqh, 32px); }
.journey-tour-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; color: var(--secondary); font-size: clamp(17px, 3.3cqw, 24px); line-height: 1.3; }
@@ -132,6 +127,8 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.journey-station[data-before=true] .journey-duration { opacity: 0; }
.service-info .journey-station-name .destination-heading { flex-shrink: 0; margin: 0; font-size: calc(clamp(22px, 4cqw, 32px) * var(--reading-scale, 1)); line-height: 1.13; font-weight: 600; letter-spacing: -.035em; overflow-wrap: anywhere; transition: color 600ms ease; }
.service-info .journey-name-focus .destination-heading { font-size: calc(clamp(48px, 11cqw, 96px) * var(--reading-scale, 1)); font-weight: 650; letter-spacing: -.055em; color: var(--foreground); }
.journey-transliteration { flex-shrink: 0; color: var(--muted); font-size: calc(clamp(14px, 2.2cqw, 20px) * var(--reading-scale, 1)); font-weight: 500; line-height: 1.2; letter-spacing: -.01em; overflow-wrap: anywhere; margin-bottom: 6px; }
.journey-name-focus .journey-transliteration { font-size: calc(clamp(17px, 3.1cqw, 28px) * var(--reading-scale, 1)); }
.journey-duration { align-self: start; display: flex; align-items: baseline; gap: 8px; color: var(--muted); font-size: clamp(24px, 4.5cqw, 36px); font-weight: 550; line-height: 1; white-space: nowrap; transition: opacity 300ms ease-out; }
.journey-duration > span { display: inline-flex; align-items: baseline; gap: 5px; }
.journey-duration small { font-size: .72em; font-weight: 500; }
@@ -152,8 +149,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.journey-metro[data-loop=true] .journey-station, .journey-metro[data-loop=true] .journey-continuation, .journey-metro[data-loop=true] .journey-line, .journey-metro[data-loop=true] .journey-name-layer { transition: none; }
@keyframes journey-overview-reset { from { opacity: 0; } to { opacity: 1; } }
@container journey-card (max-width: 380px) {
.journey-trip { display: block; }
.journey-trip time { margin-top: 7px; }
.journey-metro { --station-width: 140px; }
.service-info .journey-name-focus .destination-heading { font-size: calc(clamp(34px, 11cqw, 46px) * var(--reading-scale, 1)); }
.journey-name-layer { padding-right: 22px; }
@@ -161,10 +156,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.journey-caption { font-size: 14px; gap: 4px 10px; }
}
@container journey-card (max-height: 380px) {
.journey-route { gap: 12px; padding-bottom: 12px; }
.journey-route .route-badge { width: 72px; height: 48px; border-radius: 14px; font-size: 30px; }
.service-info .journey-trip .destination-heading { font-size: 24px; }
.journey-trip time { font-size: 24px; }
.journey-metro { padding-top: 12px; }
.journey-map-space { margin-top: 10px; }
.journey-station { grid-template-rows: auto minmax(0, 1fr) 32px; gap: 6px; }
@@ -184,10 +175,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.journey-caption-full { display: none; }
.journey-caption-compact { display: inline; }
}
@container journey-card (max-height: 260px) {
/* The same route and departure time remain visible in the persistent next-trip bar. */
.journey-route { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.journey-station, .journey-continuation, .journey-line, .journey-name-layer, .journey-duration, .service-info .journey-station .destination-heading { transition: none !important; will-change: auto !important; }
.journey-metro .destination-current, .journey-metro[data-loop=true] .journey-rail-window { animation: none; }
@@ -252,6 +239,50 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.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; }
.last-trip-notice { display: flex; align-items: center; gap: 9px; flex-shrink: 0; padding: 9px 12px; border-radius: 12px; color: var(--warning); background: color-mix(in srgb, var(--warning) 10%, var(--background)); font-size: clamp(14px, 1.4vw, 24px); line-height: 1.3; }
.last-trip-notice svg { flex-shrink: 0; }
.last-trip-notice small { font-size: .85em; color: var(--secondary); }
.last-trip-compact { background: transparent; padding: 3px 0 0; font-size: 13px; }
.last-trip-compact svg, .last-trip-compact small { display: none; }
.places-info { container-name: places-card; }
.places-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: repeat(var(--place-rows, 1), minmax(0, 1fr)); gap: 12px; }
.place-trip { min-height: 0; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 24px; border-bottom: 1px solid var(--line); padding: 10px 0; }
.place-trip:last-child { border-bottom: none; }
.place-copy { min-height: 0; min-width: 0; display: flex; flex-direction: column; justify-content: center; }
.places-info .place-copy .destination-heading { font-size: calc(clamp(24px, 5cqw, 52px) * var(--reading-scale, 1)); font-weight: 600; line-height: 1.13; letter-spacing: -.04em; margin: 0; overflow-wrap: anywhere; }
.place-details { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin-top: 10px; color: var(--secondary); font-size: clamp(18px, 3.2cqw, 30px); }
.place-details .route-badge { width: auto; min-width: 48px; height: 36px; border-radius: 10px; padding: 0 9px; font-size: 24px; }
.place-details time { display: inline-flex; align-items: baseline; gap: 8px; color: var(--accent); font-weight: 600; }
.place-details small, .place-arrival small { font-size: .6em; color: var(--secondary); font-weight: 400; }
.place-duration { display: flex; align-items: center; gap: 6px; font-size: .8em; }
.place-arrival { display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 8px; color: var(--secondary); font-size: clamp(14px, 2cqw, 20px); }
.place-arrival time { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: flex-end; gap: 8px; color: var(--foreground); font-weight: 600; font-size: calc(clamp(30px, 5.2cqw, 54px) * var(--reading-scale, 1)); }
.place-trip .place-unavailable { font-size: 16px; margin: 8px 0 0; }
.passenger-caption { flex-shrink: 0; display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font-size: clamp(13px, 1.2vw, 20px); padding-top: 12px; line-height: 1.3; }
.fare-info { container-name: fare-card; }
.fare-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1.8fr) minmax(0, .8fr); align-items: stretch; gap: 8px; text-align: center; padding: 6px 0; }
.fare-label { font-size: clamp(16px, 2cqw, 24px); color: var(--secondary); }
.fare-price, .fare-payment { min-height: 0; display: flex; align-items: center; justify-content: center; }
.fare-price .destination-heading { color: var(--accent); margin: 0; font-size: clamp(40px, 15cqw, 130px); line-height: 1.08; font-weight: 650; letter-spacing: -.06em; overflow-wrap: anywhere; }
.fare-info .fare-payment .destination-heading { color: var(--secondary); margin: 0; font-size: clamp(22px, 4cqw, 40px); font-weight: 500; line-height: 1.3; overflow-wrap: anywhere; }
@container fare-card (max-height: 300px) {
.fare-label { display: none; }
.fare-body { grid-template-rows: minmax(0, 1.8fr) minmax(0, .8fr); padding: 0; }
}
.weather-hint { display: flex; align-items: center; justify-content: center; gap: 10px; padding-top: 10px; color: var(--secondary); font-size: clamp(15px, 2.6cqw, 24px); line-height: 1.3; }
.weather-hint svg { flex-shrink: 0; color: var(--accent); }
.weather-hint strong { color: var(--accent); white-space: nowrap; font-size: 1.2em; font-weight: 600; }
.passenger-editor { min-width: 0; border: 1px solid var(--outline); border-radius: 14px; padding: 16px; margin: 12px 0; }
.passenger-editor legend { color: var(--secondary); padding: 0 6px; font-size: 14px; }
.passenger-editor .text-button { font-size: 14px; padding: 8px 0; }
@container places-card (max-width: 440px) {
.place-trip { gap: 14px; }
.place-details { gap: 6px 10px; font-size: 21px; }
.place-details .route-badge { height: 30px; font-size: 20px; min-width: 40px; }
.place-duration { flex-basis: 100%; }
.place-arrival { font-size: 12px; }
.place-arrival time { font-size: 30px; }
}
.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; }
@@ -507,6 +538,16 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.weather-info { padding: 16px; }
}
/* Viewing distance raises type sizes; pagination preserves the available height. */
/* More cards rotate one at a time. Keep their controls above the title so the
additional tabs never consume the name's width on a short TV viewport. */
.info-screen[data-compact=true][data-many-blocks=true][data-cards=true] .info-intro { position: static; margin: 0 0 10px; flex-shrink: 0; }
.info-screen[data-compact=true][data-many-blocks=true][data-cards=true] .info-card > h3 { padding-right: 0; min-height: 0; display: block; }
.info-screen[data-many-blocks=true] .information-tabs { flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.info-screen[data-many-blocks=true] .information-tabs button { padding: 7px 10px; font-size: 13px; }
@media (max-width: 440px) {
.info-screen[data-many-blocks=true] .information-tabs button { font-size: 12px; padding: 6px 8px; }
.weather-hint { font-size: 14px; gap: 7px; }
}
.board { --reading-scale: 1; }
.board[data-profile='street'], .board[data-distance='medium'], .board[data-distance='far'] { --hero-height: clamp(125px, calc(18vh * var(--reading-scale)), 270px); }
.board h1 { font-size: clamp(30px, calc(3.8vw * var(--reading-scale)), 90px); }