Files
TREK/client/src/mobile/mobile.css
T
Maurice fc648bc7e4 fix: green checks after the first-screen redesign and the safe-area floor
- native-shell/amount.ts: the no-break spaces in the price pattern were
  written out as characters; \s already matches them.
- widgets.ts: the Live Activity's lead time stays inside the module.
- i18n: the count strings of the new plan tiles replace the removed ones
  on NOT_PLURAL, and the address placeholders of the non-Latin locales
  are their own instead of English copies.
- mobile.css: the side safe-area margins of the floating bars use
  margin-inline, swapped for right to left, instead of physical sides.
2026-11-12 14:36:13 +01:00

852 lines
39 KiB
CSS

/* ── Mobile rewrite design tokens ─────────────────────────────────────────────
* Scoped to .m-root (the MobileShell wrapper) so the mobile design system
* never leaks into desktop chrome. Values are the mobile design ground truth;
* dark values follow the app-wide `.dark` class strategy.
*/
.m-root {
--m-ink: #101013;
--m-muted: #68686F;
--m-faint: #9A9AA1;
--m-bg: #F3F2EF;
--m-scr: radial-gradient(120% 80% at 50% -10%, #FBFAF7 0%, #F1EFEA 55%, #E9E7E1 100%);
--m-glass: rgba(255,255,255,.6);
/* The dock's glass: denser than --m-glass, so a photo scrolling under it
* neither turns it muddy nor washes out its icons. */
--m-dock: rgba(255,255,255,.8);
--m-gbr: rgba(255,255,255,.75);
--m-card: rgba(255,255,255,.55);
--m-cbr: rgba(255,255,255,.75);
--m-inner: rgba(255,255,255,.68);
--m-inbr: rgba(255,255,255,.9);
--m-act: #101013;
--m-actfg: #fff;
--m-dim: rgba(16,16,19,.4);
/* A picture shown full screen (MImageViewer): dark in either scheme, as a photo viewer is. */
--m-viewer-bg: rgba(0,0,0,.92);
--m-viewer-ink: #fff;
--m-viewer-btn: rgba(255,255,255,.15);
--m-sheet: rgba(250,250,248,.94);
--m-sheetop: #FAFAF8;
--m-shbr: rgba(255,255,255,.95);
--m-ic: rgba(16,16,19,.04);
--m-avbr: rgba(255,255,255,.9);
--m-rowbr: rgba(16,16,19,.045);
--m-conn: #9A9AA1;
--m-mapb: #E8E6E1;
--m-mapr: #FBFAF8;
--m-mapw: #DFE2E4;
--m-mkr: #101013;
--m-mkfg: #fff;
--m-trackoff: rgba(16,16,19,.14);
/* The edge of an empty checkbox: at least 3:1 against a row, so an open item
* shows its box rather than reading as plain text. */
--m-checkbr: rgba(16,16,19,.55);
/* Strength of a dayTintProvider plugin's colour on an inactive day chip. A notch
* above the desktop badge: the chip has no neutral base under it, so a softer tint
* reads as a rendering artifact rather than a deliberate colour. */
--day-tint-chip: 20%;
/* Toggle knob is white on both themes (off-track is translucent enough). */
--m-knob: #fff;
/* Status color canon — theme independent. */
--m-st-confirmed: #2FA37A;
--m-st-pending: #E8A13A;
--m-st-info: #4A7DDB;
--m-st-danger: #D6273B;
--m-st-neutral: #9A9AA1;
/* Chrome metrics. --bottom-nav-h stays the single source of truth for
* content clearance (same value the legacy shell uses). The demo's fixed
* 52px/26px offsets stand in for the phone status bar / home indicator, so
* real devices derive them from the safe-area insets. */
--bottom-nav-h: calc(84px + env(safe-area-inset-bottom, 0px));
/* The safe area the iOS app measures (TrekBridgeViewController
* publishSafeArea) is a floor under env(): where a release lays the web view
* out below the status bar, WebKit reports 0 while a camera in a corner still
* reaches into the page. In a browser the variables are unset: env() alone. */
--m-safe-t: max(env(safe-area-inset-top, 0px), var(--trek-native-safe-top, 0px));
--m-safe-l: max(env(safe-area-inset-left, 0px), var(--trek-native-safe-left, 0px));
--m-safe-r: max(env(safe-area-inset-right, 0px), var(--trek-native-safe-right, 0px));
--m-safe-top: calc(var(--m-safe-t) + 12px);
/* The navigation rail's share of the leading edge (wide windows only), what
* every full-width fixed bar and the page column start after. */
--m-rail-w: 0px;
}
/* Wide size class (hooks/sizeClass.ts, written to the shell's root as
* data-size): the rail on the leading edge takes the dock's place, so nothing
* docks at the bottom any more and content only clears the home indicator.
* Asked from <html>, so a sheet's own .m-root (portalled, perhaps to body)
* reads the same values as the shell. */
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
--bottom-nav-h: env(safe-area-inset-bottom, 0px);
--m-rail-w: calc(var(--m-safe-l) + 100px);
}
html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
--m-rail-w: calc(var(--m-safe-r) + 100px);
}
.dark .m-root {
--m-ink: #F5F5F7;
--m-muted: #9C9CA3;
--m-faint: #6E6E76;
--m-bg: #0A0A0C;
--m-scr: radial-gradient(120% 80% at 50% -10%, #17171C 0%, #0C0C0F 55%, #08080A 100%);
--m-glass: rgba(26,26,31,.6);
--m-dock: rgba(26,26,31,.82);
--m-gbr: rgba(255,255,255,.12);
--m-card: rgba(24,24,29,.55);
--m-cbr: rgba(255,255,255,.1);
--m-inner: rgba(255,255,255,.07);
--m-inbr: rgba(255,255,255,.1);
--m-act: #F5F5F7;
--m-actfg: #101013;
--m-dim: rgba(0,0,0,.55);
--m-sheet: rgba(22,22,27,.94);
--m-sheetop: #191920;
--m-shbr: rgba(255,255,255,.14);
--m-ic: rgba(255,255,255,.09);
--m-avbr: rgba(255,255,255,.14);
--m-rowbr: rgba(255,255,255,.07);
--m-conn: #6E6E76;
--m-mapb: #121215;
--m-mapr: #1E1F24;
--m-mapw: #0D1013;
--m-mkr: #F5F5F7;
--m-mkfg: #101013;
--m-trackoff: rgba(255,255,255,.14);
--m-checkbr: rgba(255,255,255,.4);
--day-tint-chip: 28%;
}
/* Geist "subtext" tier weight — single knob. The design's Geist labels/numbers
* read heavy at their declared bold; render the whole tier at Medium (500) for
* the fine, low-key look. Higher specificity than Tailwind's font-* utilities,
* so it caps font-medium / font-semibold / font-bold / font-extrabold on any
* .font-geist span. Retune here if a weight feels too light or heavy. */
.m-root .font-geist { font-weight: 500; }
/* ── Keyframes ──────────────────────────────────────────────────────────── */
/* The floating card pops in place, the way a native alert does. It is opaque
halfway through the pop, so the page behind never reads through its fields. */
@keyframes m-sheet-in {
from { opacity: 0; transform: translateY(10px) scale(.96); }
45% { opacity: 1; }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes m-sheet-out {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(6px) scale(.97); }
}
/* The docked sheet rises from below the screen edge and sinks back there: it
sits above the nav, so it travels its own height plus that gap. */
@keyframes m-sheet-up {
from { transform: translateY(calc(100% + var(--bottom-nav-h, 84px) + 24px)); }
to { transform: translateY(0); }
}
@keyframes m-sheet-down {
from { transform: translateY(0); }
to { transform: translateY(calc(100% + var(--bottom-nav-h, 84px) + 24px)); }
}
@keyframes m-drawer-in {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
@keyframes m-drawer-out {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
@keyframes m-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes m-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes m-pop-in {
from { opacity: 0; transform: translateY(-6px) scale(.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes m-toast-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes m-toast-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(6px); }
}
/* A panel at the trailing edge (the assistant on a wide window) slides in from
that edge, the way Slide Over does. */
@keyframes m-panel-in {
from { opacity: 0; transform: translateX(28px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes m-panel-in-rtl {
from { opacity: 0; transform: translateX(-28px); }
to { opacity: 1; transform: translateX(0); }
}
/* A form sheet on a wide window sits mid-screen, so it rises from the bottom
edge of the window rather than from just below its own resting place. */
@keyframes m-sheet-up-wide {
from { transform: translateY(calc(50dvh + 50%)); }
to { transform: translateY(0); }
}
@keyframes m-sheet-down-wide {
from { transform: translateY(0); }
to { transform: translateY(calc(50dvh + 50%)); }
}
.m-sheet-in { animation: m-sheet-in 260ms var(--ease-out-quint) both; }
.m-sheet-out { animation: m-sheet-out 220ms var(--ease-drawer) both; }
.m-sheet-up { animation: m-sheet-up 420ms var(--ease-drawer) both; }
.m-sheet-down { animation: m-sheet-down 300ms var(--ease-drawer) both; }
.m-drawer-in { animation: m-drawer-in 280ms var(--ease-drawer) both; }
.m-drawer-out { animation: m-drawer-out 280ms var(--ease-drawer) both; }
.m-fade-in { animation: m-fade-in 280ms var(--ease-drawer) both; }
.m-fade-out { animation: m-fade-out 280ms var(--ease-drawer) both; }
.m-pop-in { animation: m-pop-in 220ms var(--ease-out-quint) both; }
.m-toast-in { animation: m-toast-in 260ms var(--ease-out-quint) both; }
.m-toast-out { animation: m-toast-out 220ms ease both; }
@media (prefers-reduced-motion: no-preference) {
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-sheet-up { animation-name: m-sheet-up-wide; }
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-sheet-down { animation-name: m-sheet-down-wide; }
}
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-panel-in { animation: m-panel-in 320ms var(--ease-out-quint) both; }
html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-panel-in { animation-name: m-panel-in-rtl; }
@media (prefers-reduced-motion: reduce) {
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-panel-in { animation: m-fade-in 120ms ease-out both; }
}
/* Reduced motion: gentler, not zero (same rule the desktop keyframes follow). */
@media (prefers-reduced-motion: reduce) {
.m-sheet-in, .m-sheet-up, .m-drawer-in, .m-pop-in, .m-toast-in {
animation: m-fade-in 120ms ease-out both;
}
.m-sheet-out, .m-sheet-down, .m-drawer-out, .m-toast-out {
animation: m-fade-out 120ms ease-out both;
}
}
/* ── TREK mascot (empty-state) — logo-as-body character, per-scene props ───── */
@keyframes trek-idle { 0%, 100% { transform: translateY(0) rotate(-1.1deg); } 50% { transform: translateY(-2px) rotate(1.1deg); } }
@keyframes trek-breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.012, 1.024); } }
@keyframes trek-shadow { 0%, 100% { transform: scaleX(1); opacity: .15; } 50% { transform: scaleX(1.05); opacity: .17; } }
/* body hop, occasional: long rest, then anticipation squash → airborne → land → settle */
@keyframes trek-hop {
0%, 74%, 100% { transform: translateY(0) scale(1, 1); }
79% { transform: translateY(1px) scale(1.05, .92); }
87% { transform: translateY(-10px) scale(.95, 1.07); }
94% { transform: translateY(0) scale(1.07, .93); }
98% { transform: translateY(0) scale(.99, 1.01); }
}
/* body nod — a pleased look left-and-right, e.g. cheering the poll results */
@keyframes trek-nod {
0%, 100% { transform: translateY(0) rotate(0); }
25% { transform: translateY(-2px) rotate(-4deg); }
50% { transform: translateY(0) rotate(0); }
75% { transform: translateY(-2px) rotate(4deg); }
}
/* slow blink: eyes squeeze shut for a couple of frames each cycle */
@keyframes trek-blink { 0%, 90%, 100% { transform: scaleY(1); } 94%, 97% { transform: scaleY(.12); } }
/* entrance / poke-to-react pop-in (plays on mount and on each remount/tap) */
@keyframes trek-in {
0% { opacity: 0; transform: translateY(7px) scale(.85); }
60% { opacity: 1; }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
/* pupils glancing around — centred most of the time, occasional look down/side */
@keyframes trek-look {
0%, 34%, 100% { transform: translate(0, 0); }
42%, 52% { transform: translate(0, .9px); }
64%, 72% { transform: translate(.9px, .1px); }
84%, 90% { transform: translate(-.8px, .2px); }
}
/* wheels rolling in place */
@keyframes trek-roll { to { transform: rotate(360deg); } }
/* skateboard ollie — body and board pop off the ground together, now and then */
@keyframes trek-sk-body {
0%, 68%, 100% { transform: translateY(0) scale(1, 1); }
73% { transform: translateY(1.5px) scale(1.04, .95); }
84% { transform: translateY(-9px) scale(.97, 1.05); }
92% { transform: translateY(0) scale(1.05, .95); }
96% { transform: translateY(0) scale(1, 1); }
}
@keyframes trek-sk-board {
0%, 68%, 100% { transform: translateY(0) rotate(0); }
73% { transform: translateY(0) rotate(-6deg); }
84% { transform: translateY(-8px) rotate(7deg); }
90% { transform: translateY(-3px) rotate(-3deg); }
95% { transform: translateY(0) rotate(0); }
}
/* guide pennant fluttering — pivots at the pole */
@keyframes trek-flag {
0%, 100% { transform: skewY(0) scaleX(1); }
30% { transform: skewY(-7deg) scaleX(.96); }
60% { transform: skewY(6deg) scaleX(1.03); }
}
/* poll bars staying up, gently rising and settling like live tallies */
@keyframes trek-bar { 0%, 100% { transform: scaleY(.78); } 50% { transform: scaleY(1); } }
@keyframes trek-typing { 0%, 70%, 100% { transform: scale(.5); opacity: .4; } 35% { transform: scale(1); opacity: 1; } }
/* card / note rocking gently */
@keyframes trek-ticket { 0%, 100% { transform: rotate(-2.5deg); } 50% { transform: rotate(2.5deg); } }
/* a line being written: draws in from the left, holds, then a fresh pass */
@keyframes trek-write {
0% { transform: scaleX(0); }
30%, 82% { transform: scaleX(1); }
92%, 100% { transform: scaleX(0); }
}
/* pencil scribbling as the lines appear */
@keyframes trek-pencil {
0%, 100% { transform: translate(0, 0) rotate(0); }
20% { transform: translate(-1.5px, 1.5px) rotate(-4deg); }
45% { transform: translate(1.5px, -.5px) rotate(3deg); }
70% { transform: translate(-1px, 1px) rotate(-2deg); }
}
/* suitcase rocking on its wheels, restless to get going */
@keyframes trek-suitcase { 0%, 100% { transform: rotate(-2.5deg); } 50% { transform: rotate(2.5deg); } }
/* suitcase handle telescoping up and down */
@keyframes trek-handle { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
/* collections map pin bobbing */
@keyframes trek-pin { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
/* Chain: the pair sways, and the near link slides out along its own axis and
clicks back in. Moving only the near one keeps the joint readable at 88px. */
@keyframes trek-chain { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(-6deg); } }
@keyframes trek-link { 0%, 62%, 100% { transform: translateX(0); } 30% { transform: translateX(2.6px); } }
/* atlas globe meridian drifting to fake a slow spin */
@keyframes trek-globe { 0%, 100% { transform: translateX(-2px); } 50% { transform: translateX(2px); } }
/* costs top coin bouncing on */
@keyframes trek-coin { 0%, 100% { transform: translateY(0); } 30% { transform: translateY(-5px); } 55% { transform: translateY(0); } 70% { transform: translateY(-2px); } 84% { transform: translateY(0); } }
/* journey page turning over the spine */
@keyframes trek-page { 0%, 55%, 100% { transform: scaleX(1); } 78% { transform: scaleX(-.9); } }
/* dashboard paper plane bobbing in flight */
@keyframes trek-plane { 0%, 100% { transform: translate(0, 0) rotate(0); } 25% { transform: translate(-1px, -2px) rotate(-4deg); } 50% { transform: translate(0, 0) rotate(0); } 75% { transform: translate(1px, 2px) rotate(4deg); } }
/* notifications Zzz drifting up and fading */
@keyframes trek-z { 0% { opacity: 0; transform: translate(0, 4px) scale(.7); } 30% { opacity: .9; } 100% { opacity: 0; transform: translate(3px, -8px) scale(1.1); } }
/* search magnifier scanning */
@keyframes trek-magnifier { 0%, 100% { transform: translateX(-3px) rotate(-3deg); } 50% { transform: translateX(3px) rotate(3deg); } }
/* to-do checkmark ticking on, holding, then resetting */
@keyframes trek-check { 0%, 8% { transform: scale(0); } 22% { transform: scale(1.2); } 32%, 88% { transform: scale(1); } 96%, 100% { transform: scale(0); } }
/* tours: a walking step, the pole swinging forward and planting, the pack riding along */
@keyframes trek-walk { 0%, 50%, 100% { transform: translateY(0); } 25%, 75% { transform: translateY(-1.6px); } }
@keyframes trek-pole { 0%, 100% { transform: rotate(-9deg); } 45% { transform: rotate(7deg); } 55% { transform: rotate(7deg); } }
@keyframes trek-pack { 0%, 50%, 100% { transform: rotate(0); } 25%, 75% { transform: rotate(-3deg); } }
@keyframes trek-trail { to { stroke-dashoffset: 8; } }
.trek { animation: trek-in 480ms cubic-bezier(.34, 1.4, .64, 1) both; transform-origin: 50% 100%; }
.trek-root { animation: trek-idle 3.4s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
.trek-body { animation: trek-breathe 3.8s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
.trek-shadow { animation: trek-shadow 3.4s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 50%; opacity: .15; filter: blur(1px); }
/* eyes read as cutouts in the surface behind the mascot, so they stay visible on the monochrome body */
.trek-eye { fill: var(--m-bg); }
.trek-eyes { animation: trek-blink 5.2s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.trek-pupils { animation: trek-look 7s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
/* body bounce — per scene */
.trek-bounce { transform-box: fill-box; transform-origin: 50% 100%; }
.trek-bounce--idle { animation: trek-hop 4.6s ease-in-out infinite; }
.trek-bounce--polls { animation: trek-nod 3s ease-in-out infinite; }
.trek-bounce--transport { animation: trek-sk-body 3.6s ease-in-out infinite; }
.trek-bounce--tours { animation: trek-walk 1.6s ease-in-out infinite; }
/* per-scene props */
.trek-board { animation: trek-sk-board 3.6s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 65%; }
.trek-wheel { animation: trek-roll 1.1s linear infinite; transform-box: fill-box; transform-origin: center; }
.trek-flag { animation: trek-flag 1.8s ease-in-out infinite; transform-box: fill-box; transform-origin: 0% 50%; }
.trek-ticket { animation: trek-ticket 3.6s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
.trek-note { animation: trek-ticket 3.9s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
.trek-pencil { animation: trek-pencil 1.15s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.trek-write { animation: trek-write 3.8s ease-in-out infinite; transform-box: fill-box; transform-origin: 0% 50%; }
.trek-bar { animation: trek-bar 2.6s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
.trek-dot { animation: trek-typing 1.4s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 50%; }
.trek-suitcase { animation: trek-suitcase 1.5s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
.trek-handle { animation: trek-handle 1.5s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
.trek-pin { animation: trek-pin 2.2s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
.trek-chain { animation: trek-chain 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.trek-link { animation: trek-link 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.trek-globe { animation: trek-globe 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.trek-coin { animation: trek-coin 2s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
.trek-page { animation: trek-page 3.4s ease-in-out infinite; transform-box: fill-box; transform-origin: 0% 50%; }
.trek-plane { animation: trek-plane 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.trek-magnifier { animation: trek-magnifier 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.trek-z { animation: trek-z 2.8s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.trek-check { animation: trek-check 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.trek-pole { animation: trek-pole 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 0%; }
.trek-pack { animation: trek-pack 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: 90% 10%; }
.trek-trail { animation: trek-trail .8s linear infinite; }
/* staggered starts for written lines / poll bars / Zzz */
.trek-d2, .trek-z2 { animation-delay: .5s; }
.trek-d3, .trek-z3 { animation-delay: 1s; }
/* Mobile range slider — the --m-* twin of the desktop .trek-range. */
.m-range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 5px;
border-radius: 999px;
background: linear-gradient(to right, var(--m-act) var(--fill, 50%), var(--m-trackoff) var(--fill, 50%));
outline: none;
cursor: pointer;
}
.m-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--m-knob);
border: 1px solid var(--m-rowbr);
box-shadow: 0 1px 4px rgba(0, 0, 0, .25);
transition: transform .12s ease;
}
.m-range::-webkit-slider-thumb:hover { transform: scale(1.1); }
.m-range::-webkit-slider-thumb:active { transform: scale(1.18); }
.m-range::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--m-knob);
border: 1px solid var(--m-rowbr);
box-shadow: 0 1px 4px rgba(0, 0, 0, .25);
}
.m-range::-moz-range-track { height: 5px; border-radius: 999px; background: transparent; }
@media (prefers-reduced-motion: reduce) {
.trek, .trek-root, .trek-body, .trek-shadow, .trek-eyes, .trek-pupils,
.trek-bounce--idle, .trek-bounce--polls, .trek-bounce--transport, .trek-bounce--tours, .trek-pole, .trek-pack, .trek-trail,
.trek-board, .trek-wheel, .trek-flag, .trek-ticket, .trek-note, .trek-pencil, .trek-write, .trek-bar,
.trek-suitcase, .trek-handle, .trek-pin, .trek-globe, .trek-coin, .trek-page, .trek-plane,
.trek-magnifier, .trek-z, .trek-check, .trek-chain, .trek-link { animation: none; }
/* Zzz would sit invisible at opacity 0 without their animation — pin them visible */
.trek-z { opacity: 1; }
}
/* The phone map carries no visible credit.
*
* Both engines park a credit at the bottom of the map, and on a phone the map is the
* whole screen, so whatever is put there sits in the middle of the picture. It has been
* a band across the bottom (#2022) and then a round (i) beside the locate button, and
* neither reads as part of this shell: a lone control under the locate button is the
* first thing the eye lands on over a full-bleed map.
*
* So on the phone it is off. The desktop map is untouched and still shows the full
* credit with its links, which is where this belongs; `.m-root` is the phone shell only.
* Both engines and both corners, because a phone meets either depending on the screen,
* and the GL containers hold nothing else — MapViewGL adds no control of its own, so
* hiding the corner hides the credit and the wordmark and nothing besides.
*
* Owner's call, made knowingly: OSM and the tile vendors ask for a visible credit. */
.m-root .leaflet-control-attribution,
.m-root .maplibregl-ctrl-bottom-left,
.m-root .mapboxgl-ctrl-bottom-left,
.m-root .maplibregl-ctrl-bottom-right,
.m-root .mapboxgl-ctrl-bottom-right {
display: none;
}
/* Text fields carry the row's background, not their own.
*
* iOS paints its own background inside a text control, and under
* `color-scheme: dark` that fill is darker than the card the field sits on —
* so every field showed as a dark rectangle inside a lighter row (#2028). The
* mobile fields are deliberately `bg-transparent` because the row around them
* supplies the surface, which is exactly why the native fill has nothing
* covering it here and shows through; a desktop field hides it behind its own
* background. Dropping the native appearance is what actually removes it.
*
* Only text entry. `file`, `color`, `range` and the date/time pickers need
* their native chrome to stay usable, and `select` keeps its own arrow. */
.m-root input:not([type]),
.m-root input[type="text"],
.m-root input[type="password"],
.m-root input[type="email"],
.m-root input[type="url"],
.m-root input[type="search"],
.m-root textarea {
-webkit-appearance: none;
appearance: none;
}
/* Horizontal scroller without a visible scrollbar, for control rows that must
survive narrow viewports and large system font scales (dashboard filter). */
.m-hscroll {
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
.m-hscroll::-webkit-scrollbar {
display: none;
}
/* ── Page transitions (mobile/routeTransition.ts) ───────────────────────────
The way a native navigation stack moves: forward slides the new page in from
the trailing edge over the old one, which drifts a little the other way and
dims; back is the mirror; switching between the dock's sections only
cross-fades. --m-vt-dir flips the travel for right-to-left languages. The
dock keeps its own name, so it stands still while the pages move under it. */
:root {
--m-vt-dir: 1;
/* The company-holiday tint of the Vacay cells (vacayDayModel), for the legend chip. */
--m-vacay-company: #f5d9a6;
}
:root[dir='rtl'] {
--m-vt-dir: -1;
}
.m-dock-vt {
view-transition-name: m-dock;
}
@keyframes m-vt-in-trailing {
from {
transform: translateX(calc(100% * var(--m-vt-dir)));
}
}
@keyframes m-vt-out-leading {
to {
transform: translateX(calc(-30% * var(--m-vt-dir)));
filter: brightness(0.82);
}
}
@keyframes m-vt-in-leading {
from {
transform: translateX(calc(-30% * var(--m-vt-dir)));
filter: brightness(0.82);
}
}
@keyframes m-vt-out-trailing {
to {
transform: translateX(calc(100% * var(--m-vt-dir)));
}
}
:root[data-route-transition='forward']::view-transition-old(root) {
animation: m-vt-out-leading 0.4s cubic-bezier(0.32, 0.72, 0, 1) both;
}
:root[data-route-transition='forward']::view-transition-new(root) {
animation: m-vt-in-trailing 0.4s cubic-bezier(0.32, 0.72, 0, 1) both;
box-shadow: -16px 0 40px -12px rgb(0 0 0 / 0.35);
}
:root[data-route-transition='back']::view-transition-old(root) {
z-index: 1;
animation: m-vt-out-trailing 0.34s cubic-bezier(0.32, 0.72, 0, 1) both;
box-shadow: -16px 0 40px -12px rgb(0 0 0 / 0.35);
}
:root[data-route-transition='back']::view-transition-new(root) {
animation: m-vt-in-leading 0.34s cubic-bezier(0.32, 0.72, 0, 1) both;
}
:root[data-route-transition='tab']::view-transition-old(root),
:root[data-route-transition='tab']::view-transition-new(root) {
animation-duration: 0.18s;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
/* A scroll box under a fixed header: content fades out over the padding at its
edges instead of being cut off in a hard line right under the header. */
.m-scroll-fade {
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
}
/* The shared pickers (select, date, time) inside the phone UI look like the
sheets' own fields: a 42 px round field on the field surface. They style
themselves inline for the desktop, hence the !important. */
.m-root [data-field-trigger] {
min-height: 42px;
border-radius: 9999px !important;
border: 1px solid var(--m-rowbr) !important;
background: var(--m-ic) !important;
}
.m-root button[data-field-trigger],
.m-root [data-field-trigger='pad'] {
padding-inline: 14px !important;
}
/* ── The page column on a wide window ───────────────────────────────────────
Fixed bars span the page, not the window: they start after the navigation
rail (--m-rail-w, 0 on a phone, so these are the phone's insets there). */
.m-bar-x {
inset-inline-start: calc(var(--m-rail-w, 0px) + 16px);
inset-inline-end: 16px;
}
.m-span-x {
inset-inline-start: var(--m-rail-w, 0px);
inset-inline-end: 0;
}
/* Without the rail the floating bars keep clear of the sides' safe area too:
a camera in a corner can come as a side inset. The insets are the screen's,
not the text direction's, so right to left takes them the other way round.
On a wide window the rail takes the leading side and a bar may centre itself
(mx-auto). */
html:not(:has(.m-shell[data-size='wide'], .m-shell[data-size='split'])) :is(.m-bar-x, .m-span-x) {
margin-inline: var(--m-safe-l, 0px) var(--m-safe-r, 0px);
}
html[dir='rtl']:not(:has(.m-shell[data-size='wide'], .m-shell[data-size='split'])) :is(.m-bar-x, .m-span-x) {
margin-inline: var(--m-safe-r, 0px) var(--m-safe-l, 0px);
}
/* A screen whose background is a map reaches under the rail: the map runs to
the window's edge and the rail floats over it like the top controls do. Its
own bars then keep to the page column with m-bar-x / m-span-x. */
.m-bleed {
margin-inline-start: calc(var(--m-rail-w, 0px) * -1);
}
/* The trip's pane beside the map (split size class, MTripShell): a third of
the window, within what a list and its rows need. On a foldable that reports
its fold (two horizontal viewport segments), the pane takes the first half
to the fold exactly, so no row runs across the crease and the map has the
other half to itself. */
.m-trip {
--trip-pane-open: clamp(360px, 38vw, 460px);
}
@media (horizontal-viewport-segments: 2) {
.m-trip {
--trip-pane-open: calc(env(viewport-segment-width 0 0, 50vw) - var(--m-rail-w, 0px));
}
}
/* The leading pane of a two-pane screen (a tab's summary, a list beside its
detail): about a third of the page, within what its cards need. On a
foldable that reports its fold it ends at the fold, like the trip's pane. */
.m-root {
--m-pane-aside-w: clamp(320px, 34vw, 420px);
--m-pane-trail-w: clamp(340px, 36vw, 440px);
}
@media (horizontal-viewport-segments: 2) {
.m-root {
--m-pane-aside-w: calc(env(viewport-segment-width 0 0, 50vw) - var(--m-rail-w, 0px));
--m-pane-trail-w: env(viewport-segment-width 1 0, 50vw);
}
}
/* The Android app reports a foldable's fold itself (native/windowFold.ts):
MobileShell marks a vertical fold that separates the window with data-fold
and its place in --m-fold-start/--m-fold-end, and the panes meet there. The
leading side follows the reading direction, the fold's place does not. */
.m-root[data-fold='vertical'] {
--m-pane-aside-w: calc(var(--m-fold-start) - var(--m-rail-w, 0px));
--m-pane-trail-w: calc(100vw - var(--m-fold-end));
}
.m-root[data-fold='vertical'] .m-trip {
--trip-pane-open: calc(var(--m-fold-start) - var(--m-rail-w, 0px));
}
:root[dir='rtl'] .m-root[data-fold='vertical'] {
--m-pane-aside-w: calc(100vw - var(--m-fold-end) - var(--m-rail-w, 0px));
--m-pane-trail-w: var(--m-fold-start);
}
:root[dir='rtl'] .m-root[data-fold='vertical'] .m-trip {
--trip-pane-open: calc(100vw - var(--m-fold-end) - var(--m-rail-w, 0px));
}
.m-pane-aside {
width: var(--m-pane-aside-w);
}
/* The trailing pane: what accompanies the page rather than leads it, like the
trip chat beside its notes and polls. */
.m-pane-trail {
width: var(--m-pane-trail-w);
}
/* The phone rules of index.css, for the app. It shows this shell at every
size, an iPad's included (mobile/useIsPhone.ts), where their media query does
not match; the shell's root carries .m-shell, so they follow it there: the
document is the scroller, the shell paints the background, no zoom buttons
on a touch map, no scrollbars. */
html:has(.m-shell) {
overflow: visible;
overscroll-behavior: none;
}
/* In the iOS app the page bounces at its top and bottom, as every iOS list
does; the fixed bars stay where they are. The rule above is for the browser,
where a pull at the top reloads an installed PWA, which the app cannot do.
Not on Android, whose stretch scales the bars too (MainActivity turns it
off), and not sideways. A screen with pull-to-refresh bounces too; its pull
holds only the move down from the top (mobile/usePullToRefresh.ts). */
html[data-native-ios],
html[data-native-ios]:has(.m-shell) {
overscroll-behavior-y: auto;
}
html:has(.m-shell) body {
height: auto;
overflow: visible;
overflow-x: clip;
background-color: transparent;
}
/* The few routes without a phone screen of their own (a plugin's page, help,
a trip's files) still render through PageShell. Inside the touch shell its
desktop navbar, shown from 768px up, would stand over the rail and repeat the
navigation, and --nav-h would keep 64px of room for it: the shell has its own
navigation, so neither is there. */
html:has(.m-shell) {
--nav-h: 0px;
}
html:has(.m-shell) [data-desktop-navbar] {
display: none;
}
/* A plugin's page inside the touch shell: below the status bar, above the dock,
after the rail, and exactly the visible height (100dvh), so the plugin's own
document is what scrolls instead of the app stretching past the screen. */
html:has(.m-shell) [data-plugin-page] {
--plugin-page-h: calc(100dvh - var(--bottom-nav-h, 0px));
box-sizing: border-box;
padding-top: env(safe-area-inset-top, 0px);
padding-inline-start: var(--m-rail-w, 0px);
}
html:has(.m-shell) .leaflet-control-zoom {
display: none !important;
}
html:has(.m-shell) * {
scrollbar-width: none;
}
html:has(.m-shell) ::-webkit-scrollbar {
width: 0;
height: 0;
}
/* While the trip's map runs under the rail (MTripShell, --trip-map-inset), the
controls both engines draw in its corners step clear of the rail. A margin on
the leading side moves only the corner next to the rail, in either reading
direction: the corner on the far side is anchored there and does not move. */
.m-trip .leaflet-control-container > div,
.m-trip .maplibregl-control-container > div,
.m-trip .mapboxgl-control-container > div {
margin-inline-start: var(--trip-map-inset, 0px);
}
/* The strip under a floating dock (components/MDockScrim.tsx): solid across
the gap below the dock, clear from about its middle up. */
.m-dock-scrim {
-webkit-mask-image: linear-gradient(to top, #000 calc(env(safe-area-inset-bottom, 0px) + 12px), transparent calc(env(safe-area-inset-bottom, 0px) + 40px));
mask-image: linear-gradient(to top, #000 calc(env(safe-area-inset-bottom, 0px) + 12px), transparent calc(env(safe-area-inset-bottom, 0px) + 40px));
}
/* A horizontal strip with more behind an edge fades out there
(components/useEdgeFade.ts), so a cut chip reads as "the row goes on". */
.m-fade-s,
.m-fade-e,
.m-fade-se {
/* A strip with a chevron over its faded end fades wider (--m-fade-reach). */
--m-fade-w: var(--m-fade-reach, 28px);
-webkit-mask-image: var(--m-fade-mask);
mask-image: var(--m-fade-mask);
}
.m-fade-s {
--m-fade-mask: linear-gradient(var(--m-fade-dir, 90deg), transparent 0, #000 var(--m-fade-w));
}
.m-fade-e {
--m-fade-mask: linear-gradient(var(--m-fade-dir, 90deg), #000 calc(100% - var(--m-fade-w)), transparent 100%);
}
.m-fade-se {
--m-fade-mask: linear-gradient(var(--m-fade-dir, 90deg), transparent 0, #000 var(--m-fade-w), #000 calc(100% - var(--m-fade-w)), transparent 100%);
}
:root[dir='rtl'] .m-fade-s,
:root[dir='rtl'] .m-fade-e,
:root[dir='rtl'] .m-fade-se {
--m-fade-dir: 270deg;
}
/* The same for a vertical scroller, a form sheet's body above all: it fades
out at the top once scrolled and at the foot while more waits below, so a
field cut by the footer reads as "the form goes on". Never mirrored. */
.m-fade-t,
.m-fade-b,
.m-fade-tb {
--m-fade-h: var(--m-fade-reach-y, 24px);
-webkit-mask-image: var(--m-fade-vmask);
mask-image: var(--m-fade-vmask);
}
.m-fade-t {
--m-fade-vmask: linear-gradient(to bottom, transparent 0, #000 var(--m-fade-h));
}
.m-fade-b {
--m-fade-vmask: linear-gradient(to bottom, #000 calc(100% - var(--m-fade-h)), transparent 100%);
}
.m-fade-tb {
--m-fade-vmask: linear-gradient(to bottom, transparent 0, #000 var(--m-fade-h), #000 calc(100% - var(--m-fade-h)), transparent 100%);
}
/* A hint longer than its field ends in an ellipsis instead of being cut off
mid-word at the field's edge ("Код аэропорта или город (нап"). */
.m-root input:placeholder-shown {
text-overflow: ellipsis;
}
/* ── Sheets ─────────────────────────────────────────────────────────────────
A sheet takes the focus itself when it opens (modalFocus.ts), so a screen
reader starts inside it. The app-wide focus ring (index.css) is meant for
controls: on the sheet it drew a 2px frame round the whole dialog and
squared its corners off to 4px. */
.m-root [data-m-sheet]:focus-visible {
outline: none;
border-radius: var(--m-sheet-r);
}
/* A form sheet (MSheet `form`) on a window as short as a phone on its side
fills the height, and its actions move up beside the title the way an
iPhone form carries Cancel and Save in its bar, so the fields get every
line in between instead of a slot 140px tall. Cancel then does what the
header's close button did, so that one goes. */
@media (max-height: 500px) {
/* Row 1: the title and the actions. Row 2: whatever a form keeps fixed
under its title (the transport's manual/automated switch). Row 3: the
fields, which take the rest. Doubled class: the panel's own `flex`
utility must not win the display back. */
.m-form-sheet.m-form-sheet {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto minmax(0, 1fr);
align-items: center;
}
.m-form-sheet > * {
grid-column: 1 / -1;
}
.m-form-sheet > .m-fs-head {
grid-area: 1 / 1;
}
.m-form-sheet > .m-fs-foot {
grid-area: 1 / 2;
border-top: 0;
padding-block: 16px 8px;
padding-inline: 0 18px;
}
.m-form-sheet > .m-fs-body {
grid-row: 3;
align-self: stretch;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
}
.m-form-sheet:has(> .m-fs-foot) > .m-fs-head .m-fs-close {
display: none;
}
}
/* A segmented control in the trip's top bar that does not fit between the back
button and the trailing button draws its segments tighter first
(MTripShellParts GlassSegment), and scrolls only where that is not enough. */
.m-seg[data-tight='true'] > * {
padding-inline: 10px;
}
/* A one-line Markdown caption (MarkdownText clamp: an address, a note in a
row) ends in an ellipsis. The clamp sits on the wrapper, but the text is in
the paragraph inside it, so the line was cut off mid-letter at the row's
edge or under its buttons with nothing to say it went on. */
.m-root .collab-note-md.truncate > * {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The trip's shared links in the touch shell (CollabLinks in MCollabTab). Their
edit, pin and delete buttons are always out on a touch screen, so a link in
one of two columns kept a sliver for its title ("JR seat / reservations") and
cut its host. One column, until the board has room for two links side by
side with their titles on one line. */
.m-root .collab-link-grid__cells {
grid-template-columns: minmax(0, 1fr);
}
@container (min-width: 720px) {
.m-root .collab-link-grid__cells {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}