Files

6135 lines
152 KiB
CSS

/* === Reset & Base === */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--safe-bottom: env(safe-area-inset-bottom, 0px);
--tab-height: 56px;
/* Light mode (default).
Surface model (iOS grouped-list style):
--color-page app/page background (light gray)
--color-bg elevated floating surfaces: sheets, popups, map panels
--color-bg-secondary card surface sitting on the page (white)
--color-inset nested fills inside a card: inputs, pressed states */
--color-text: #000;
--color-text-2: #3c3c43;
--color-page: #fafafa;
--color-bg: #fff;
--color-bg-secondary: #ffffff;
--color-inset: #f2f2f7;
--color-bg-elevated: #ffffff;
/* 4.6:1 on white — the old #8e8e93 was a 3.3:1 WCAG AA failure for the
app's pervasive muted text (hints, sublines, section headers). */
--color-hint: #6d6d72;
--color-separator: rgba(60,60,67,0.12);
--color-border: var(--color-separator);
--color-accent: #007aff;
--color-accent-text: #fff;
--color-favorite: #ffb800;
--color-good: #34c759;
--color-okay: #ff9500;
--color-bad: #ff3b30;
/* Spacing scale — every padding/margin/gap should snap to this. */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-7: 32px;
--sp-8: 40px;
/* Border-radius scale. r-md is the workhorse for cards/inputs;
r-pill is for chips and the round status pills. */
--r-xs: 6px;
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
--r-xl: 20px;
--r-pill: 999px;
/* Elevation — light mode keeps shadows soft so the secondary surface
stays the primary visual anchor on cards. */
--shadow-1: 0 1px 2px rgba(0,0,0,0.04), 0 1px 3px rgba(0,0,0,0.05);
--shadow-2: 0 2px 8px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.04);
--shadow-3: 0 6px 18px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.06);
--shadow-4: 0 16px 48px rgba(0,0,0,0.16), 0 4px 12px rgba(0,0,0,0.08);
/* === Motion system ===
Every transition and animation in the app draws its curve and duration
from here. One personality, applied consistently, is what separates a
premium-feeling UI from one that merely has animations: nothing should
move on a curve that isn't in this list.
Curves:
--ease-out the workhorse. Fast start, long soft landing — the
"expo out" feel iOS uses for almost everything.
Everything ENTERING the screen uses this.
--ease-in the mirror: slow start, accelerating exit. Everything
LEAVING the screen uses this, so departures gather
speed instead of drifting.
--ease-out-fast a shorter tail, for sub-200 ms press/hover feedback
where the long landing would read as sluggish.
--ease-soft gentler decel for opacity/colour-only fades.
--ease-in-out symmetric, for things that move and come back, and
for sweeps that should start and finish gently.
--ease-spring ~4 % overshoot. Toggles, thumbs, small pops.
--ease-spring-lg ~10 % overshoot. Celebratory pops only.
--ease-emphasis iOS sheet curve — near-instant start, glassy glide.
Reserved for large surfaces (sheets, drawers).
Durations scale with how far a thing travels: a 2 px press is --dur-1,
a full-screen sheet is --dur-6. Anything longer than --dur-6 stops
feeling responsive. */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in: cubic-bezier(0.64, 0, 0.78, 0);
--ease-out-fast: cubic-bezier(0.2, 0, 0, 1);
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1);
--ease-spring-lg: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-emphasis: cubic-bezier(0.32, 0.72, 0, 1);
--dur-0: 110ms;
--dur-1: 160ms;
--dur-2: 220ms;
--dur-3: 280ms;
--dur-4: 360ms;
--dur-5: 460ms;
--dur-6: 580ms;
/* Uniform press feedback. Rows use the subtle value (they're wide, so a
hard scale reads as a glitch), compact controls use the standard one. */
--press-scale: 0.96;
--press-scale-subtle: 0.985;
/* Stagger timing for dynamically rendered lists lives in app.js
(staggerIn) — it has to scale with item count, which CSS can't do. */
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-text: #ffffff;
--color-text-2: #ebebf5;
--color-page: #000000;
--color-bg: #000000;
--color-bg-secondary: #1c1c1e;
--color-inset: #2c2c2e;
--color-bg-elevated: #2c2c2e;
--color-hint: #98989e;
--color-separator: rgba(255,255,255,0.1);
--color-border: var(--color-separator);
--color-accent: #0a84ff;
--color-accent-text: #fff;
--color-favorite: #ffb800;
--shadow-1: 0 1px 2px rgba(0,0,0,0.4);
--shadow-2: 0 2px 8px rgba(0,0,0,0.35);
--shadow-3: 0 6px 18px rgba(0,0,0,0.45);
--shadow-4: 0 16px 48px rgba(0,0,0,0.55);
}
}
html[data-theme="dark"] {
--color-text: #ffffff;
--color-text-2: #ebebf5;
--color-page: #000000;
--color-bg: #000000;
--color-bg-secondary: #1c1c1e;
--color-inset: #2c2c2e;
--color-bg-elevated: #2c2c2e;
--color-hint: #98989e;
--color-separator: rgba(255,255,255,0.1);
--color-border: var(--color-separator);
--color-accent: #0a84ff;
--color-accent-text: #fff;
--shadow-1: 0 1px 2px rgba(0,0,0,0.4);
--shadow-2: 0 2px 8px rgba(0,0,0,0.35);
--shadow-3: 0 6px 18px rgba(0,0,0,0.45);
--shadow-4: 0 16px 48px rgba(0,0,0,0.55);
color-scheme: dark;
}
html[data-theme="light"] {
color-scheme: light;
}
html, body {
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.4;
color: var(--color-text);
background: var(--color-page);
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
-webkit-overflow-scrolling: touch;
}
/* === Accessibility: Focus-visible === */
:where(button, a, [role="button"], input, select, textarea):focus { outline: none; }
:where(button, a, [role="button"]):focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: 6px;
}
:where(input, select, textarea):focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
/* === Accessibility: Tap targets === */
/* Extend sub-44px controls' hit area to ~44px without changing their
visual size (iOS HIG / WCAG 2.5.5). The pseudo-element participates in
hit testing but paints nothing. */
.hour-chart-close, #map-location-banner-close, .map-search-clear,
.alert-step-btn, .hl-pin-btn {
position: relative;
}
.hour-chart-close::after, #map-location-banner-close::after,
.map-search-clear::after, .alert-step-btn::after, .hl-pin-btn::after,
.detail-modal-close::after, .sheet-expand-btn::after {
content: '';
position: absolute;
inset: calc((100% - 44px) / 2);
}
/* === Accessibility: Reduced motion === */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* === App Container === */
#app {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: calc(var(--tab-height) + var(--safe-bottom));
overflow: hidden;
}
/* First paint eases in instead of snapping on. Opacity only — a transform
here would turn #app into the containing block for its position:fixed
descendants (the sheet, the toast) and displace them mid-animation. */
@keyframes app-boot {
from { opacity: 0; }
to { opacity: 1; }
}
#app,
.tab-bar {
animation: app-boot var(--dur-4) var(--ease-soft) both;
}
/* === Tab Views ===
The container itself no longer moves — it only manages opacity and
visibility. All the motion lives on the cards inside (see the cascade
below), which is what makes a tab switch read as content arriving and
leaving rather than a whole page being swapped.
Dropping the container transform also removes a side effect: a non-`none`
transform makes the element the containing block for any position:fixed
descendant, so the old `translateY(0) scale(1)` on the active view was
silently redefining "fixed" for everything inside it. */
.tab-view {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
opacity: 0;
visibility: hidden;
pointer-events: none;
z-index: 0;
/* Held visible long enough for the cards to finish leaving (the slowest
exits at ~290 ms), so nothing gets cut off mid-flight. */
transition: opacity var(--dur-3) var(--ease-in),
visibility 0s linear var(--dur-4);
}
.tab-view.active {
opacity: 1;
visibility: visible;
pointer-events: auto;
/* Above the outgoing view, so cards arriving pass in front of cards
leaving instead of behind them. */
z-index: 1;
transition: opacity var(--dur-2) var(--ease-soft),
visibility 0s linear 0s;
}
/* --- Tab card choreography ----------------------------------------------
Entering a tab settles its cards in one after another; leaving sends them
back down. Enter decelerates (--ease-out), exit accelerates (--ease-in) —
the pairing is what makes the movement feel physical rather than like two
unrelated fades.
Enter is deliberately the longer, gentler half: arrivals are what the user
is waiting for, departures should get out of the way.
Containers whose contents are animated by JS on render (the stats body,
the history hero/summary cards) are excluded so nothing animates twice. */
@keyframes tab-card-enter {
from { opacity: 0; transform: translateY(14px) scale(0.985); }
to { opacity: 1; transform: none; }
}
@keyframes tab-card-exit {
from { opacity: 1; transform: none; }
to { opacity: 0; transform: translateY(-10px) scale(0.985); }
}
/* Opacity-only entrance for blocks whose *contents* are staggered by JS
(the stats body, the history hero/summary). Moving the container as well
would double the travel of every card inside it. */
@keyframes tab-block-enter {
from { opacity: 0; }
to { opacity: 1; }
}
/* Per-card stagger index. These rules only set a custom property, so they
can never collide with the animation rules that consume it — which is what
broke an earlier version of this cascade, where an `animation` shorthand on
a higher-specificity selector silently reset every delay to zero. */
.tab-view > *:nth-child(1) { --i: 0; }
.tab-view > *:nth-child(2) { --i: 1; }
.tab-view > *:nth-child(3) { --i: 2; }
.tab-view > *:nth-child(4) { --i: 3; }
.tab-view > *:nth-child(5) { --i: 4; }
.tab-view > *:nth-child(6) { --i: 5; }
.tab-view > *:nth-child(n+7) { --i: 6; }
/* EXIT — matches whenever the view is *not* active. Flipping animation-name
is what drives both directions: removing .active drops the enter rule, the
name reverts to tab-card-exit, and a new animation starts. */
.tab-view > .page-header,
#view-settings > .section,
#view-settings > .about-footer,
#view-stats > .section,
#view-stats > #stats-content,
#view-history > .section {
animation-duration: var(--dur-2);
animation-timing-function: var(--ease-in);
animation-fill-mode: both;
animation-name: tab-card-exit;
/* Leaving reverses the order — the block furthest down goes first, so the
page empties from the bottom up. */
animation-delay: calc((6 - var(--i, 0)) * 12ms);
}
/* ENTER — every selector here is one class more specific than its exit
counterpart above, so .active always wins. */
.tab-view.active > .page-header,
#view-settings.active > .section,
#view-settings.active > .about-footer,
#view-stats.active > .section,
#view-history.active > .section:not(#history-stats):not(#history-summary) {
animation-duration: var(--dur-5);
animation-timing-function: var(--ease-out);
animation-fill-mode: both;
animation-name: tab-card-enter;
animation-delay: calc(var(--i, 0) * 42ms);
}
#view-stats.active > #stats-content,
#view-history.active > #history-stats,
#view-history.active > #history-summary {
animation-duration: var(--dur-4);
animation-timing-function: var(--ease-out);
animation-fill-mode: both;
animation-name: tab-block-enter;
animation-delay: calc(var(--i, 0) * 42ms);
}
/* The map tab is a single full-bleed canvas — sliding it would fight the
map's own pan/zoom, and it has no page-header to cascade. */
#view-map > * {
animation: none;
}
/* === Sections (iOS Settings Style) === */
.section {
padding: 0 var(--sp-4);
margin-bottom: var(--sp-3);
}
.section-header {
font-size: 13px;
font-weight: 600;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.6px;
padding: var(--sp-5) var(--sp-1) var(--sp-2);
display: flex;
align-items: center;
gap: var(--sp-2);
}
.section-sub-header {
font-size: 11px;
font-weight: 600;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.6px;
padding: var(--sp-5) var(--sp-1) var(--sp-2);
}
.section-hint {
margin: 0 var(--sp-1) var(--sp-3);
font-size: 12px;
line-height: 1.5;
color: var(--color-hint);
}
.sync-badge {
display: none;
align-items: center;
justify-content: center;
color: #fff;
opacity: 0.45;
cursor: help;
width: 14px;
height: 14px;
position: relative;
transition: opacity var(--dur-3) var(--ease-soft), color var(--dur-3) var(--ease-soft);
}
body.logged-in .sync-badge {
display: inline-flex;
}
.sync-badge svg {
position: absolute;
left: 0;
top: 0;
transition: opacity var(--dur-3) var(--ease-soft);
}
.sync-badge .sync-icon-idle { opacity: 1; }
.sync-badge .sync-icon-ok { opacity: 0; }
/* The rotating arrow caused visible wobble because the icon is asymmetric;
sync state is now signalled by an opacity pulse on the cloud itself, so
the spin icon is permanently hidden (markup kept for back-compat). */
.sync-badge .sync-icon-spin { display: none; }
/* Syncing: cloud stays put and pulses gently. No rotation, no swap. */
.sync-badge.syncing { opacity: 1; }
.sync-badge.syncing .sync-icon-idle {
animation: sync-pulse 0.7s ease-in-out infinite;
}
/* Synced: cloud crossfades into the cloud+check variant with a green tint,
plus a tiny pop so success feels noticed. */
.sync-badge.synced .sync-icon-idle { opacity: 0; }
.sync-badge.synced .sync-icon-ok {
opacity: 1;
animation: sync-check-pop 0.28s ease-out;
}
.sync-badge.synced {
opacity: 0.95;
color: #34c759;
}
@keyframes sync-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
@keyframes sync-check-pop {
0% { transform: scale(0.7); }
60% { transform: scale(1.08); }
100% { transform: scale(1); }
}
/* Sits directly on the page (not inside a card), so it needs the card
surface — the inset tone would vanish against the light-mode page. */
.cloud-sync-hint {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: var(--sp-3) var(--sp-4) var(--sp-1);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--r-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
font-size: 12px;
color: var(--color-hint);
line-height: 1.4;
}
.cloud-sync-hint svg {
flex-shrink: 0;
opacity: 0.5;
}
body.logged-in .cloud-sync-hint {
display: none;
}
/* === Cards (shadcn-style: 1 px border + subtle background) === */
.card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-md);
padding: var(--sp-3) var(--sp-4);
margin-bottom: var(--sp-2);
}
.card-list {
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-md);
overflow: hidden;
margin-bottom: var(--sp-2);
}
.card-list .card-row {
padding: var(--sp-3) var(--sp-4);
border-bottom: 0.5px solid var(--color-separator);
}
/* Inline card header (used inside a .card to act as a section title for the
card's contents). Title is bigger and more emphatic than .section-header,
the description sits muted right beneath. */
.card-head {
display: flex;
flex-direction: column;
gap: var(--sp-1);
padding-bottom: var(--sp-3);
margin-bottom: var(--sp-3);
border-bottom: 1px solid var(--color-separator);
}
.card-head-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
}
.card-title-lg {
font-size: 17px;
font-weight: 600;
color: var(--color-text);
letter-spacing: -0.01em;
line-height: 1.25;
}
.card-description {
font-size: 13px;
color: var(--color-hint);
line-height: 1.45;
}
/* === Page header (sits at the top of each non-map tab) === */
.page-header {
/* Top inset keeps the H1 out from under the notch/status bar on iOS
(viewport-fit=cover pulls content behind it). */
padding: calc(env(safe-area-inset-top, 0px) + var(--sp-5)) var(--sp-4) var(--sp-3);
margin-bottom: var(--sp-2);
}
.page-title {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text);
line-height: 1.15;
}
.page-description {
font-size: 14px;
color: var(--color-hint);
margin-top: var(--sp-1);
line-height: 1.45;
}
/* === Metric (KPI) card — used for the 3-up dashboard tiles in history
and stats. Tighter than .card, more visual weight on the value. */
.metric-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-md);
padding: var(--sp-3) var(--sp-4);
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--sp-1);
min-height: 84px;
min-width: 0;
}
.metric-label {
font-size: 12px;
color: var(--color-hint);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.metric-value {
font-size: 24px;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.metric-trend {
font-size: 12px;
color: var(--color-hint);
margin-top: var(--sp-1);
display: inline-flex;
align-items: center;
gap: 4px;
}
.metric-trend.up { color: var(--color-bad); }
.metric-trend.down { color: var(--color-good); }
.metric-trend.flat { color: var(--color-hint); }
.metric-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.stats-overview-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 600px) {
.metric-row {
grid-template-columns: 1fr;
}
}
/* === Extreme cards (lowest / highest price in history summary) === */
.extreme-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-md);
padding: var(--sp-4) var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-1);
min-width: 0;
}
.extreme-label {
font-size: 12px;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.extreme-value {
font-size: 28px;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.extreme-value.good { color: var(--color-good); }
.extreme-value.bad { color: var(--color-bad); }
.extreme-card.is-empty .extreme-value { color: var(--color-hint); opacity: 0.6; }
.extreme-station {
font-size: 13px;
color: var(--color-hint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty-state-inline {
text-align: center;
padding: var(--sp-5) 0 var(--sp-2);
opacity: 0.5;
font-size: 13px;
color: var(--color-hint);
}
/* === Stats hero (overall avg headline) === */
.stats-hero {
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-md);
padding: var(--sp-5) var(--sp-5);
margin-bottom: var(--sp-3);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: var(--sp-1);
min-height: 120px;
}
.stats-hero-label {
font-size: 12px;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.stats-hero-value {
font-size: 40px;
font-weight: 800;
color: var(--color-text);
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
line-height: 1.05;
}
/* === Best times (cheapest day / hour) === */
.best-time-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-3);
}
.best-time-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-md);
padding: var(--sp-3) var(--sp-4);
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--sp-1);
min-height: 96px;
min-width: 0;
}
.best-time-label {
font-size: 12px;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.best-time-value {
font-size: 20px;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
}
.best-time-price {
font-size: 14px;
font-weight: 600;
margin-top: var(--sp-1);
font-variant-numeric: tabular-nums;
}
.best-time-price.good { color: var(--color-good); }
.best-time-price.bad { color: var(--color-bad); }
@media (max-width: 360px) {
.best-time-grid {
grid-template-columns: 1fr;
}
}
#stats-content {
width: 100%;
}
#stats-content > .metric-row {
margin-left: var(--sp-4);
margin-right: var(--sp-4);
}
/* === Stats hero card — overall avg with price spread visualisation === */
.stats-hero-section {
padding-bottom: 0;
}
.stats-hero-card {
position: relative;
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-lg);
padding: var(--sp-5) var(--sp-4) var(--sp-4);
margin-bottom: var(--sp-2);
overflow: hidden;
}
.stats-hero-top {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-3);
}
.stats-hero-headline {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.stats-hero-label {
font-size: 11px;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.7px;
font-weight: 600;
}
.stats-hero-value {
font-size: 36px;
font-weight: 800;
color: var(--color-text);
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
line-height: 1.05;
}
.stats-spread {
position: relative;
z-index: 1;
margin-top: var(--sp-4);
}
.stats-spread-track {
position: relative;
height: 5px;
border-radius: 3px;
background: var(--color-separator);
overflow: visible;
}
.stats-spread-fill {
position: absolute;
inset: 0;
border-radius: 3px;
background: linear-gradient(90deg, var(--color-good) 0%, var(--color-okay) 55%, var(--color-bad) 100%);
opacity: 0.32;
}
.stats-spread-marker {
position: absolute;
top: 50%;
width: 13px;
height: 13px;
margin-left: -6.5px;
border-radius: 50%;
background: var(--color-accent);
border: 2.5px solid var(--color-bg-secondary);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
transform: translateY(-50%);
transition: left 0.7s var(--ease-out);
}
.stats-spread-labels {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
margin-top: 8px;
font-size: 11px;
color: var(--color-hint);
font-variant-numeric: tabular-nums;
}
.stats-spread-low { color: var(--color-good); font-weight: 600; }
.stats-spread-high { color: var(--color-bad); font-weight: 600; }
.stats-spread-caption {
text-transform: uppercase;
letter-spacing: 0.6px;
font-weight: 600;
font-size: 10px;
color: var(--color-hint);
}
/* === Stats facts row — compact 3-up below the hero === */
.stats-facts-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.stats-fact-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-md);
padding: 10px;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.stats-fact-icon {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 9px;
display: grid;
place-items: center;
}
.stats-fact-icon-good { background: color-mix(in srgb, var(--color-good) 16%, transparent); color: var(--color-good); }
.stats-fact-icon-bad { background: color-mix(in srgb, var(--color-bad) 16%, transparent); color: var(--color-bad); }
.stats-fact-icon-accent { background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent); }
.stats-fact-body {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
.stats-fact-label {
font-size: 10px;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
line-height: 1.25;
}
.stats-fact-value {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
line-height: 1.15;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Best-time header + savings badge */
.best-time-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
}
.best-time-icon-wrap {
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: 8px;
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
color: var(--color-accent);
display: grid;
place-items: center;
}
.best-time-savings {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 6px;
padding: 3px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-good) 16%, transparent);
color: var(--color-good);
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
align-self: flex-start;
width: fit-content;
}
/* === Weekday tile row — 7 chronological cards with a coloured
cheapness bar at the bottom. Background stays neutral so the
price reads cleanly; the bar carries the rank signal. The
cheapest day gets a subtle tinted surface + coloured border. === */
.stats-tile-grid {
display: grid;
gap: 6px;
}
.stats-tile-grid-7 {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.stats-tile {
position: relative;
border-radius: 12px;
padding: 11px 4px 9px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 2px;
min-height: 64px;
min-width: 0;
transition: transform var(--dur-1) var(--ease-out-fast), border-color var(--dur-2) var(--ease-soft), background var(--dur-2) var(--ease-soft);
}
.stats-tile.is-empty .stats-tile-name,
.stats-tile.is-empty .stats-tile-value { opacity: 0.4; }
.stats-tile.is-best {
border-color: color-mix(in srgb, var(--tile-color) 65%, transparent);
background: color-mix(in srgb, var(--tile-color) 9%, var(--color-bg-secondary));
}
.stats-tile-name {
font-size: 10px;
font-weight: 700;
color: var(--color-hint);
letter-spacing: 0.5px;
text-transform: uppercase;
line-height: 1;
}
.stats-tile-value {
font-size: 13px;
font-weight: 700;
color: var(--tile-color, var(--color-text));
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
line-height: 1.1;
margin-top: 2px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stats-tile.is-empty .stats-tile-value {
color: var(--color-hint);
}
.stats-tile-bar {
margin-top: auto;
width: calc(100% - 12px);
height: 3px;
background: var(--color-separator);
border-radius: 999px;
overflow: hidden;
align-self: center;
}
.stats-tile-bar-fill {
height: 100%;
width: 0;
background: var(--tile-bar-color, var(--tile-color, color-mix(in srgb, var(--color-accent) 55%, transparent)));
border-radius: 999px;
transition: width 0.7s var(--ease-out);
}
.stats-tile.is-empty .stats-tile-bar-fill { background: var(--color-separator); }
.stats-tile-crown {
position: absolute;
top: 4px;
right: 5px;
font-size: 10px;
color: var(--tile-color);
z-index: 2;
line-height: 1;
}
/* === Settings: account hero card === */
.account-hero {
position: relative;
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-lg);
padding: var(--sp-4) var(--sp-4) var(--sp-4);
overflow: hidden;
}
.account-hero-top {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: var(--sp-4);
min-width: 0;
}
.account-hero-avatar {
flex-shrink: 0;
width: 52px;
height: 52px;
border-radius: 50%;
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-bg-secondary));
color: color-mix(in srgb, var(--color-accent) 80%, var(--color-text));
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent);
transition: border-color var(--dur-3) var(--ease-soft), background var(--dur-3) var(--ease-soft);
}
.account-hero.is-signed-in .account-hero-avatar {
background: var(--color-bg);
border-color: color-mix(in srgb, var(--color-accent) 55%, transparent);
color: var(--color-accent);
}
.account-hero-avatar .account-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.account-hero-avatar .account-avatar-fallback {
width: 100%;
height: 100%;
display: grid;
place-items: center;
font-size: 24px;
font-weight: 700;
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
}
.account-hero-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.account-hero-label {
font-size: 10px;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.7px;
font-weight: 700;
}
.account-hero-name {
font-size: 19px;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.02em;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-hero-subline {
font-size: 13px;
color: var(--color-hint);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
}
.account-hero-actions {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: var(--sp-3);
margin-top: var(--sp-4);
flex-wrap: wrap;
}
.account-hero-sync-hint {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--color-hint);
line-height: 1.35;
}
.account-hero-sync-hint svg {
flex-shrink: 0;
color: var(--color-accent);
opacity: 0.85;
}
.account-hero-btn {
flex-shrink: 0;
padding: 9px 18px;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-accent-text);
border: none;
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--dur-1) var(--ease-out), box-shadow var(--dur-2) var(--ease-out), background var(--dur-2) var(--ease-soft);
}
.account-hero-btn:hover {
box-shadow: 0 4px 12px color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.account-hero-btn:active {
transform: scale(0.96);
}
.account-hero.is-signed-in .account-hero-btn {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
.account-hero.is-signed-in .account-hero-btn:hover {
background: color-mix(in srgb, var(--color-text) 14%, transparent);
box-shadow: none;
}
/* === Settings rows: icon chip in front of the label === */
.settings-icon-chip {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 9px;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
color: var(--color-accent);
}
.settings-icon-chip.settings-icon-good {
background: color-mix(in srgb, var(--color-good) 14%, transparent);
color: var(--color-good);
}
.settings-icon-chip.settings-icon-warn {
background: color-mix(in srgb, var(--color-bad) 14%, transparent);
color: var(--color-bad);
}
.settings-card .settings-row-left {
gap: var(--sp-3);
}
.settings-card .settings-row-label {
font-size: 15px;
font-weight: 500;
}
/* === Segmented control (theme, history-default range) === */
.seg-control {
position: relative;
display: flex;
gap: 2px;
margin: 0 var(--sp-4) var(--sp-3);
padding: 2px;
border-radius: 9px;
background: color-mix(in srgb, var(--color-text) 7%, transparent);
}
.seg-thumb {
position: absolute;
top: 2px;
bottom: 2px;
left: 0;
width: 0;
background: var(--color-bg);
border-radius: 7px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.06);
/* The thumb overshoots a hair and settles — the single most recognisable
"this is a native control" cue in an iOS segmented picker. Width lags on
the plain curve so the pill doesn't wobble as it resizes. */
transition: transform var(--dur-5) var(--ease-spring),
width var(--dur-4) var(--ease-out);
pointer-events: none;
z-index: 1;
}
html[data-theme="dark"] .seg-thumb,
html:not([data-theme]) .seg-thumb { /* dark via prefers-color-scheme */
background: var(--color-bg-secondary);
}
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) .seg-thumb {
background: var(--color-bg-secondary);
}
}
.seg-option {
position: relative;
z-index: 2;
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 7px 10px;
border: none;
background: none;
color: var(--color-hint);
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
border-radius: 7px;
-webkit-tap-highlight-color: transparent;
transition: color var(--dur-2) var(--ease-soft);
white-space: nowrap;
min-width: 0;
}
.seg-option svg {
flex-shrink: 0;
opacity: 0.65;
transition: opacity var(--dur-2) var(--ease-soft), color var(--dur-2) var(--ease-soft);
}
.seg-option:hover { color: var(--color-text); }
.seg-option:hover svg { opacity: 0.85; }
.seg-option.active {
color: var(--color-text);
font-weight: 700;
}
.seg-option.active svg {
color: var(--color-accent);
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.seg-thumb { transition: none; }
}
/* === About hero — bigger app-icon centerpiece === */
.about-hero {
display: flex;
align-items: center;
gap: var(--sp-4);
padding: var(--sp-5);
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-md);
margin-bottom: var(--sp-3);
}
.about-hero .about-app-icon {
flex-shrink: 0;
width: 68px;
height: 68px;
border-radius: 16px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
cursor: pointer;
}
.about-hero-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.about-hero-info .about-app-name {
font-size: 22px;
font-weight: 800;
color: var(--color-text);
letter-spacing: -0.02em;
}
.about-hero-info .about-app-version {
font-size: 12px;
color: var(--color-hint);
font-variant-numeric: tabular-nums;
margin-bottom: 4px;
}
.about-hero-tag {
font-size: 13px;
color: var(--color-hint);
line-height: 1.35;
}
/* === History tab — hero card with current avg + trend pills === */
.history-hero-card {
position: relative;
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-lg);
padding: var(--sp-5) var(--sp-4) var(--sp-4);
overflow: hidden;
}
.history-hero-top {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-3);
}
.history-hero-headline {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.history-hero-label {
font-size: 11px;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.7px;
font-weight: 600;
}
.history-hero-value {
font-size: 36px;
font-weight: 800;
color: var(--color-text);
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
line-height: 1.05;
}
.history-hero-pills {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.history-hero-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 11px;
border-radius: 999px;
background: color-mix(in srgb, var(--pill-color) 13%, transparent);
color: var(--pill-color);
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.1px;
}
.history-hero-pill-arrow {
font-weight: 700;
}
.history-hero-pill-label {
color: color-mix(in srgb, var(--pill-color) 55%, var(--color-text));
opacity: 0.75;
font-weight: 500;
margin-left: 2px;
letter-spacing: 0.2px;
}
/* === History extreme cards — clickable, opens centred station popup === */
.history-extreme-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.history-extreme-card {
position: relative;
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: 14px;
padding: 12px 14px;
display: flex;
align-items: center;
gap: 12px;
text-align: left;
cursor: pointer;
font-family: inherit;
color: var(--color-text);
overflow: hidden;
-webkit-tap-highlight-color: transparent;
transition: transform var(--dur-1) var(--ease-out-fast),
box-shadow var(--dur-2) var(--ease-out),
border-color var(--dur-2) var(--ease-soft);
}
.history-extreme-card:hover {
border-color: color-mix(in srgb, var(--accent) 35%, var(--color-separator));
box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 12%, transparent);
}
.history-extreme-card:active {
transform: scale(0.98);
}
.history-extreme-card.is-empty {
cursor: default;
opacity: 0.55;
}
.history-extreme-card.is-empty:hover {
border-color: var(--color-separator);
box-shadow: none;
}
.history-extreme-card.is-empty:active {
transform: none;
}
.history-extreme-icon {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
display: grid;
place-items: center;
}
.history-extreme-body {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
overflow: hidden;
}
.history-extreme-label {
font-size: 10px;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.history-extreme-value {
font-size: 18px;
font-weight: 800;
color: var(--accent);
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
line-height: 1.15;
}
.history-extreme-station {
font-size: 12px;
color: var(--color-hint);
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* === History chart custom tooltip === */
.history-tooltip {
position: absolute;
pointer-events: none;
z-index: 99997;
background: rgba(20, 20, 20, 0.94);
border-radius: 12px;
padding: 10px 14px;
transform: translate(-50%, calc(-100% - 14px)) scale(0.94);
transform-origin: bottom center;
opacity: 0;
transition: opacity var(--dur-1) var(--ease-soft), transform var(--dur-1) var(--ease-spring-lg);
box-shadow: 0 8px 26px rgba(0, 0, 0, 0.28);
white-space: nowrap;
font-family: -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
text-align: center;
min-width: 130px;
}
.history-tooltip.show {
opacity: 1;
transform: translate(-50%, calc(-100% - 14px)) scale(1);
}
.history-tooltip-time {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
letter-spacing: 0.3px;
margin-bottom: 2px;
}
.history-tooltip-price {
font-size: 18px;
font-weight: 800;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.history-tooltip-meta {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 5px;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.78);
font-variant-numeric: tabular-nums;
letter-spacing: 0.1px;
}
.history-tooltip-hint {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.14);
font-size: 10px;
color: rgba(255, 255, 255, 0.55);
letter-spacing: 0.2px;
}
/* Hour chart section header — drilled-down day + close button */
#hour-chart-label {
display: flex !important;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.hour-chart-day {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hour-chart-close {
flex-shrink: 0;
border: none;
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-hint);
width: 24px;
height: 24px;
border-radius: 50%;
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
-webkit-tap-highlight-color: transparent;
transition: background var(--dur-1) var(--ease-soft), color var(--dur-1) var(--ease-soft);
}
.hour-chart-close:hover {
background: color-mix(in srgb, var(--color-text) 14%, transparent);
color: var(--color-text);
}
.hour-chart-close:active { transform: scale(0.92); }
/* === Hour line chart — custom HTML tooltip so the price text can pick
up the hovered hour's rank colour. === */
.stats-hour-tooltip {
position: absolute;
pointer-events: none;
z-index: 99997;
background: rgba(20, 20, 20, 0.94);
border-radius: 12px;
padding: 9px 14px;
transform: translate(-50%, calc(-100% - 14px)) scale(0.94);
transform-origin: bottom center;
opacity: 0;
transition: opacity var(--dur-1) var(--ease-soft), transform var(--dur-1) var(--ease-spring-lg);
box-shadow: 0 8px 26px rgba(0, 0, 0, 0.28);
white-space: nowrap;
font-family: -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
text-align: center;
}
.stats-hour-tooltip.show {
opacity: 1;
transform: translate(-50%, calc(-100% - 14px)) scale(1);
}
.stats-hour-tooltip-time {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
letter-spacing: 0.3px;
margin-bottom: 2px;
}
.stats-hour-tooltip-price {
font-size: 17px;
font-weight: 800;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
/* === Hour line chart — Chart.js canvas wrapper === */
.stats-hour-chart-wrap {
position: relative;
height: 170px;
padding: var(--sp-3) var(--sp-2) var(--sp-2);
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-lg);
animation: stats-chart-enter 0.55s var(--ease-out) both;
}
@keyframes stats-chart-enter {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.stats-hour-chart-wrap { animation: none; }
}
/* === Station ranking — quiet numbered badges, green winner === */
.stats-station-list .station-ranking-item {
position: relative;
cursor: pointer;
}
.stats-station-list .ranking-pos {
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--color-inset);
color: var(--color-hint);
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.stats-station-list .station-ranking-item.is-best .ranking-pos {
background: color-mix(in srgb, var(--color-good) 16%, transparent);
color: var(--color-good);
}
.stats-station-list .ranking-price {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.stats-station-list .station-ranking-item.is-best .ranking-price {
color: var(--color-good);
font-weight: 700;
}
/* Small "Ø" mark in the ranking price so users understand the number is
the 30-day average rather than the current price. Hover-title row
carries min + sample count for the full picture. */
.ranking-price-avg-mark {
font-size: 11px;
font-weight: 600;
margin-right: 4px;
opacity: 0.65;
letter-spacing: 0.5px;
}
@media (max-width: 360px) {
.stats-fact-value { font-size: 14px; }
.stats-fact-icon { width: 26px; height: 26px; border-radius: 8px; }
.stats-hero-value { font-size: 32px; }
}
.card-list .card-row:last-child {
border-bottom: none;
}
.card-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
position: relative;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
}
.card-row-left {
display: flex;
align-items: center;
gap: var(--sp-3);
flex: 1;
min-width: 0;
}
.account-avatar-img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.account-avatar-fallback {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color-tint, #007aff);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 16px;
}
.card-icon {
font-size: 20px;
flex-shrink: 0;
width: 28px;
text-align: center;
}
.card-title {
font-size: 15px;
font-weight: 500;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-subtitle {
font-size: 13px;
color: var(--color-hint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-value {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
flex-shrink: 0;
}
.card-value.good { color: var(--color-good); }
.card-value.okay { color: var(--color-okay); }
.card-value.bad { color: var(--color-bad); }
.card-action {
background: var(--color-accent);
color: var(--color-accent-text);
border: none;
border-radius: var(--r-sm);
padding: var(--sp-2) var(--sp-4);
font-size: 14px;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
transition: transform var(--dur-1) var(--ease-out-fast), opacity var(--dur-1) var(--ease-soft);
}
.card-action:hover { opacity: 0.92; }
.card-action:active {
transform: scale(0.95);
}
/* Ripple effect */
.ripple {
position: relative;
overflow: hidden;
}
.ripple::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: var(--color-hint);
transform: translate(-50%, -50%);
opacity: 0;
}
.ripple:active::after {
width: 200%;
height: 200%;
opacity: 0.15;
transition: width var(--dur-3) var(--ease-out), height var(--dur-3) var(--ease-out), opacity var(--dur-3) var(--ease-out);
}
/* === Chips === */
.chip-row {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
padding-bottom: var(--sp-1);
}
.chip {
background: var(--color-bg-secondary);
color: var(--color-text);
border: none;
border-radius: var(--r-pill);
padding: var(--sp-2) var(--sp-4);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background var(--dur-1) var(--ease-out), color var(--dur-1) var(--ease-out), transform var(--dur-0) var(--ease-out), box-shadow var(--dur-1) var(--ease-out);
-webkit-user-select: none;
user-select: none;
white-space: nowrap;
}
.chip:active {
transform: scale(0.95);
}
.chip.active {
background: var(--color-accent);
color: var(--color-accent-text);
box-shadow: var(--shadow-1);
}
.country-chip-row {
margin-bottom: var(--sp-3);
}
.country-chip-row .chip {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.country-flag {
display: inline-block;
flex-shrink: 0;
border-radius: 1.5px;
box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.15);
vertical-align: middle;
}
/* === Map === */
#map-container {
position: relative;
height: 55vh;
min-height: 250px;
}
#map-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: calc(env(safe-area-inset-top, 0px) + 56px);
background: #000;
z-index: 2;
pointer-events: none;
}
#map {
width: 100%;
height: 100%;
z-index: 1;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
.map-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--color-bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
z-index: 10;
color: var(--color-hint);
font-size: 14px;
transition: opacity var(--dur-3) var(--ease-out);
}
.map-overlay.hidden {
opacity: 0;
pointer-events: none;
}
/* Retry affordance shown in the overlay when station loading fails */
.map-retry-btn {
padding: 8px 18px;
border: none;
border-radius: var(--r-pill);
background: var(--color-accent);
color: var(--color-accent-text);
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.map-retry-btn:active { transform: scale(0.96); }
/* === Map Search Bar === */
#map-search-wrapper {
position: absolute;
top: calc(env(safe-area-inset-top, 0px) + 12px);
left: 12px;
right: 12px;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: stretch;
pointer-events: none;
}
#map-search-wrapper > * {
pointer-events: auto;
}
/* Top row holds the search box (grows) and the "Hier suchen" pill (right,
only visible after the map has moved). When the pill is hidden the box
takes the full width on its own. */
#map-search-top-row {
display: flex;
align-items: center;
gap: var(--sp-2);
}
/* Scan-picker mode hides the search box — keep the confirm/cancel pills
centred in the row instead of stuck to the left. */
#map-search-top-row:has(#map-search-box.hidden) {
justify-content: center;
}
#map-search-box {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
background: var(--color-bg);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
padding: 0 12px;
height: 44px;
gap: 8px;
}
.map-search-icon {
color: var(--color-hint);
flex-shrink: 0;
}
#map-search-input {
flex: 1;
border: none;
background: transparent;
color: var(--color-text);
font-size: 15px;
outline: none;
min-width: 0;
}
#map-search-input::placeholder {
color: var(--color-hint);
}
.map-search-clear {
border: none;
background: none;
color: var(--color-hint);
font-size: 20px;
cursor: pointer;
line-height: 1;
flex-shrink: 0;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background var(--dur-1) var(--ease-out);
}
.map-search-clear:hover,
.map-search-clear:focus-visible {
background: var(--color-inset);
}
.map-search-clear.hidden {
display: none;
}
#map-search-results {
margin-top: 4px;
background: var(--color-bg);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
overflow: hidden;
max-height: 240px;
overflow-y: auto;
}
#map-search-results.hidden {
display: none;
}
.map-search-result-item {
padding: 12px 14px;
font-size: 14px;
color: var(--color-text);
cursor: pointer;
border-bottom: 1px solid var(--color-separator);
display: flex;
align-items: center;
gap: 10px;
}
.map-search-result-item:last-child {
border-bottom: none;
}
/* Keyboard highlight while arrowing through search results */
.map-search-result-item.kb-active {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
}
.map-search-result-item:active {
background: var(--color-inset);
}
.map-search-station-item {
gap: 12px;
}
.map-search-station-rank {
width: 26px;
height: 26px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.map-search-station-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.map-search-station-name {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.map-search-station-addr {
font-size: 12px;
color: var(--color-hint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.map-search-station-price {
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
/* ─── Route planner chip + panel ────────────────────────────── */
#map-route-chip-row {
display: flex;
justify-content: flex-start;
margin-top: 6px;
pointer-events: none;
}
.map-route-chip {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--color-bg);
border: none;
border-radius: 999px;
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
color: var(--color-text);
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
transition: transform var(--dur-0) var(--ease-out), background var(--dur-1) var(--ease-out);
}
.map-route-chip:hover { background: var(--color-inset); }
.map-route-chip:active { transform: scale(0.96); }
.map-route-chip[aria-expanded="true"] {
background: var(--color-accent);
color: #fff;
}
.map-route-chip[aria-expanded="true"] svg { color: #fff; }
.map-route-panel {
margin-top: 6px;
background: var(--color-bg);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.map-route-panel.hidden { display: none; }
.map-route-row {
display: flex;
align-items: center;
gap: 10px;
}
.map-route-dot {
flex-shrink: 0;
width: 10px;
height: 10px;
border-radius: 50%;
}
.map-route-dot-start { background: var(--color-accent); }
.map-route-dot-dest {
background: transparent;
border: 2px solid var(--color-text);
box-sizing: border-box;
}
.map-route-input-wrapper {
flex: 1;
position: relative;
min-width: 0;
}
.map-route-input {
width: 100%;
border: 1px solid var(--color-separator);
background: var(--color-bg);
color: var(--color-text);
border-radius: 8px;
padding: 8px 10px;
font-size: 14px;
outline: none;
}
.map-route-input:focus {
border-color: var(--color-accent);
}
.map-route-suggest {
position: absolute;
top: calc(100% + 2px);
left: 0;
right: 0;
z-index: 1001;
background: var(--color-bg);
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
overflow: hidden;
max-height: 200px;
overflow-y: auto;
}
.map-route-suggest.hidden { display: none; }
.map-route-suggest-item {
padding: 10px 12px;
font-size: 13px;
color: var(--color-text);
cursor: pointer;
border-bottom: 1px solid var(--color-separator);
display: flex;
align-items: center;
gap: 8px;
}
.map-route-suggest-item:last-child { border-bottom: none; }
.map-route-suggest-item:active { background: var(--color-inset); }
.map-route-suggest-item.is-current-location {
color: var(--color-accent);
font-weight: 500;
}
.map-route-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 2px;
}
.map-route-close {
background: none;
border: none;
color: var(--color-hint);
font-size: 13px;
cursor: pointer;
padding: 6px 10px;
border-radius: 6px;
}
.map-route-close:hover { background: var(--color-inset); }
.map-route-go {
background: var(--color-accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 7px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.map-route-go[disabled] {
opacity: 0.4;
cursor: not-allowed;
}
.map-route-summary {
font-size: 12px;
color: var(--color-hint);
padding: 4px 2px 0;
}
.map-route-summary.hidden { display: none; }
.route-exit-btn {
position: absolute;
top: calc(12px + 44px + 6px);
right: 12px;
z-index: 1001;
background: var(--color-bg);
border: none;
border-radius: 999px;
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
color: var(--color-text);
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
display: inline-flex;
align-items: center;
gap: 6px;
}
.route-exit-btn.hidden { display: none; }
/* ── Route scan dots ─────────────────────────────────────────────── */
/* Yellow markers that appear along the route where live scans will run. */
.route-scan-dot {
pointer-events: none;
}
.route-scan-dot-inner {
width: 18px;
height: 18px;
border-radius: 50%;
background: #f5c518;
border: 2px solid #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
transition: background var(--dur-3) var(--ease-soft), transform var(--dur-3) var(--ease-out), opacity var(--dur-3) var(--ease-soft);
}
.route-scan-dot.pending .route-scan-dot-inner {
animation: route-scan-pulse 1.6s ease-in-out infinite;
}
.route-scan-dot.scanning .route-scan-dot-inner {
animation: route-scan-pulse 0.7s ease-in-out infinite;
background: #ffd84d;
}
.route-scan-dot.done .route-scan-dot-inner {
background: #4caf50;
animation: none;
}
.route-scan-dot.empty .route-scan-dot-inner {
background: #9e9e9e;
animation: none;
opacity: 0.7;
}
.route-scan-dot.error .route-scan-dot-inner {
background: #f44336;
animation: none;
}
@keyframes route-scan-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.35); opacity: 0.75; }
}
#map-search-box.hidden { display: none; }
.map-search-no-results {
padding: 14px;
font-size: 13px;
color: var(--color-hint);
text-align: center;
}
/* === Map Location Banner === */
#map-location-banner {
position: absolute;
top: 64px;
right: 12px;
z-index: 1000;
background: rgba(255, 204, 0, 0.75);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
color: #5a4500;
border-radius: 10px;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
max-width: 220px;
}
#map-location-banner.hidden {
display: none;
}
#map-location-banner-close {
border: none;
background: none;
color: #5a4500;
font-size: 18px;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
opacity: 0.7;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: opacity var(--dur-1) var(--ease-out), background var(--dur-1) var(--ease-out);
}
#map-location-banner-close:hover,
#map-location-banner-close:focus-visible {
opacity: 1;
background: rgba(0, 0, 0, 0.08);
}
#map-location-banner-close:active {
opacity: 1;
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid var(--color-bg-secondary);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* The shared entrance for anything that renders into a list or grid. The
slight scale-up is what sells it: pure translation reads as a slide,
translation plus scale reads as the element arriving from behind the
surface — which is how the rest of the app's depth model behaves. */
@keyframes slide-in-up {
from { opacity: 0; transform: translateY(14px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.station-item.anim-in,
.ranking-item.anim-in,
.stat-card.anim-in,
.metric-card.anim-in,
.best-time-card.anim-in,
.stats-hero-card.anim-in,
.stats-fact-card.anim-in,
.stats-tile.anim-in,
.history-hero-card.anim-in,
.history-extreme-card.anim-in {
animation: slide-in-up var(--dur-5) var(--ease-out) both;
}
/* Hour bars grow via their inner fill (height transition driven by JS),
no scaleY animation on the column container — that would squash the
layout since the cell now anchors content to the bottom. */
/* === Confetti burst — decorative click effect === */
.confetti-burst {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 99999;
overflow: visible;
}
.confetti-piece {
position: absolute;
will-change: transform, opacity;
pointer-events: none;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.18));
}
.confetti-trigger {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.confetti-trigger.stats-fact-icon,
.confetti-trigger.best-time-icon-wrap,
.confetti-trigger.about-heart,
.confetti-trigger.about-app-icon {
transition: transform var(--dur-1) var(--ease-spring);
}
.confetti-trigger.stats-fact-icon:hover,
.confetti-trigger.best-time-icon-wrap:hover,
.confetti-trigger.about-heart:hover,
.confetti-trigger.about-app-icon:hover {
transform: scale(1.08);
}
.about-heart {
display: inline-block;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.about-app-icon {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
/* === Floating value chip — fades in above tiny tap targets to surface
their label (hour heatmap cells, weekday tiles, …) === */
.floating-value {
position: fixed;
pointer-events: none;
z-index: 99998;
font-size: 12px;
font-weight: 700;
padding: 5px 10px;
border-radius: 999px;
background: var(--color-bg-secondary);
border: 1px solid currentColor;
font-variant-numeric: tabular-nums;
opacity: 0;
transform: translate(-50%, calc(-100% - 14px)) translateY(8px) scale(0.9);
transition: transform var(--dur-3) var(--ease-spring-lg), opacity var(--dur-2) var(--ease-soft);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.18);
white-space: nowrap;
}
.floating-value.show {
opacity: 1;
transform: translate(-50%, calc(-100% - 14px)) translateY(0) scale(1);
}
@media (prefers-reduced-motion: reduce) {
.floating-value {
transition: opacity var(--dur-1) linear;
transform: translate(-50%, calc(-100% - 14px));
}
.floating-value.show {
transform: translate(-50%, calc(-100% - 14px));
}
}
@keyframes confetti-pop {
0% { transform: scale(1); }
45% { transform: scale(1.22); }
100% { transform: scale(1); }
}
.confetti-pop {
animation: confetti-pop 0.42s var(--ease-spring-lg);
}
@media (prefers-reduced-motion: reduce) {
.confetti-burst { display: none; }
.confetti-pop { animation: none; }
}
/* === Skeleton Loading === */
/* The sweep pauses briefly at each end instead of ping-ponging continuously.
A constant-speed shimmer reads as "busy"; a breathing one reads as
"waiting", which is what a placeholder actually means. */
@keyframes shimmer {
0% { background-position: -150% 0; }
60% { background-position: 150% 0; }
100% { background-position: 150% 0; }
}
/* Placeholders fade up rather than appearing fully formed, so a fast
response never flashes a hard-edged skeleton on screen. */
@keyframes skeleton-appear {
from { opacity: 0; }
to { opacity: 1; }
}
.skeleton-item {
display: flex;
align-items: center;
padding: 14px 16px;
gap: 12px;
border-bottom: 0.5px solid var(--color-separator);
animation: skeleton-appear var(--dur-4) var(--ease-soft) both;
}
.skeleton-item:nth-child(2) { animation-delay: 40ms; }
.skeleton-item:nth-child(3) { animation-delay: 80ms; }
.skeleton-item:nth-child(4) { animation-delay: 120ms; }
.skeleton-item:nth-child(n+5) { animation-delay: 160ms; }
.skeleton-item:last-child {
border-bottom: none;
}
.skeleton-bone {
background: linear-gradient(90deg, var(--color-inset) 25%, var(--color-separator) 50%, var(--color-inset) 75%);
background-size: 200% 100%;
animation: shimmer 1.9s var(--ease-in-out) infinite;
border-radius: 6px;
}
.skeleton-rank {
width: 28px;
height: 28px;
border-radius: 8px;
flex-shrink: 0;
}
.skeleton-text-long {
height: 14px;
width: 70%;
margin-bottom: 6px;
}
.skeleton-text-short {
height: 12px;
width: 45%;
}
.skeleton-price {
width: 52px;
height: 20px;
margin-left: auto;
flex-shrink: 0;
}
/* Fills the history price-chart box while the first fetch is in flight.
Sits over the (still-blank) canvas and is removed once the chart draws. */
.chart-skeleton {
position: absolute;
inset: var(--sp-4) var(--sp-2) var(--sp-2);
border-radius: 10px;
}
/* === Station Sort === */
.station-sort-bar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 12px 16px 4px;
gap: 8px;
}
.station-sort-actions {
display: inline-flex;
align-items: center;
gap: 6px;
justify-self: start;
}
.station-sort-label {
font-size: 13px;
font-weight: 600;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.3px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
justify-self: center;
text-align: center;
}
.station-sort-label.is-route {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.15;
white-space: normal;
overflow: visible;
text-align: center;
}
.station-sort-label.is-route .station-count-sub {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.2px;
opacity: 0.85;
margin-top: 2px;
}
.station-sort-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: none;
border-radius: 8px;
background: var(--color-bg-secondary);
color: var(--color-text);
font-size: 13px;
font-weight: 600;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
white-space: nowrap;
transition: opacity var(--dur-1) var(--ease-out), transform var(--dur-1) var(--ease-out-fast);
flex-shrink: 0;
justify-self: end;
}
.station-sort-btn:active {
transform: scale(0.95);
}
.station-sort-btn svg {
flex-shrink: 0;
color: var(--color-accent);
}
/* === Station List (below map) === */
.station-list {
padding: var(--sp-2) 0;
margin: 0 var(--sp-4);
background: var(--color-bg-secondary);
border-radius: var(--r-md);
overflow: hidden;
}
.station-item {
display: flex;
align-items: center;
padding: var(--sp-3) var(--sp-4);
gap: var(--sp-3);
-webkit-tap-highlight-color: transparent;
transition: transform var(--dur-1) var(--ease-out-fast);
position: relative;
border-bottom: 0.5px solid var(--color-separator);
}
.station-item:last-child {
border-bottom: none;
}
.station-item:active {
transform: scale(0.97);
}
.station-rank {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.station-info {
flex: 1;
min-width: 0;
}
.station-name {
font-size: 15px;
font-weight: 500;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.station-addr {
font-size: 13px;
color: var(--color-hint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.station-price {
font-size: 20px;
font-weight: 700;
flex-shrink: 0;
}
.station-price sup {
font-size: 13px;
vertical-align: super;
}
.fav-btn {
background: none;
border: none;
cursor: pointer;
padding: 6px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.fav-btn {
position: relative;
overflow: visible;
}
.fav-btn svg {
transition: transform var(--dur-3) var(--ease-out-fast);
position: relative;
z-index: 1;
}
.fav-btn:active svg {
transform: scale(0.8);
}
.fav-btn svg path {
fill: none;
stroke: var(--color-hint);
stroke-width: 1.5;
transition: fill var(--dur-3) var(--ease-out), stroke var(--dur-3) var(--ease-out);
}
.fav-btn.active svg path {
fill: var(--color-favorite);
stroke: var(--color-favorite);
}
/* Sparkle particles container */
.fav-sparkles {
position: absolute;
inset: -8px;
pointer-events: none;
z-index: 0;
}
.fav-sparkle {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
top: 50%;
left: 50%;
opacity: 0;
}
.fav-btn.anim-pop svg {
animation: fav-sparkle-star 0.5s var(--ease-spring-lg);
}
.fav-btn.anim-pop .fav-sparkle:nth-child(1) { animation: fav-spark-out 0.55s 0.05s ease-out forwards; --spark-x: -14px; --spark-y: -12px; background: var(--color-favorite); }
.fav-btn.anim-pop .fav-sparkle:nth-child(2) { animation: fav-spark-out 0.5s 0.08s ease-out forwards; --spark-x: 12px; --spark-y: -14px; background: #ffcc4d; }
.fav-btn.anim-pop .fav-sparkle:nth-child(3) { animation: fav-spark-out 0.6s 0.03s ease-out forwards; --spark-x: 15px; --spark-y: 4px; background: var(--color-favorite); }
.fav-btn.anim-pop .fav-sparkle:nth-child(4) { animation: fav-spark-out 0.5s 0.1s ease-out forwards; --spark-x: -12px; --spark-y: 10px; background: #ffcc4d; }
.fav-btn.anim-pop .fav-sparkle:nth-child(5) { animation: fav-spark-out 0.55s 0.06s ease-out forwards; --spark-x: 4px; --spark-y: 15px; background: #ffd983; }
.fav-btn.anim-pop .fav-sparkle:nth-child(6) { animation: fav-spark-out 0.5s 0.12s ease-out forwards; --spark-x: -16px; --spark-y: -2px; background: #ffd983; }
.fav-btn.anim-pop .fav-sparkle-ring {
animation: fav-ring-burst 0.5s 0.02s ease-out forwards;
}
.fav-sparkle-ring {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
border: 2px solid var(--color-favorite);
transform: translate(-50%, -50%);
opacity: 0;
pointer-events: none;
}
@keyframes fav-sparkle-star {
0% { transform: scale(0.3); }
30% { transform: scale(1.2); }
60% { transform: scale(0.9); }
100% { transform: scale(1); }
}
@keyframes fav-spark-out {
0% { opacity: 1; transform: translate(0, 0) scale(1); }
50% { opacity: 1; transform: translate(var(--spark-x), var(--spark-y)) scale(1.2); }
100% { opacity: 0; transform: translate(calc(var(--spark-x) * 1.3), calc(var(--spark-y) * 1.3)) scale(0); }
}
@keyframes fav-ring-burst {
0% { width: 0; height: 0; opacity: 0.8; border-width: 3px; }
60% { opacity: 0.4; border-width: 1.5px; }
100% { width: 36px; height: 36px; opacity: 0; border-width: 0.5px; }
}
.fav-btn.anim-unpop svg {
animation: fav-unpop 0.3s var(--ease-out-fast);
}
@keyframes fav-unpop {
0% { transform: scale(1); }
40% { transform: scale(0.7); }
100% { transform: scale(1); }
}
.station-dist {
font-size: 12px;
color: var(--color-hint);
text-align: right;
}
/* Toolbar toggles (favourites pin + group-by-price) sit in the sort bar
and match the sort button's chip-style theming so the row reads as a
cohesive control strip. Inactive sits on the secondary surface, active
fills with the accent and flips the icon to accent-text. */
.station-fav-btn {
background: var(--color-bg-secondary);
border: none;
padding: 7px;
cursor: pointer;
color: var(--color-hint);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: color var(--dur-1) var(--ease-out), background var(--dur-1) var(--ease-out), transform var(--dur-1) var(--ease-out-fast);
-webkit-tap-highlight-color: transparent;
}
.station-fav-btn:hover {
background: var(--color-separator);
color: var(--color-text);
}
.station-fav-btn:active { transform: scale(0.94); }
.station-fav-btn.active {
background: var(--color-accent);
color: var(--color-accent-text);
}
.station-fav-btn.active:hover {
background: var(--color-accent);
color: var(--color-accent-text);
filter: brightness(1.05);
}
/* Cloud-sync indicator sits right beside the toolbar toggles. Only visible
during the syncing → synced burst, then fades back out — idle has no
point taking permanent visual space. */
body.logged-in .station-fav-sync {
display: inline-flex;
opacity: 0;
transition: opacity var(--dur-3) var(--ease-soft);
pointer-events: none;
}
body.logged-in .station-fav-sync.syncing,
body.logged-in .station-fav-sync.synced {
opacity: 1;
pointer-events: auto;
}
/* === Chart === */
.chart-container {
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-lg);
padding: var(--sp-4) var(--sp-2) var(--sp-2);
height: 280px;
position: relative;
}
.chart-container canvas {
width: 100% !important;
height: 100% !important;
}
/* === Bottom Sheet === */
.bottom-sheet {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
/* Must outlast the content's slide, or closing clips the animation the
moment visibility flips. (Opening is unaffected: visibility resolves to
`visible` as soon as the transition starts.) */
transition: visibility var(--dur-6) var(--ease-out);
}
.bottom-sheet.hidden {
visibility: hidden;
pointer-events: none;
}
/* Plain dim, no backdrop blur: blurring the map behind the sheet obscured
the thing the user was just looking at, and on a full-screen backdrop it's
the most expensive effect on the page. */
.bottom-sheet-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.4);
opacity: 0;
transition: opacity var(--dur-4) var(--ease-soft);
}
.bottom-sheet:not(.hidden) .bottom-sheet-backdrop {
opacity: 1;
}
.bottom-sheet-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: var(--color-bg);
border-radius: 16px 16px 0 0;
max-height: 85vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-y;
transform: translateY(100%);
/* --ease-emphasis is the iOS sheet curve: it leaves the bottom edge almost
instantly, then glides the last few pixels. --dur-6 gives that glide room
to be felt — at --dur-4 the tail is over before it registers. */
transition: transform var(--dur-6) var(--ease-emphasis),
max-height var(--dur-4) var(--ease-emphasis),
border-radius var(--dur-4) var(--ease-out);
padding-bottom: var(--safe-bottom);
}
.bottom-sheet:not(.hidden) .bottom-sheet-content {
transform: translateY(0);
}
.bottom-sheet-content.expanded {
max-height: 95vh;
border-radius: 8px 8px 0 0;
}
.bottom-sheet-content.dragging {
transition: none !important;
overflow-y: hidden;
}
.bottom-sheet-content.snapping {
transition: transform var(--dur-4) var(--ease-emphasis) !important;
}
.bottom-sheet-handle-area {
padding: 10px 0 6px;
cursor: grab;
touch-action: none;
}
.bottom-sheet-handle-area:active {
cursor: grabbing;
}
.bottom-sheet-handle {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--color-hint);
margin: 0 auto;
opacity: 0.5;
}
/* --- Station detail: block cascade ---------------------------------------
The detail surface is a stack of blocks (price, address, status, opening
hours, navigation, chart). Revealing them in sequence rather than all at
once gives the eye an order to read them in, and covers the moment the
skeleton is swapped for real data.
The header is deliberately excluded: it's already on screen from the
skeleton, so re-animating it would flicker the one thing that should stay
put. Everything fills in beneath a fixed title. */
@keyframes sheet-block-enter {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: none; }
}
#bottom-sheet-body.is-revealing > *:nth-child(1) { --i: 0; }
#bottom-sheet-body.is-revealing > *:nth-child(2) { --i: 1; }
#bottom-sheet-body.is-revealing > *:nth-child(3) { --i: 2; }
#bottom-sheet-body.is-revealing > *:nth-child(4) { --i: 3; }
#bottom-sheet-body.is-revealing > *:nth-child(5) { --i: 4; }
#bottom-sheet-body.is-revealing > *:nth-child(6) { --i: 5; }
#bottom-sheet-body.is-revealing > *:nth-child(7) { --i: 6; }
#bottom-sheet-body.is-revealing > *:nth-child(n+8) { --i: 7; }
#bottom-sheet-body.is-revealing > *:not(.sheet-station-header) {
animation-name: sheet-block-enter;
animation-duration: var(--dur-4);
animation-timing-function: var(--ease-out);
animation-fill-mode: both;
animation-delay: calc(var(--i, 0) * 34ms);
}
/* === Station detail modal — used when the user opens a station from
the Stats Tankstellen-Ranking or the Verlauf cheapest/most-expensive
cards. Same internal layout as the map bottom-sheet, but presented
as a floating card in the middle of the screen instead of a
drag-to-dismiss sheet glued to the bottom edge. */
.bottom-sheet.detail-modal {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
/* Snaps shut faster than the drag-sheet slides, so its visibility flip
shouldn't wait on the sheet's longer window. */
transition: visibility var(--dur-4) var(--ease-out);
}
/* Dims harder than the drag-sheet — it's a focus surface, not a peek at the
map underneath. Separation comes from the dim alone; no blur. */
.bottom-sheet.detail-modal .bottom-sheet-backdrop {
background: rgba(0, 0, 0, 0.55);
}
.bottom-sheet.detail-modal .bottom-sheet-content {
position: relative;
bottom: auto;
left: auto;
right: auto;
width: min(440px, 100%);
max-height: 86vh;
border-radius: 20px;
padding-bottom: 8px;
transform-origin: center center;
transform: scale(0.92);
opacity: 0;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.32), 0 8px 24px rgba(0, 0, 0, 0.18);
/* This is the CLOSING state, so it carries the exit curve: the modal holds
its size for a moment, then drops away with gathering speed. Using the
decel curve here made it drift off, which read as lag. */
transition: transform var(--dur-3) var(--ease-in),
opacity var(--dur-2) var(--ease-in);
}
.bottom-sheet.detail-modal:not(.hidden) .bottom-sheet-content {
transform: scale(1);
opacity: 1;
/* Tiny overshoot on the way in: scale up to 1.02 mid-flight, settle
back to 1. Soft "pop" without the awkward translate-up. */
animation: detail-modal-pop var(--dur-5) var(--ease-out) both;
}
@keyframes detail-modal-pop {
0% { transform: scale(0.92); opacity: 0; }
60% { transform: scale(1.02); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
.bottom-sheet.detail-modal .bottom-sheet-handle-area {
display: none;
}
.bottom-sheet.detail-modal .sheet-expand-btn {
display: none;
}
.detail-modal-close {
position: absolute;
top: 12px;
right: 12px;
z-index: 2;
width: 32px;
height: 32px;
border-radius: 50%;
background: color-mix(in srgb, var(--color-text) 8%, transparent);
border: none;
color: var(--color-text);
cursor: pointer;
display: grid;
place-items: center;
transition: background var(--dur-1) var(--ease-soft), transform var(--dur-1) var(--ease-out);
padding: 0;
}
.detail-modal-close:hover {
background: color-mix(in srgb, var(--color-text) 14%, transparent);
}
.detail-modal-close:active {
transform: scale(0.92);
}
/* Skeleton placeholders shown while we fetch the full detail data. */
.detail-skeleton {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 20px 20px;
}
.detail-skeleton-line,
.detail-skeleton-block {
border-radius: 8px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--color-text) 8%, transparent) 0%,
color-mix(in srgb, var(--color-text) 14%, transparent) 50%,
color-mix(in srgb, var(--color-text) 8%, transparent) 100%
);
background-size: 200% 100%;
animation: detail-skeleton-shimmer 1.4s ease-in-out infinite;
}
.detail-skeleton-line {
height: 14px;
width: 100%;
}
.detail-skeleton-line.short {
width: 60%;
}
.detail-skeleton-line.detail-skeleton-price {
height: 36px;
width: 50%;
margin-bottom: 6px;
}
.detail-skeleton-block {
height: 140px;
margin-top: 8px;
}
@keyframes detail-skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.bottom-sheet.detail-modal .bottom-sheet-content,
.bottom-sheet.detail-modal:not(.hidden) .bottom-sheet-content {
transition: opacity var(--dur-1) var(--ease-soft);
transform: none;
animation: none;
}
.detail-skeleton-line,
.detail-skeleton-block {
animation: none;
}
}
/* Sheet station detail */
.sheet-station-header {
padding: 12px 20px 8px;
display: flex;
align-items: flex-start;
gap: 8px;
}
.sheet-fav-btn {
margin-top: 2px;
}
.sheet-station-name {
font-size: 18px;
font-weight: 700;
color: var(--color-text);
}
.sheet-station-brand {
font-size: 14px;
color: var(--color-hint);
margin-top: 2px;
}
.sheet-station-price {
font-size: 36px;
font-weight: 800;
padding: 8px 20px;
}
.sheet-station-price sup {
font-size: 18px;
vertical-align: super;
}
.sheet-info-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
font-size: 14px;
color: var(--color-text);
border-top: 0.5px solid var(--color-separator);
}
.sheet-info-icon {
font-size: 18px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
/* Sheet opening hours */
.sheet-hours-section {
overflow: hidden;
}
.sheet-hours-toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
font-size: 14px;
color: var(--color-text);
border-top: 0.5px solid var(--color-separator);
transition: background var(--dur-1) var(--ease-out);
}
.sheet-hours-toggle:active {
background: var(--color-inset);
}
.sheet-hours-chevron {
transition: transform var(--dur-3) var(--ease-soft);
}
.sheet-hours-section.expanded .sheet-hours-chevron {
transform: rotate(180deg);
}
.sheet-hours-list {
max-height: 0;
overflow: hidden;
transition: max-height var(--dur-3) var(--ease-soft),
opacity var(--dur-3) var(--ease-soft);
opacity: 0;
padding: 0 20px;
}
.sheet-hours-section.expanded .sheet-hours-list {
max-height: 300px;
opacity: 1;
padding: 0 20px 10px;
}
.sheet-hours-row {
display: flex;
justify-content: space-between;
/* 24 px icon slot + 10 px gap = 34 px text indent, matching the toggle row. */
padding: 5px 0 5px 34px;
font-size: 13px;
}
.sheet-hours-day {
color: var(--color-hint);
font-weight: 500;
}
.sheet-hours-time {
color: var(--color-text);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* Sheet chart */
.sheet-chart-section {
padding: 0 20px 8px;
}
.sheet-chart-header-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0 8px;
}
.sheet-chart-header {
font-size: 13px;
font-weight: 500;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sheet-chart-toggle {
display: flex;
gap: 4px;
background: var(--color-inset);
border-radius: 8px;
padding: 2px;
}
.sheet-toggle-btn {
border: none;
background: transparent;
color: var(--color-hint);
font-size: 12px;
font-weight: 500;
padding: 4px 10px;
border-radius: 6px;
cursor: pointer;
transition: all var(--dur-2) var(--ease-out);
}
.sheet-toggle-btn.active {
background: var(--color-accent);
color: #fff;
}
.sheet-toggle-btn.disabled {
opacity: 0.35;
cursor: not-allowed;
}
.sheet-chart-container {
background: var(--color-inset);
border-radius: 12px;
padding: 12px 8px 8px;
height: 120px;
position: relative;
transition: height var(--dur-4) var(--ease-emphasis);
}
.bottom-sheet-content.expanded .sheet-chart-container {
height: 28vh;
}
.sheet-chart-container canvas {
width: 100% !important;
height: 100% !important;
}
.sheet-chart-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-hint);
font-size: 13px;
}
/* Friendly empty-state for German stations that aren't in the scanner — points
the user at the location-request flow. */
.sheet-chart-empty-msg {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 6px;
padding: 12px 16px;
height: 100%;
max-width: 320px;
margin: 0 auto;
}
.sheet-chart-empty-title {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
}
.sheet-chart-empty-hint {
font-size: 12px;
color: var(--color-hint);
line-height: 1.4;
}
.sheet-chart-empty-btn {
margin-top: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 999px;
border: none;
background: var(--color-accent);
color: var(--color-accent-text);
font-size: 13px;
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.sheet-chart-empty-btn:active { opacity: 0.85; transform: scale(0.97); }
/* Subtle pill for manual-scan stations — ephemeral data (15 min TTL).
Freshness colour comes from --scan-color which is set on
#bottom-sheet-body each tick (green → yellow → red). The pill itself
stays low-key: tinted background, hint text, small pulsing dot,
hairline progress strip along the inner-bottom edge. */
#bottom-sheet-body {
--scan-color: #22c55e;
}
.sheet-manual-scan-banner-wrap {
padding: 4px 20px 10px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
/* Small pill button in the sheet header that rescans the 25 km circle
* around the current station — same action as "Hier suchen" but anchored
* to the tapped pin. Sits next to the freshness banner when there is one,
* on its own otherwise. */
.sheet-refresh-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px 6px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-inset);
color: var(--color-text);
font-size: 11.5px;
font-weight: 500;
cursor: pointer;
transition: background var(--dur-1) var(--ease-soft), border-color var(--dur-1) var(--ease-soft), opacity var(--dur-1) var(--ease-soft);
}
.sheet-refresh-btn:hover:not(:disabled) {
background: var(--color-border);
}
.sheet-refresh-btn:active:not(:disabled) {
transform: scale(0.97);
}
.sheet-refresh-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.sheet-refresh-btn svg {
flex-shrink: 0;
}
.sheet-manual-scan-banner {
--scan-track: rgba(148, 163, 184, 0.18);
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 11px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--scan-color) 10%, transparent);
font-size: 11.5px;
color: var(--color-hint);
max-width: 100%;
position: relative;
overflow: hidden;
transition: background 1s linear;
}
.sheet-manual-scan-text {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sheet-manual-scan-countdown {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--color-text);
}
/* Small pulsing dot. */
.sheet-manual-scan-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--scan-color);
flex-shrink: 0;
animation: manualScanPulse 1.8s ease-in-out infinite;
transition: background 1s linear;
}
@keyframes manualScanPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.8); }
}
/* Hairline progress strip hugging the inside-bottom of the pill. */
.sheet-manual-scan-progress {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: var(--scan-track);
}
.sheet-manual-scan-progress-bar {
height: 100%;
width: 100%;
background: var(--scan-color);
transition: width 0.9s linear, background 1s linear;
}
/* Last minute: only the dot pulses faster. */
.sheet-manual-scan-banner.urgent .sheet-manual-scan-dot {
animation-duration: 0.9s;
}
.sheet-manual-scan-banner.expired {
opacity: 0.55;
}
.sheet-manual-scan-banner.expired .sheet-manual-scan-dot {
animation: none;
}
.sheet-expand-btn {
display: none;
position: absolute;
top: 12px;
right: 16px;
width: 32px;
height: 32px;
border: none;
background: var(--color-inset);
border-radius: 8px;
cursor: pointer;
align-items: center;
justify-content: center;
color: var(--color-text);
z-index: 2;
transition: background var(--dur-1) var(--ease-out);
}
.sheet-expand-btn:hover {
background: var(--color-border);
}
.sheet-expand-btn svg {
width: 18px;
height: 18px;
}
@media (hover: hover) and (pointer: fine) {
.sheet-expand-btn {
display: flex;
}
.sheet-station-header {
padding-right: 52px;
}
}
.sheet-nav-buttons {
display: flex;
gap: 10px;
padding: 16px 20px;
}
.sheet-nav-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 13px 16px;
border-radius: 14px;
border: none;
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: transform var(--dur-1) var(--ease-out), box-shadow var(--dur-1) var(--ease-out);
}
.sheet-nav-btn:active {
transform: scale(0.95);
}
.sheet-nav-btn.gmaps,
.sheet-nav-btn.amaps {
background: var(--color-inset);
color: var(--color-text);
border: 1px solid var(--color-separator);
}
.sheet-nav-icon {
border-radius: 5px;
flex-shrink: 0;
}
/* === Stats === */
.stat-row {
display: flex;
gap: var(--sp-2);
padding: 0 var(--sp-4);
margin-bottom: var(--sp-2);
}
.stat-card {
flex: 1;
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
border-radius: var(--r-md);
padding: var(--sp-3) var(--sp-3);
text-align: center;
}
.stat-card-value {
font-size: 20px;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
}
.stat-card-label {
font-size: 12px;
color: var(--color-hint);
margin-top: var(--sp-1);
}
.ranking-item {
display: flex;
align-items: center;
padding: var(--sp-3) var(--sp-4);
gap: var(--sp-3);
border-bottom: 0.5px solid var(--color-separator);
transition: transform var(--dur-1) var(--ease-out-fast), background var(--dur-1) var(--ease-soft);
}
.ranking-item:hover {
background: var(--color-separator);
}
.ranking-item:active {
transform: scale(0.98);
}
.ranking-pos {
font-size: 13px;
font-weight: 700;
color: var(--color-hint);
width: 24px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.ranking-name {
flex: 1;
font-size: 14px;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ranking-price {
font-size: 15px;
font-weight: 600;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
/* === Tab Bar === */
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: calc(var(--tab-height) + var(--safe-bottom));
padding-bottom: var(--safe-bottom);
background: color-mix(in srgb, var(--color-bg-secondary) 88%, transparent);
-webkit-backdrop-filter: blur(18px) saturate(1.6);
backdrop-filter: blur(18px) saturate(1.6);
border-top: 0.5px solid var(--color-separator);
display: flex;
align-items: center;
justify-content: space-around;
z-index: 100;
}
.tab-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
background: none;
border: none;
cursor: pointer;
padding: 6px 14px;
border-radius: 10px;
-webkit-user-select: none;
user-select: none;
color: var(--color-hint);
transition: color var(--dur-3) var(--ease-soft),
transform var(--dur-0) var(--ease-out-fast);
}
/* The selected pill is its own layer so it can spring in from the centre.
Animating `background` alone just cross-fades a rectangle — scaling a
pseudo-element gives the selection somewhere to come *from*. */
.tab-item::before {
content: '';
position: absolute;
inset: 0;
border-radius: 10px;
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
opacity: 0;
transform: scale(0.72);
pointer-events: none;
transition: opacity var(--dur-2) var(--ease-soft),
transform var(--dur-4) var(--ease-spring);
}
.tab-item.active {
color: var(--color-accent);
}
.tab-item.active::before {
opacity: 1;
transform: scale(1);
}
.tab-item:active {
transform: scale(0.93);
}
/* Softer than the old 1.18 overshoot — at 24 px the big jump read as a
glitch rather than a bounce. */
@keyframes tab-bounce {
0% { transform: scale(1); }
38% { transform: scale(1.12); }
70% { transform: scale(0.97); }
100% { transform: scale(1); }
}
.tab-item.active .tab-icon {
animation: tab-bounce var(--dur-5) var(--ease-spring);
}
.tab-icon {
width: 24px;
height: 24px;
fill: currentColor;
position: relative;
}
.tab-item span {
font-size: 10px;
font-weight: 500;
position: relative;
transition: font-weight var(--dur-2) var(--ease-soft);
}
.tab-item.active span {
font-weight: 600;
}
/* === Location chip in settings === */
/* Leaflet popup override */
.leaflet-popup-content-wrapper {
border-radius: 12px !important;
box-shadow: 0 4px 20px rgba(0,0,0,0.15) !important;
}
.leaflet-popup-content {
margin: 10px 14px !important;
font-family: inherit !important;
font-size: 14px !important;
}
/* Map bubble markers (tankste! style) */
.map-bubble {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 3px 8px 4px;
border-radius: 8px;
color: #fff;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
text-align: center;
min-width: 48px;
}
.map-bubble-brand {
font-size: 9px;
font-weight: 500;
opacity: 0.9;
letter-spacing: 0.3px;
line-height: 1.1;
}
.map-bubble-price {
font-size: 15px;
font-weight: 800;
line-height: 1.2;
letter-spacing: -0.3px;
}
.map-bubble-price sup {
font-size: 9px;
vertical-align: super;
}
.map-bubble-arrow {
position: absolute;
bottom: -6px;
left: 50%;
margin-left: -6px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid;
}
/* Cluster marker overrides */
.marker-cluster-small,
.marker-cluster-medium,
.marker-cluster-large {
background: none !important;
}
.marker-cluster-small div,
.marker-cluster-medium div,
.marker-cluster-large div {
background: none !important;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--color-hint);
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 12px;
}
.empty-state-text {
font-size: 15px;
}
/* Map floating action button — sits above the bottom sheet's resting peek
on mobile by leaving a generous bottom inset. On desktop the sheet is a
side panel, so the FAB returns to a tighter offset (overridden below). */
.map-fab {
position: absolute;
bottom: 20px;
right: 16px;
z-index: 1000;
width: 46px;
height: 46px;
border-radius: var(--r-md);
border: none;
background: var(--color-bg);
color: var(--color-accent);
box-shadow: var(--shadow-3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
transition: transform var(--dur-1) var(--ease-out-fast), opacity var(--dur-1) var(--ease-soft);
}
.map-fab:active { opacity: 0.7; transform: scale(0.92); }
/* Row that holds the "Search here" pill (and, in scan-picker mode, a round
cancel button to its left). Lives inside #map-search-top-row, inline to
the right of the search box, so it sits on the same line as the search
bar. Hidden (display:none below) until the map has moved, at which point
the box shrinks to make room. */
.map-search-here-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex: 0 0 auto;
margin-top: 0;
pointer-events: none;
}
.map-search-here-row > * {
pointer-events: auto;
}
.map-search-here-row:empty,
.map-search-here-row:has(> .map-search-here.hidden:only-child) {
display: none;
}
.map-search-here {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border-radius: 999px;
border: none;
background: var(--color-accent);
color: var(--color-accent-text);
font-size: 14px;
font-weight: 600;
box-shadow: 0 4px 14px rgba(0,0,0,0.2);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
animation: searchHerePopIn 0.26s var(--ease-spring-lg);
white-space: nowrap;
}
.map-search-here:active { opacity: 0.85; transform: scale(0.96); }
.map-search-here.hidden { display: none; }
.map-search-here[disabled] {
background: var(--color-hint);
color: var(--color-bg);
opacity: 0.75;
cursor: not-allowed;
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
}
.map-search-here[disabled]:active { transform: none; opacity: 0.75; }
@keyframes searchHerePopIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
/* "Confirm" state of the search-here pill (during scan-picker mode) */
.map-search-here.is-confirm {
background: var(--color-accent);
color: var(--color-accent-text);
box-shadow: 0 6px 18px rgba(0, 122, 255, 0.35);
}
/* Round cancel pill placed left of the confirm button while picking.
JS inserts it as a sibling of the search-here pill inside
.map-search-here-row, so flex centering handles the side-by-side layout. */
.map-scan-cancel {
width: 38px;
height: 38px;
border-radius: 50%;
border: none;
background: var(--color-bg);
color: var(--color-text);
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
animation: searchHerePopIn 0.22s ease-out;
flex-shrink: 0;
}
.map-scan-cancel:active { opacity: 0.85; transform: scale(0.94); }
/* Draggable pin shown in the centre of the scan-radius picker circle */
.scan-pin {
width: 28px;
height: 28px;
border-radius: 50%;
background: color-mix(in srgb, var(--scan-accent, #007aff) 18%, transparent);
border: 2px solid var(--scan-accent, #007aff);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
cursor: grab;
}
.scan-pin:active { cursor: grabbing; }
.scan-pin-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--scan-accent, #007aff);
box-shadow: 0 0 0 3px var(--color-bg);
animation: scanPinPulse 1.6s ease-in-out infinite;
}
@keyframes scanPinPulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(0.78); opacity: 0.85; }
}
/* Yellow pin shown for queued scan requests waiting on the cooldown.
Click to remove the pin (and its queue entry). */
.scan-queue-pin {
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(255, 193, 7, 0.22);
border: 2px solid #ffc107;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
cursor: pointer;
}
.scan-queue-pin-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ffc107;
box-shadow: 0 0 0 2px var(--color-bg);
animation: scanQueuePinPulse 1.4s ease-in-out infinite;
}
@keyframes scanQueuePinPulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(0.65); opacity: 0.65; }
}
/* The scan UI follows what the user is LOOKING at (map centre), not where
their pin sits — so searching Salzburg then panning to Munich should
still show the "Hier suchen" pill. Keyed off data-view-country. */
[data-view-country="at"] .map-search-here,
[data-view-country="at"] .map-scan-cancel,
[data-view-country="at"] .map-search-here-row,
[data-view-country="at"] .scan-queue-pin {
display: none !important;
}
/* Lift dark-mode tiles so place names stay readable without washing
out the dark background — brightness pulls labels up, lower contrast
prevents them from collapsing back into the background. */
.map-tiles-dark {
filter: brightness(1.75) contrast(0.95);
}
/* Match the Leaflet container background to the active tile palette so
loading tiles don't flash white before they paint. */
[data-theme="dark"] .leaflet-container {
background: #313131;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) .leaflet-container {
background: #313131;
}
}
/* Disable touch/select quirks on Leaflet chrome */
.leaflet-container,
.leaflet-pane,
.leaflet-control-container,
.leaflet-control-attribution,
.leaflet-control-zoom {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Hidden attribution for cleaner UX */
.leaflet-control-attribution {
display: none !important;
}
/* Settings Row (label + dropdown) */
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4);
gap: var(--sp-3);
}
.settings-row-left {
display: flex;
align-items: center;
gap: var(--sp-3);
min-width: 0;
}
.settings-row-left span {
text-transform: none;
letter-spacing: 0;
}
.settings-select {
-webkit-appearance: none;
appearance: none;
background: var(--color-inset);
color: var(--color-text);
border: 1px solid var(--color-separator);
border-radius: 8px;
padding: 8px 36px 8px 12px;
font-size: 14px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
outline: none;
transition: border-color var(--dur-1) var(--ease-out);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2398989e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 16px;
min-width: 0;
max-width: 160px;
}
.settings-select:focus {
border-color: var(--color-accent);
}
.settings-select option {
background: var(--color-bg);
color: var(--color-text);
}
/* Alert Channel Picker (reuses theme-seg pattern) */
.alert-ch-seg {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-hint);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background var(--dur-1) var(--ease-out), color var(--dur-1) var(--ease-out);
-webkit-user-select: none;
user-select: none;
}
.alert-ch-seg:hover:not(.active) {
color: var(--color-text-2);
}
.alert-ch-seg.active {
background: var(--color-accent);
color: var(--color-accent-text);
box-shadow: 0 2px 8px color-mix(in srgb, var(--color-accent) 30%, transparent);
}
.alert-ch-seg:active { transform: scale(0.97); }
.alert-ch-seg svg { flex-shrink: 0; }
/* Toggle Switch */
.toggle-switch { position: relative; display: inline-block; width: 51px; height: 31px; flex-shrink: 0; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-track {
position: absolute; cursor: pointer; inset: 0;
background: color-mix(in srgb, var(--color-text) 16%, transparent);
border-radius: 31px;
transition: background var(--dur-3) var(--ease-soft);
}
/* The knob travels on a spring while the track's colour fades on the plain
curve — the offset between the two is what gives a toggle its weight. */
.toggle-track:before {
content: ""; position: absolute;
width: 27px; height: 27px; left: 2px; bottom: 2px;
background: white; border-radius: 50%;
transform-origin: center;
transition: transform var(--dur-4) var(--ease-spring),
width var(--dur-2) var(--ease-out);
box-shadow: 0 1px 3px rgba(0,0,0,0.25), 0 2px 6px rgba(0,0,0,0.1);
}
.toggle-switch input:checked + .toggle-track { background: var(--color-good); }
.toggle-switch input:checked + .toggle-track:before { transform: translateX(20px); }
/* Held down, the knob stretches toward its travel direction and snaps back
on release — the same trick the iOS switch uses. */
.toggle-track:active:before { width: 32px; }
.toggle-switch input:checked + .toggle-track:active:before { transform: translateX(15px); }
/* === Price Alert card — redesigned for the settings tab === */
.alert-card {
padding: 0;
overflow: hidden;
transition: border-color var(--dur-3) var(--ease-soft), background var(--dur-3) var(--ease-soft);
}
.alert-card.is-armed {
border-color: color-mix(in srgb, var(--color-accent) 30%, var(--color-separator));
}
.alert-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-4);
}
.alert-toggle-left {
display: flex;
align-items: center;
gap: var(--sp-3);
min-width: 0;
}
.alert-toggle-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.alert-toggle-title {
font-size: 15px;
font-weight: 600;
color: var(--color-text);
}
.alert-toggle-sub {
font-size: 11px;
color: var(--color-hint);
line-height: 1.4;
}
.alert-config-body {
border-top: 1px solid var(--color-separator);
padding: var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-4);
}
.alert-config-group { display: flex; flex-direction: column; gap: 6px; }
.alert-config-label {
font-size: 11px;
font-weight: 600;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Threshold hero — integrated into the card body, no competing inner panel */
.alert-threshold {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 14px;
padding: 4px 0 2px;
}
.alert-threshold-head {
font-size: 11px;
font-weight: 600;
color: var(--color-hint);
text-transform: uppercase;
letter-spacing: 0.6px;
text-align: center;
}
.alert-threshold-row {
display: flex;
align-items: center;
justify-content: center;
gap: 22px;
}
.alert-step-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid transparent;
background: color-mix(in srgb, var(--color-text) 6%, transparent);
color: var(--color-text);
display: grid;
place-items: center;
cursor: pointer;
font-family: inherit;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: background var(--dur-1) var(--ease-out), transform var(--dur-0) var(--ease-out), color var(--dur-1) var(--ease-out);
}
.alert-step-btn:hover {
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
color: var(--color-accent);
}
.alert-step-btn:active { transform: scale(0.92); }
.alert-step-btn svg { width: 18px; height: 18px; }
.alert-threshold-display {
font-size: 42px;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
min-width: 132px;
width: 132px;
text-align: center;
line-height: 1;
font-family: inherit;
background: transparent;
border: 0;
outline: 0;
padding: 6px 4px;
border-radius: 10px;
box-sizing: border-box;
caret-color: var(--color-accent);
-webkit-appearance: none;
appearance: none;
transition: background var(--dur-1) var(--ease-out);
}
.alert-threshold-display:focus {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
}
.alert-threshold-display::-webkit-outer-spin-button,
.alert-threshold-display::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.alert-threshold-bar {
position: relative;
margin-top: 2px;
cursor: pointer;
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
.alert-threshold-bar:focus { outline: none; }
.alert-threshold-bar:focus-visible .alert-threshold-bar-marker {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 22%, transparent),
0 0 0 7px color-mix(in srgb, var(--color-accent) 36%, transparent),
0 2px 8px rgba(0, 0, 0, 0.22);
}
.alert-threshold-bar[hidden] { display: none; }
.alert-threshold-bar-track {
height: 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-text) 10%, transparent);
}
.alert-threshold-bar-current,
.alert-threshold-bar-marker {
position: absolute;
top: 2.5px; /* center of the 5px-tall track, regardless of label height below */
transform: translate(-50%, -50%);
}
.alert-threshold-bar-current {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-hint);
border: 2px solid var(--color-bg-secondary);
}
.alert-threshold-bar-marker {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-accent);
border: 2px solid var(--color-bg-secondary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 22%, transparent),
0 2px 8px rgba(0, 0, 0, 0.22);
transition: left var(--dur-2) var(--ease-out);
cursor: grab;
}
.alert-threshold-bar.is-dragging { cursor: grabbing; }
.alert-threshold-bar.is-dragging .alert-threshold-bar-marker {
transition: none;
cursor: grabbing;
}
.alert-threshold-bar-labels {
display: flex;
justify-content: space-between;
margin-top: 12px;
font-size: 11px;
color: var(--color-text-2);
font-variant-numeric: tabular-nums;
letter-spacing: 0.2px;
opacity: 0.7;
}
.alert-ref-price {
text-align: center;
font-size: 12px;
color: var(--color-hint);
margin-top: 4px;
letter-spacing: 0.1px;
}
/* Channel section */
.alert-channel-section {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: var(--sp-2);
}
.alert-channel-picker {
display: flex;
border-radius: 10px;
background: color-mix(in srgb, var(--color-text) 5%, transparent);
padding: 3px;
gap: 3px;
}
.alert-input {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--color-separator);
background: var(--color-inset);
color: var(--color-text);
font-size: 14px;
font-family: inherit;
box-sizing: border-box;
transition: border-color var(--dur-1) var(--ease-out), box-shadow var(--dur-1) var(--ease-out);
}
.alert-input:focus-visible {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
outline: none;
}
.alert-input-hint {
font-size: 11px;
color: var(--color-hint);
margin-top: 6px;
line-height: 1.45;
}
/* Status / background-check panel */
.alert-status-panel {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
border-radius: 10px;
background: color-mix(in srgb, var(--color-text) 5%, transparent);
transition: background var(--dur-2) var(--ease-out);
}
.alert-status-panel.is-armed {
background: color-mix(in srgb, var(--color-good) 8%, transparent);
}
.alert-status-panel.is-below {
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
}
.alert-status-row {
display: flex;
align-items: center;
gap: 14px;
font-size: 13px;
color: var(--color-text);
font-weight: 500;
}
.alert-status-row-secondary {
font-size: 11.5px;
color: var(--color-hint);
font-weight: 500;
letter-spacing: 0.1px;
}
.alert-status-row-secondary[hidden] { display: none; }
.alert-status-dot {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--color-hint);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-hint) 18%, transparent);
}
.alert-status-row.is-armed .alert-status-dot {
background: var(--color-good);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-good) 22%, transparent);
animation: alert-status-pulse 2.6s ease-in-out infinite;
}
.alert-status-row.is-below .alert-status-dot {
background: var(--color-accent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 26%, transparent);
animation: none;
}
.alert-status-icon {
flex-shrink: 0;
opacity: 0.7;
}
@keyframes alert-status-pulse {
0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-good) 18%, transparent); }
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-good) 28%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
.alert-status-dot { animation: none; }
}
/* Action buttons — clear primary/tertiary hierarchy */
.alert-actions {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 14px;
margin-top: 8px;
}
.alert-save-btn {
width: 100%;
padding: 13px;
border-radius: 12px;
border: none;
background: var(--color-accent);
color: var(--color-accent-text);
font-size: 15px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: box-shadow var(--dur-1) var(--ease-out), transform var(--dur-0) var(--ease-out), filter var(--dur-1) var(--ease-soft);
}
.alert-save-btn:hover {
box-shadow: 0 6px 18px color-mix(in srgb, var(--color-accent) 35%, transparent);
filter: brightness(1.05);
}
.alert-save-btn:active { transform: scale(0.98); }
.alert-test-btn {
align-self: center;
padding: 8px 12px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--color-hint);
font-size: 12px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: color var(--dur-1) var(--ease-out), background var(--dur-1) var(--ease-out);
}
.alert-test-btn:hover {
color: var(--color-text);
background: color-mix(in srgb, var(--color-text) 5%, transparent);
}
.alert-active-info {
margin-top: 10px;
padding: 10px 12px;
border-radius: 8px;
background: var(--color-inset);
font-size: 13px;
color: var(--color-hint);
text-align: center;
}
.settings-row-label {
font-size: 15px;
font-weight: 500;
}
/* === Location picker select === */
.location-picker {
width: 100%;
padding: var(--sp-2) var(--sp-3);
border-radius: var(--r-sm);
border: 1px solid var(--color-separator);
background: var(--color-bg-secondary);
color: var(--color-text);
font-size: 14px;
font-family: inherit;
box-sizing: border-box;
transition: border-color var(--dur-1) var(--ease-out), box-shadow var(--dur-1) var(--ease-out);
}
.location-picker-row {
display: flex;
flex-direction: column;
gap: 4px;
}
/* === Custom desktop dropdown — desktop replaces the native <select>
with a styled trigger + animated panel. On mobile the native
select stays (the OS picker is the right affordance there). === */
.custom-select { position: relative; width: 100%; }
.custom-select-trigger,
.custom-select-panel { display: none; }
@media (min-width: 900px) {
.custom-select > select { display: none; }
.custom-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 9px 14px;
border-radius: 10px;
border: 1px solid var(--color-separator);
background: var(--color-bg-secondary);
color: var(--color-text);
font-size: 14px;
font-family: inherit;
cursor: pointer;
text-align: left;
transition: border-color var(--dur-2) var(--ease-out),
box-shadow var(--dur-2) var(--ease-out);
}
.custom-select-trigger:hover {
border-color: var(--color-hint);
}
.custom-select.open .custom-select-trigger {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.15);
}
.custom-select-value {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.custom-select-caret {
flex-shrink: 0;
color: var(--color-hint);
transition: transform var(--dur-2) var(--ease-out);
}
.custom-select.open .custom-select-caret {
transform: rotate(180deg);
}
.custom-select-panel {
display: block;
position: absolute;
left: 0;
right: 0;
top: calc(100% + 6px);
z-index: 100;
border-radius: 12px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-separator);
box-shadow: var(--dt-shadow-md);
overflow: hidden;
max-height: 280px;
overflow-y: auto;
opacity: 0;
transform: translateY(-6px) scale(0.98);
transform-origin: top center;
pointer-events: none;
transition: opacity var(--dur-1) var(--ease-out), transform var(--dur-2) var(--ease-out);
}
.custom-select.open .custom-select-panel {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.custom-select-option {
padding: 9px 14px;
cursor: pointer;
font-size: 14px;
color: var(--color-text);
line-height: 1.3;
transition: background var(--dur-1) var(--ease-out);
user-select: none;
}
.custom-select-option:hover {
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
}
.custom-select-option.selected {
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
color: var(--color-accent);
font-weight: 600;
}
/* In the settings row, the picker sits to the right of an icon + label
and should match the existing settings-select width. */
.settings-row .custom-select {
width: auto;
min-width: 132px;
}
}
.location-auto-hint {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--color-hint);
padding-left: 4px;
letter-spacing: 0.1px;
}
.location-auto-hint[hidden] { display: none; }
.location-auto-hint svg { opacity: 0.75; flex-shrink: 0; }
.location-picker:hover {
border-color: var(--color-hint);
}
.location-picker:focus-visible {
border-color: var(--color-accent);
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent);
}
.request-location-btn {
width: 100%;
padding: 14px 16px;
background: transparent;
border: none;
cursor: pointer;
color: var(--color-accent);
font-size: 15px;
font-weight: 500;
border-top: 1px solid var(--color-separator);
font-family: inherit;
transition: background var(--dur-1) var(--ease-out);
}
.request-location-btn:hover {
background: var(--color-separator);
}
/* Rises and settles with a touch of overshoot on the way in, then leaves
quietly — an exiting toast shouldn't draw the eye back to itself, so the
dismiss is a plain fade-down on the softer curve. */
.toast {
position: fixed;
left: 50%;
bottom: calc(var(--tab-height) + var(--safe-bottom) + 18px);
transform: translateX(-50%) translateY(20px) scale(0.94);
background: rgba(17,17,17,0.92);
color: #fff;
padding: 12px 16px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
box-shadow: 0 10px 24px rgba(0,0,0,0.24);
opacity: 0;
pointer-events: none;
transform-origin: bottom center;
transition: opacity var(--dur-2) var(--ease-soft),
transform var(--dur-3) var(--ease-soft);
z-index: 1200;
max-width: min(92vw, 420px);
text-align: center;
}
.toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
transition: opacity var(--dur-2) var(--ease-soft),
transform var(--dur-5) var(--ease-spring);
}
.is-hidden {
display: none !important;
}
/* === PWA Install Popup === */
.pwa-popup {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.pwa-popup.hidden { display: none; }
.pwa-popup-backdrop {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.6);
}
.pwa-popup-sheet {
position: relative;
width: 100%;
max-width: 420px;
background: var(--color-bg);
border-radius: 20px 20px 0 0;
padding: 28px 24px calc(24px + var(--safe-bottom));
box-shadow: 0 -4px 32px rgba(0,0,0,0.18);
animation: pwa-slide-up 0.35s var(--ease-emphasis);
text-align: center;
}
@keyframes pwa-slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.pwa-popup-icon {
margin-bottom: 16px;
}
.pwa-popup-icon img {
width: 72px;
height: 72px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.12);
}
html[data-theme="dark"] .pwa-popup-icon img,
:root:not([data-theme="light"]) .pwa-popup-icon img {
background: #1c1c1e;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) .pwa-popup-icon img {
background: transparent;
}
}
.pwa-popup-title {
font-size: 22px;
font-weight: 700;
color: var(--color-text);
margin-bottom: 6px;
letter-spacing: -0.3px;
}
.pwa-popup-desc {
font-size: 14px;
color: var(--color-hint);
line-height: 1.45;
margin-bottom: 20px;
max-width: 320px;
margin-left: auto;
margin-right: auto;
}
.pwa-popup-tabs {
display: flex;
background: var(--color-inset);
border-radius: 10px;
padding: 3px;
margin-bottom: 16px;
gap: 2px;
}
.pwa-popup-tab {
flex: 1;
padding: 7px 8px;
border: none;
background: transparent;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-hint);
cursor: pointer;
transition: all var(--dur-2) var(--ease-out);
}
.pwa-popup-tab.active {
background: var(--color-bg);
color: var(--color-text);
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
.pwa-popup-panel {
display: none;
}
.pwa-popup-panel.active {
display: block;
animation: pwa-fade-in 0.2s ease;
}
@keyframes pwa-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.pwa-popup-steps {
list-style: none;
text-align: left;
margin: 0;
padding: 0;
}
.pwa-popup-steps li {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
border-radius: 12px;
background: var(--color-inset);
margin-bottom: 8px;
font-size: 14px;
color: var(--color-text);
line-height: 1.4;
}
.pwa-popup-steps li:last-child {
margin-bottom: 0;
}
.pwa-step-icon {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--color-accent);
}
.pwa-step-icon svg {
fill: #fff;
}
.pwa-popup-btn {
display: block;
width: 100%;
margin-top: 20px;
padding: 15px;
border: none;
border-radius: 14px;
background: var(--color-accent);
color: #fff;
font-size: 17px;
font-weight: 600;
cursor: pointer;
transition: opacity var(--dur-1) var(--ease-out);
letter-spacing: -0.2px;
}
.pwa-popup-btn:active {
opacity: 0.8;
}
/* === Pull-to-Refresh === */
#ptr-container {
position: fixed;
top: env(safe-area-inset-top, 0px);
left: 0;
right: 0;
display: flex;
justify-content: center;
pointer-events: none;
z-index: 90;
padding-top: 12px;
}
.native-spinner {
width: 20px;
height: 20px;
opacity: 0;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cg fill='%23000'%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.12'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.25' transform='rotate(45 10 10)'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.37' transform='rotate(90 10 10)'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.50' transform='rotate(135 10 10)'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.62' transform='rotate(180 10 10)'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.75' transform='rotate(225 10 10)'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.87' transform='rotate(270 10 10)'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='1' transform='rotate(315 10 10)'/%3E%3C/g%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cg fill='%23000'%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.12'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.25' transform='rotate(45 10 10)'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.37' transform='rotate(90 10 10)'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.50' transform='rotate(135 10 10)'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.62' transform='rotate(180 10 10)'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.75' transform='rotate(225 10 10)'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='.87' transform='rotate(270 10 10)'/%3E%3Crect x='9' y='1' width='2' height='5' rx='1' opacity='1' transform='rotate(315 10 10)'/%3E%3C/g%3E%3C/svg%3E");
-webkit-mask-size: contain;
mask-size: contain;
background: var(--color-text);
animation: native-spin 0.8s steps(8) infinite;
transition: opacity var(--dur-3) var(--ease-soft);
}
@keyframes native-spin {
to { transform: rotate(360deg); }
}
/* Shift app content during pull */
#app.ptr-pulling {
transition: none;
}
#app.ptr-snapping {
transition: transform var(--dur-5) var(--ease-spring);
}
#app.ptr-refreshing {
transition: transform var(--dur-4) var(--ease-soft);
}
/* === About Section === */
.about-card {
padding: 0 !important;
overflow: hidden;
}
.about-app-row {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
}
.about-app-icon {
border-radius: 14px;
flex-shrink: 0;
}
.about-app-name {
font-size: 17px;
font-weight: 600;
color: var(--color-text);
line-height: 1.2;
}
.about-app-version {
font-size: 13px;
color: var(--color-hint);
margin-top: 2px;
}
.about-links-card {
margin-top: 0;
}
.about-row {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 16px;
width: 100%;
text-decoration: none;
color: var(--color-text);
background: none;
border: none;
font-family: inherit;
cursor: pointer;
text-align: left;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
transition: background var(--dur-1) var(--ease-out);
}
.about-row:active {
background: var(--color-inset);
}
.about-row-icon {
color: var(--color-hint);
flex-shrink: 0;
}
.about-row-label {
font-size: 15px;
font-weight: 500;
flex: 1;
}
.about-row-chevron {
opacity: 0.35;
flex-shrink: 0;
transition: transform var(--dur-3) var(--ease-out);
}
.about-row-divider {
height: 1px;
background: var(--color-border);
margin-left: 48px;
}
.about-contributors-chevron.open {
transform: rotate(90deg);
}
.about-contributors-list {
max-height: 0;
overflow: hidden;
transition: max-height var(--dur-3) var(--ease-out);
}
.about-contributors-list.open {
max-height: 300px;
}
.about-contributor {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px 10px 48px;
text-decoration: none;
color: var(--color-text);
-webkit-tap-highlight-color: transparent;
transition: background var(--dur-1) var(--ease-out);
}
.about-contributor:active {
background: var(--color-inset);
}
.about-contributor-avatar {
border-radius: 50%;
flex-shrink: 0;
}
.about-contributor-info {
flex: 1;
min-width: 0;
}
.about-contributor-name {
font-size: 14px;
font-weight: 500;
color: var(--color-text);
line-height: 1.3;
}
.about-contributor-role {
font-size: 12px;
color: var(--color-hint);
line-height: 1.3;
}
.about-footer {
text-align: center;
padding: 8px 0 24px;
font-size: 11px;
color: var(--color-hint);
opacity: 0.5;
}
/* === Interaction feedback ===
A sweep that gives every remaining interactive surface the same press
response. Consistency is the whole point: when one button dips on touch
and the one beside it doesn't, the app feels unfinished no matter how
polished either control is on its own.
Rows and cards use the subtle scale — they're wide, and a hard squash on
a full-width element reads as a rendering glitch rather than a press. */
.seg-option,
.settings-select,
.location-picker,
.alert-test-btn,
.request-location-btn,
.shortcuts-reset-btn,
.shortcut-key-btn,
.about-app-icon,
.about-heart,
.toggle-switch,
.card-row,
.stat-card,
.best-time-card,
.stats-tile,
.stats-fact-card,
.hl-row {
transition: transform var(--dur-0) var(--ease-out-fast),
background var(--dur-2) var(--ease-soft),
border-color var(--dur-2) var(--ease-soft),
box-shadow var(--dur-2) var(--ease-soft),
color var(--dur-2) var(--ease-soft),
opacity var(--dur-2) var(--ease-soft);
}
.seg-option:active,
.alert-test-btn:active,
.request-location-btn:active,
.shortcuts-reset-btn:active,
.shortcut-key-btn:active,
.about-app-icon:active,
.about-heart:active {
transform: scale(var(--press-scale));
}
.card-row:active,
.stats-tile:not(.is-empty):active,
.hl-row:active {
transform: scale(var(--press-scale-subtle));
}
/* Baseline for any control that has no transition of its own. Zero-specificity
so every rule above — and every component rule earlier in this file — still
wins; this only fills the gaps. */
:where(button, a, [role="button"], input, select, textarea) {
transition-property: transform, background, border-color, box-shadow, color, opacity;
transition-duration: var(--dur-2);
transition-timing-function: var(--ease-soft);
}
/* --- Pointer-device polish ---
Lifts and shadow growth only where there's a real cursor, and only on
things that actually do something when clicked — a card that rises under
the pointer but isn't clickable is a promise the UI doesn't keep. On touch
these would either never fire or, worse, stick after a tap. */
@media (hover: hover) and (pointer: fine) {
.card-row:hover,
.history-extreme-card:not(.is-empty):hover,
.stats-tile:not(.is-empty):hover {
transform: translateY(-2px);
box-shadow: var(--shadow-2);
}
.chip:hover:not(.active) {
background: color-mix(in srgb, var(--color-text) 6%, var(--color-bg-secondary));
}
.chip.active:hover {
box-shadow: 0 4px 14px color-mix(in srgb, var(--color-accent) 32%, transparent);
}
.tab-item:hover:not(.active) {
color: var(--color-text-2);
}
.tab-item:hover::before {
opacity: 1;
transform: scale(1);
background: color-mix(in srgb, var(--color-text) 6%, transparent);
}
.tab-item.active:hover::before {
background: color-mix(in srgb, var(--color-accent) 16%, transparent);
}
.about-app-icon:hover {
transform: translateY(-2px) scale(1.03);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
}
.about-heart:hover {
transform: scale(1.18);
}
}
/* === Desktop Station Panel (transparent wrapper on mobile) === */
.desktop-station-panel {
display: contents;
}
/* === Desktop Layout === */
@media (min-width: 900px) {
/* --- Desktop-specific custom properties --- */
:root {
--sidebar-w: 88px;
--panel-w: 360px;
--sheet-w: 440px;
--content-max: 920px;
--content-max-narrow: 660px;
/* Motion comes from the global ease/duration tokens; the desktop layer
only adds its own elevation scale. */
--dt-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--dt-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.04);
--dt-shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06);
--dt-shadow-sheet: 0 16px 64px rgba(0, 0, 0, 0.14), 0 6px 20px rgba(0, 0, 0, 0.08);
--dt-blur: 20px;
}
html[data-theme="dark"] {
--dt-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.2);
--dt-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.25);
--dt-shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.35);
--dt-shadow-sheet: 0 16px 64px rgba(0, 0, 0, 0.45);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--dt-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.2);
--dt-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.25);
--dt-shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.35);
--dt-shadow-sheet: 0 16px 64px rgba(0, 0, 0, 0.45);
}
}
/* --- Sidebar Navigation --- */
.tab-bar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: auto;
width: var(--sidebar-w);
height: 100%;
flex-direction: column;
justify-content: flex-start;
padding: 16px 8px;
border-top: none;
border-right: 1px solid var(--color-separator);
gap: 2px;
z-index: 100;
background: var(--color-bg-secondary);
}
/* Desktop turns the tab bar into a left sidebar. Only the geometry changes
here — selection is still drawn by the shared ::before pill, so this must
not set `background` of its own or the two tints would stack. */
.tab-item {
padding: 10px 0;
width: 100%;
gap: 3px;
border-radius: 10px;
}
.tab-item span {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.01em;
}
.tab-item:active {
transform: scale(0.97);
}
/* --- App container shifts right --- */
#app {
left: var(--sidebar-w);
bottom: 0;
}
/* --- Tab view transitions (desktop-refined) --- */
.tab-view {
transition: opacity var(--dur-5) var(--ease-out),
transform var(--dur-5) var(--ease-out);
transform: scale(0.97) translateY(8px);
}
.tab-view.active {
transform: scale(1) translateY(0);
}
/* --- Non-map views: centered content with padding --- */
#view-history,
#view-stats,
#view-settings {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 var(--sp-6) var(--sp-7);
}
/* On desktop the view already provides side padding, so sections shed
their inner 16 px gutter and let the centred max-width column do the
work. Cards/headers align flush to that column. */
#view-history > .section,
#view-stats > .section,
#view-settings > .section {
padding-left: 0;
padding-right: 0;
}
/* Stat-row's own 16 px gutter is now redundant for the same reason. */
#view-history .stat-row,
#view-stats .stat-row {
padding-left: 0;
padding-right: 0;
}
/* Page header sits flush with the content column and gets a more
generous vertical rhythm on desktop. */
.page-header {
padding: var(--sp-7) 0 var(--sp-5);
}
.page-title {
font-size: 32px;
}
.page-description {
font-size: 15px;
margin-top: var(--sp-2);
}
#view-history.active,
#view-stats.active,
#view-settings.active {
display: flex;
}
#view-history > *,
#view-stats > *,
#view-settings > * {
width: 100%;
max-width: var(--content-max);
}
/* Bigger chart on desktop — the extra horizontal space deserves a
taller graph instead of a 280px squish. Step it up at the tablet
breakpoint so 13" laptops don't get a 400px chart that dominates
the viewport. */
#view-history .chart-container,
#view-stats .chart-container {
height: 340px;
}
@media (min-width: 1200px) {
#view-history .chart-container,
#view-stats .chart-container {
height: 400px;
}
}
/* Pair the two summary cards side-by-side when there's room. The
section-header above spans both columns. */
#history-summary {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: var(--sp-3);
row-gap: var(--sp-2);
align-items: start;
}
/* Any non-extreme-card child (section-header, empty-state message) spans
the full width of the summary grid. */
#history-summary > *:not(.extreme-card) {
grid-column: 1 / -1;
}
#history-summary > .extreme-card {
padding: var(--sp-5) var(--sp-5);
}
#history-summary > .extreme-card .extreme-value {
font-size: 32px;
}
/* Desktop card refinements — border + a hint of shadow. The border does
the visual heavy lifting (shadcn-style), shadow just adds depth on hover. */
.card {
border-radius: var(--r-lg);
padding: var(--sp-4) var(--sp-5);
transition: box-shadow var(--dur-3) var(--ease-out),
border-color var(--dur-3) var(--ease-out);
}
.card:hover {
box-shadow: var(--shadow-1);
}
.card-list {
border-radius: var(--r-lg);
transition: box-shadow var(--dur-3) var(--ease-out);
}
.card-list:hover {
box-shadow: var(--shadow-1);
}
.metric-card,
.best-time-card,
.extreme-card,
.stats-hero,
.stats-hero-card,
.stats-fact-card {
border-radius: var(--r-lg);
transition: box-shadow var(--dur-3) var(--ease-out), transform var(--dur-3) var(--ease-out);
}
.metric-card { padding: var(--sp-4) var(--sp-5); }
.best-time-card { padding: var(--sp-5) var(--sp-5); }
.extreme-card { padding: var(--sp-5) var(--sp-5); }
.stats-hero { padding: var(--sp-6) var(--sp-6); }
.stats-hero-card { padding: var(--sp-6); }
.stats-fact-card { padding: var(--sp-4); gap: var(--sp-3); }
.stats-fact-card .stats-fact-icon { width: 38px; height: 38px; border-radius: 11px; }
.stats-fact-card .stats-fact-icon svg { width: 18px; height: 18px; }
.stats-fact-value { font-size: 18px; }
.stats-hero-value { font-size: 44px; }
.stats-facts-row { gap: var(--sp-3); }
#stats-content > .metric-row {
margin-left: 0;
margin-right: 0;
}
#stats-content > .section {
padding-left: 0;
padding-right: 0;
}
#stats-content > .stats-overview-row {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
#stats-content .metric-card,
#stats-content .best-time-card {
min-height: 88px;
padding: var(--sp-4) var(--sp-5);
}
.metric-card:hover,
.best-time-card:hover,
.extreme-card:hover,
.stats-hero:hover,
.stats-fact-card:hover {
box-shadow: var(--shadow-1);
}
.stats-hero-card:hover {
box-shadow: var(--dt-shadow-md);
}
/* Desktop: roomier tiles & bar chart */
.stats-tile-grid { gap: 8px; }
.stats-tile {
min-height: 80px;
padding: 14px 4px 12px;
border-radius: 14px;
transition: transform var(--dur-1) var(--ease-out), box-shadow var(--dur-2) var(--ease-out), border-color var(--dur-2) var(--ease-out), background var(--dur-2) var(--ease-out);
}
.stats-tile:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-1);
}
.stats-tile-name { font-size: 11px; }
.stats-tile-value { font-size: 16px; }
.stats-tile-bar { height: 4px; }
.stats-hour-chart-wrap { height: 210px; }
/* History tab: roomier hero card, bigger headline value, and lift
the extreme cards on hover so they read as obviously clickable. */
.history-hero-card {
padding: var(--sp-6);
border-radius: var(--r-lg);
transition: box-shadow var(--dur-3) var(--ease-out);
}
.history-hero-card:hover {
box-shadow: var(--dt-shadow-md);
}
.history-hero-value { font-size: 44px; }
.history-hero-pills { gap: 10px; margin-top: 18px; }
.history-extreme-row { gap: var(--sp-3); }
.history-extreme-card {
border-radius: var(--r-lg);
padding: 14px 16px 14px 20px;
transition: transform var(--dur-1) var(--ease-out),
box-shadow var(--dur-2) var(--ease-out),
border-color var(--dur-2) var(--ease-out);
}
.history-extreme-card:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 16%, transparent);
}
.history-extreme-icon {
width: 38px;
height: 38px;
border-radius: 11px;
}
.history-extreme-value { font-size: 22px; }
.metric-value { font-size: 28px; }
.stats-hero-value { font-size: 48px; }
/* Desktop chip refinements */
.chip {
transition: background var(--dur-2) var(--ease-out),
color var(--dur-2) var(--ease-out),
transform var(--dur-1) var(--ease-out),
box-shadow var(--dur-2) var(--ease-out);
}
.chip:hover:not(.active) {
background: var(--color-separator);
}
.chip.active {
box-shadow: none;
}
/* Desktop chart container — matches card radius so the chart reads as a
hero card in the same family. Border carries the surface; shadow only
appears on hover for affordance. */
.chart-container {
border-radius: var(--r-lg);
padding: var(--sp-5) var(--sp-3) var(--sp-3);
transition: box-shadow var(--dur-3) var(--ease-out);
}
.chart-container:hover {
box-shadow: var(--shadow-1);
}
/* Stats card hover */
.stat-card {
border-radius: var(--r-lg);
padding: var(--sp-4) var(--sp-3);
transition: box-shadow var(--dur-3) var(--ease-out);
}
.stat-card:hover {
box-shadow: var(--shadow-1);
}
/* Stat row gets a touch more breathing room between cards on desktop. */
.stat-row {
gap: var(--sp-3);
}
/* Section header reads a little louder on desktop so the hierarchy
between header → cards is unmistakable. */
.section-header {
font-size: 12px;
padding: var(--sp-6) var(--sp-1) var(--sp-2);
letter-spacing: 0.7px;
}
.section-header:first-child,
.section > .section-header:first-child {
padding-top: var(--sp-3);
}
/* Section header sync-badge sits slightly higher to align with the
bolder header text. */
.section-header .sync-badge {
transform: translateY(-1px);
}
/* Ranking item hover */
.ranking-item {
transition: background var(--dur-2) var(--ease-out),
transform var(--dur-1) var(--ease-out);
border-radius: 0;
}
.ranking-item:hover {
background: var(--color-inset);
}
.ranking-item:active {
transform: scale(0.99);
}
/* Settings row hover */
.settings-row {
border-radius: 0;
transition: background var(--dur-2) var(--ease-out);
}
.settings-row:hover {
background: color-mix(in srgb, var(--color-text) 5%, transparent);
}
/* --- Map view: full width with side panel (panel on right) --- */
#view-map {
display: flex;
flex-direction: row;
overflow: hidden;
}
#view-map.active {
display: flex;
}
/* Remove the dark bar above the map on desktop */
#map-container::before {
display: none;
}
#map-container {
flex: 1;
height: 100%;
min-height: unset;
}
/* Map search bar refinement */
#map-search-wrapper {
top: 16px;
left: 16px;
max-width: 400px;
}
/* Desktop: the "Hier suchen" pill sits inline to the right of the search
box, same as mobile. Widen the wrapper only while the pill is visible so
the search input keeps its width. */
.map-search-here-row {
position: static;
transform: none;
width: auto;
margin-top: 0;
}
#map-search-wrapper:has(.map-search-here:not(.hidden)) {
max-width: 520px;
}
#map-search-box {
border-radius: 12px;
box-shadow: var(--dt-shadow-md);
background: var(--color-bg);
transition: box-shadow var(--dur-3) var(--ease-out);
height: 46px;
}
#map-search-box:focus-within {
box-shadow: var(--dt-shadow-lg), 0 0 0 3px rgba(0, 122, 255, 0.2);
}
#map-search-results {
border-radius: 12px;
box-shadow: var(--dt-shadow-lg);
background: var(--color-bg);
margin-top: 6px;
}
.map-search-result-item {
transition: background var(--dur-1) var(--ease-soft);
}
.map-search-result-item:hover {
background: var(--color-inset);
}
/* Map FAB refinement */
.map-fab {
border-radius: 12px;
box-shadow: var(--dt-shadow-md);
background: var(--color-bg);
transition: box-shadow var(--dur-3) var(--ease-out),
transform var(--dur-2) var(--ease-out);
width: 46px;
height: 46px;
}
.map-fab:hover {
box-shadow: var(--dt-shadow-lg);
transform: scale(1.05);
}
.map-fab:active {
transform: scale(0.94);
opacity: 1;
}
/* --- Station panel (right sidebar on map) --- */
.desktop-station-panel {
display: flex;
flex-direction: column;
width: var(--panel-w);
flex-shrink: 0;
height: 100%;
overflow-y: auto;
border-left: 1px solid var(--color-separator);
background: var(--color-bg);
}
.desktop-station-panel .station-sort-bar {
padding: 16px 16px 8px;
position: sticky;
top: 0;
background: var(--color-bg);
z-index: 2;
backdrop-filter: blur(var(--dt-blur));
-webkit-backdrop-filter: blur(var(--dt-blur));
}
.desktop-station-panel .station-list {
margin: 0;
border-radius: 0;
flex: 1;
background: transparent;
overflow: visible;
}
/* Station list item hover on desktop */
.station-item {
transition: background var(--dur-2) var(--ease-out),
transform var(--dur-1) var(--ease-out);
cursor: pointer;
}
.station-item:hover {
background: var(--color-inset);
}
.station-item:active {
transform: scale(0.99);
}
/* Station item stagger animation */
.station-item.anim-in {
animation: dt-station-slide 0.35s var(--ease-out) both;
}
@keyframes dt-station-slide {
from {
opacity: 0;
transform: translateX(16px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* --- Bottom sheet becomes right side panel --- */
.bottom-sheet {
left: var(--sidebar-w);
}
.bottom-sheet-backdrop {
background: rgba(0, 0, 0, 0.25);
transition: opacity var(--dur-4) var(--ease-soft);
}
.bottom-sheet-content {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: auto;
width: var(--sheet-w);
max-width: var(--sheet-w);
max-height: 100%;
border-radius: 0;
border-left: 1px solid var(--color-separator);
box-shadow: var(--dt-shadow-sheet);
transform: translateX(100%);
/* Same emphasis curve as the mobile sheet so the panel reads as the same
surface arriving from a different edge. */
transition: transform var(--dur-5) var(--ease-emphasis);
padding-bottom: 0;
}
.bottom-sheet:not(.hidden) .bottom-sheet-content {
transform: translateX(0);
}
.bottom-sheet-content.expanded {
max-height: 100%;
border-radius: 0;
}
.bottom-sheet-handle-area {
display: none;
}
/* Sheet station header spacing for desktop (space for close button only) */
.sheet-station-header {
padding: 20px 20px 8px;
padding-right: 48px;
}
.sheet-station-name {
font-size: 20px;
}
.sheet-station-price {
font-size: 40px;
padding: 12px 20px;
}
/* Sheet chart container */
.sheet-chart-container {
height: 160px;
border-radius: 14px;
box-shadow: var(--dt-shadow-sm);
}
.bottom-sheet-content.expanded .sheet-chart-container {
height: 240px;
}
/* Sheet nav buttons */
.sheet-nav-btn {
border-radius: 12px;
transition: background var(--dur-1) var(--ease-soft);
}
.sheet-nav-btn:hover {
background: var(--color-separator);
}
.sheet-nav-btn:active {
opacity: 0.7;
}
/* --- Desktop close button (Apple xmark.circle.fill style) --- */
.sheet-desktop-close {
position: absolute;
top: 14px;
right: 14px;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: var(--color-inset);
color: var(--color-hint);
font-size: 15px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: background var(--dur-2) var(--ease-soft),
color var(--dur-2) var(--ease-soft),
opacity var(--dur-2) var(--ease-soft);
opacity: 0.7;
line-height: 1;
}
.sheet-desktop-close:hover {
opacity: 1;
background: var(--color-separator);
color: var(--color-text);
}
.sheet-desktop-close:active {
opacity: 0.5;
}
/* The desktop close button overlays the top-right corner — give the fav
star enough right-side breathing room so they don't visually collide. */
.sheet-fav-btn {
margin-right: 36px;
}
/* --- Hide expand button on desktop (close button handles dismiss) --- */
.sheet-expand-btn {
display: none !important;
}
/* --- Hide pull-to-refresh on desktop --- */
#ptr-container {
display: none;
}
/* --- PWA popup centers on desktop --- */
.pwa-popup {
align-items: center;
justify-content: center;
}
.pwa-popup-sheet {
border-radius: 20px;
box-shadow: var(--dt-shadow-lg);
max-width: 440px;
}
/* --- Toast positioning (account for sidebar) --- */
.toast {
/* No bottom tab bar on desktop — mobile's 74px offset would float
the toast in the middle of nowhere. */
bottom: 24px;
border-radius: 14px;
box-shadow: var(--dt-shadow-lg);
}
.toast.visible {
left: calc(var(--sidebar-w) + 50%);
transform: translateX(-50%) translateX(calc(var(--sidebar-w) / -2));
}
/* --- About card refinements --- */
.about-card {
border-radius: 14px;
box-shadow: var(--dt-shadow-sm);
transition: box-shadow var(--dur-3) var(--ease-out);
}
.about-card:hover {
box-shadow: var(--dt-shadow-md);
}
.about-row {
transition: background var(--dur-2) var(--ease-out);
}
.about-row:hover {
background: rgba(127, 127, 127, 0.06);
}
/* Auth hero card */
.auth-hero-card {
border-radius: 20px;
box-shadow: var(--dt-shadow-md);
transition: box-shadow var(--dur-3) var(--ease-out);
}
.auth-hero-card:hover {
box-shadow: var(--dt-shadow-lg);
}
/* Section header subtle refinement */
.section-header {
padding-top: 20px;
font-size: 12px;
letter-spacing: 0.8px;
}
/* Location picker dropdown */
.location-picker {
border-radius: 10px;
transition: border-color var(--dur-2) var(--ease-out),
box-shadow var(--dur-2) var(--ease-out);
}
.location-picker:focus {
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.15);
}
/* Scrollbar styling for panels */
.desktop-station-panel::-webkit-scrollbar,
.bottom-sheet-content::-webkit-scrollbar,
#view-history::-webkit-scrollbar,
#view-stats::-webkit-scrollbar,
#view-settings::-webkit-scrollbar {
width: 6px;
}
.desktop-station-panel::-webkit-scrollbar-track,
.bottom-sheet-content::-webkit-scrollbar-track,
#view-history::-webkit-scrollbar-track,
#view-stats::-webkit-scrollbar-track,
#view-settings::-webkit-scrollbar-track {
background: transparent;
}
.desktop-station-panel::-webkit-scrollbar-thumb,
.bottom-sheet-content::-webkit-scrollbar-thumb,
#view-history::-webkit-scrollbar-thumb,
#view-stats::-webkit-scrollbar-thumb,
#view-settings::-webkit-scrollbar-thumb {
background: var(--color-separator);
border-radius: 3px;
}
.desktop-station-panel::-webkit-scrollbar-thumb:hover,
.bottom-sheet-content::-webkit-scrollbar-thumb:hover,
#view-history::-webkit-scrollbar-thumb:hover,
#view-stats::-webkit-scrollbar-thumb:hover,
#view-settings::-webkit-scrollbar-thumb:hover {
background: var(--color-hint);
}
/* Firefox scrollbar */
.desktop-station-panel,
.bottom-sheet-content,
#view-history,
#view-stats,
#view-settings {
scrollbar-width: thin;
scrollbar-color: var(--color-separator) transparent;
}
/* Toggle switch hover */
.toggle-switch {
transition: opacity var(--dur-2) var(--ease-soft);
}
.toggle-switch:hover {
opacity: 0.85;
}
/* --- Button hover refinements (Apple-style: subtle opacity, no lifts) --- */
.card-action {
transition: opacity var(--dur-1) var(--ease-soft);
}
.card-action:hover {
opacity: 0.85;
}
.card-action:active {
opacity: 0.65;
}
.primary-btn {
transition: opacity var(--dur-1) var(--ease-soft);
}
.primary-btn:hover {
opacity: 0.85;
}
.secondary-btn {
transition: background var(--dur-2) var(--ease-soft);
}
.secondary-btn:hover {
background: var(--color-separator);
}
/* Sort button hover */
.station-sort-btn {
transition: background var(--dur-2) var(--ease-soft);
}
.station-sort-btn:hover {
background: var(--color-separator);
}
.station-sort-btn:active {
opacity: 0.7;
}
/* Map location banner refinement */
#map-location-banner {
border-radius: 12px;
box-shadow: var(--dt-shadow-md);
backdrop-filter: blur(var(--dt-blur));
-webkit-backdrop-filter: blur(var(--dt-blur));
}
}
/* ── History locations (Standorte mit Verlaufsdaten) ───────────────── */
.hl-summary-top {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 14px 16px;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background var(--dur-1) var(--ease-out);
}
.hl-summary-top:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
.hl-total-num {
font-size: 26px;
font-weight: 700;
line-height: 1;
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.hl-total-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
color: var(--color-hint);
}
.hl-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.hl-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 9px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-text) 8%, transparent);
font-size: 12px;
color: var(--color-text);
}
.hl-chip b { font-weight: 700; }
.hl-chevron { transition: transform var(--dur-2) var(--ease-out); }
.hl-group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border-top: 1px solid var(--color-separator);
background: color-mix(in srgb, var(--color-text) 4%, transparent);
}
.hl-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-top: 1px solid var(--color-separator);
transition: background var(--dur-1) var(--ease-out);
}
.hl-row:hover { background: color-mix(in srgb, var(--color-text) 3%, transparent); }
.hl-row-name {
font-size: 14px;
font-weight: 500;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hl-row-sub { font-size: 11px; color: var(--color-hint); margin-top: 2px; }
.hl-pin-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-accent);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background var(--dur-1) var(--ease-out), transform var(--dur-1) var(--ease-out);
}
.hl-pin-btn:hover { background: color-mix(in srgb, var(--color-accent) 22%, transparent); transform: scale(1.06); }
.hl-pin-btn:active { transform: scale(0.94); }
/* "Standort anfragen" reads as the primary action of the locations section. */
.request-location-btn { color: var(--color-accent); font-weight: 600; }
.request-location-btn svg { color: var(--color-accent); }
/* ── Settings: grouped card (Allgemein) ────────────────────────────── */
.card.settings-group {
padding: var(--sp-1) 0 var(--sp-2);
}
.settings-group .settings-row {
padding: var(--sp-3) var(--sp-4) var(--sp-3);
}
.settings-group .settings-row-stacked {
padding-bottom: var(--sp-1);
}
.settings-group .seg-control {
margin: var(--sp-2) var(--sp-4) var(--sp-2);
}
.settings-divider {
height: 1px;
background: var(--color-separator);
margin: var(--sp-2) 0 var(--sp-1) var(--sp-4);
}
/* Settings list cards render their own rows — let rows own the padding */
#history-locations-card,
#user-requests-card {
padding: 0;
overflow: hidden;
}
/* Compact "n Standorte" summary row */
.hl-summary-top { gap: 12px; padding: 12px 16px; }
.hl-total-num {
font-size: 17px;
font-weight: 600;
letter-spacing: -0.01em;
}
.hl-total-label {
font-size: 15px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
color: var(--color-text);
}
/* ── Light-mode card lift ──────────────────────────────────────────────
The page is near-white, so cards separate through their hairline
border plus this whisper of a shadow (invisible on dark's black page). */
.card,
.card-list,
.metric-card,
.extreme-card,
.stat-card,
.stats-hero,
.stats-hero-card,
.stats-fact-card,
.stats-tile,
.best-time-card,
.history-hero-card,
.history-extreme-card,
.account-hero,
.about-hero,
.chart-container,
.stats-hour-chart-wrap {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03), 0 2px 6px rgba(0, 0, 0, 0.03);
}
/* Chips sit directly on the page — on near-white they need their own
edge to read as controls. */
.chip {
border: 1px solid var(--color-separator);
}
.chip.active {
border-color: transparent;
}
/* Small "Min" prefix on the tooltip's big price — the plotted line is
the daily cheapest, while Ø/Max ride along in the meta row. */
.history-tooltip-price-mark {
font-size: 11px;
font-weight: 600;
margin-right: 5px;
color: rgba(255, 255, 255, 0.6);
letter-spacing: 0.4px;
vertical-align: 2px;
}