Fix mobile layout

This commit is contained in:
InfoX 2026-10-03 15:02:13 +02:00
parent 96703b6b30
commit 9d4b857893
Signed by: infox
GPG key ID: 0C5A10019B6B50F6
9 changed files with 187 additions and 42 deletions

View file

@ -9,6 +9,7 @@ import { DEFAULT_FILTERS, PlacesView, filterPlaces, type Filters } from './compo
import { ProfileView } from './components/ProfileView';
import { RouteView, type RouteForm } from './components/RouteView';
import { SettingsPanel } from './components/SettingsPanel';
import { SheetHandle, type Sheet } from './components/SheetHandle';
import { Alert, Icon } from './components/ui';
import { assessPlace, buildIndex } from './lib/assess';
import { findAccount, userKey, type Account } from './lib/auth';
@ -26,7 +27,7 @@ import { NoCreditsError, vetReport } from './lib/vet';
import { GuideBanner } from './components/GuideBanner';
import { findAlternatives } from './lib/route';
import { applySettings, defaultSettings, isSettings, type Settings } from './lib/settings';
import { clearLocal, newId, useLocalState, useOnline } from './lib/store';
import { clearLocal, isMetered, newId, useLocalState, useMedia, useOnline } from './lib/store';
import type { Assessment, Lang, Place, Profile, Snapshot, UserReport, Verdict } from './lib/types';
type View = 'places' | 'route' | 'dashboard' | 'about' | 'profile';
@ -62,7 +63,10 @@ export default function App() {
const lang: Lang = account ? settings.lang : loginLang;
const [view, setView] = useState<View>('places');
const [pane, setPane] = useState<'list' | 'map'>('list');
// Mobile portrait: the map fills the screen and the panel is a bottom sheet (peek / half / full).
const [sheet, setSheet] = useState<Sheet>('half');
const sheetMode = useMedia('(max-width: 63.99em) and (orientation: portrait)');
const setPane = useCallback((p: 'list' | 'map') => setSheet(p === 'map' ? 'peek' : 'half'), []);
const online = useOnline();
const i18n = useMemo<I18n>(() => {
@ -152,7 +156,7 @@ export default function App() {
const fit = inReach.filter((p) => { const v = assessments.get(p.id)?.verdict; return v === 'match' || v === 'partial'; }).length;
setReachState({ minutes, summary: { n: inReach.length, fit } });
setReachLines(r.lines);
setPane('map');
setSheet('half');
};
const select = useCallback((id: string | null) => {
@ -199,6 +203,11 @@ export default function App() {
...(photo ? { photoId: id } : {}), vetting: { state: 'running' },
};
if (photo) await putPhoto(id, photo.dataUrl);
// the on-device model is ~90 MB: ask before downloading it on a metered link
if (photo && isMetered() && !window.confirm(t('vet.metered'))) {
setUserReports((cur) => [...cur, { ...rep, vetting: { state: 'failed', reason: 'skipped' } }]);
return;
}
setUserReports((cur) => [...cur, rep]);
try {
const result = await vetReport(rep, photo ? { dataUrl: photo.dataUrl, blur: photo.blur } : null);
@ -224,7 +233,7 @@ export default function App() {
setRouteOptions(o);
setRouteSel(0);
setRouteEnds(from && to ? { from, to } : null);
if (o?.[0] && o[0].result.status !== 'none') setPane('map');
if (o?.[0] && o[0].result.status !== 'none') setSheet('half');
};
useEffect(() => { setRouteOptions(null); setRouteEnds(null); setGuiding(false); }, [profile]);
useEffect(() => { if (view !== 'route' || !route || route.status === 'none') setGuiding(false); }, [view, route]);
@ -303,12 +312,12 @@ export default function App() {
};
const official = account?.role === 'official';
const nav: { id: View; label: string }[] = [
{ id: 'places', label: t('nav.places') },
{ id: 'route', label: t('nav.route') },
...(official ? [{ id: 'dashboard' as View, label: t('nav.dashboard') }] : []),
{ id: 'about', label: t('nav.about') },
{ id: 'profile', label: t('nav.profile') },
const nav: { id: View; label: string; icon: string }[] = [
{ id: 'places', label: t('nav.places'), icon: 'location_on' },
{ id: 'route', label: t('nav.route'), icon: 'directions_walk' },
...(official ? [{ id: 'dashboard' as View, label: t('nav.dashboard'), icon: 'assessment' }] : []),
{ id: 'about', label: t('nav.about'), icon: 'info' },
{ id: 'profile', label: t('nav.profile'), icon: 'account_circle' },
];
const needsProfile = !!account && !profile;
const split = !!account && !needsProfile && (view === 'places' || view === 'route');
@ -325,10 +334,10 @@ export default function App() {
return (
<I18nContext.Provider value={i18n}>
<div className="kbb-app">
<div className={`kbb-app ${guiding && view === 'route' ? 'is-guiding' : ''}`}>
<a className="usa-skipnav" href="#main">{t('skip')}</a>
<section className="usa-site-alert usa-site-alert--info usa-site-alert--slim" aria-label={t('banner.proto')}>
<section className="kbb-proto usa-site-alert usa-site-alert--info usa-site-alert--slim" aria-label={t('banner.proto')}>
<div className="usa-alert">
<div className="usa-alert__body">
<p className="usa-alert__text">
@ -346,15 +355,16 @@ export default function App() {
{t('app.name')}
</p>
<nav aria-label={t('nav.label')} className="kbb-head__nav">
<ul className="usa-button-group usa-button-group--segmented margin-0 padding-0">
<ul className="kbb-nav usa-button-group usa-button-group--segmented margin-0 padding-0">
{nav.map((n) => (
<li className="usa-button-group__item" key={n.id}>
<button
type="button" aria-current={view === n.id ? 'page' : undefined}
className={`usa-button usa-button--small ${view === n.id ? '' : 'usa-button--outline'}`}
className={`kbb-nav__btn usa-button usa-button--small ${view === n.id ? '' : 'usa-button--outline'}`}
onClick={() => setView(n.id)}
>
{n.label}
<Icon name={n.icon} size={3} className="kbb-nav__icon" />
<span>{n.label}</span>
</button>
</li>
))}
@ -380,28 +390,11 @@ export default function App() {
</div>
</header>
{split && (
<div className="desktop:display-none bg-base-lightest border-bottom-1px border-base-lighter padding-1">
<div role="group" aria-label={t('view.label')} className="grid-container padding-0">
<ul className="usa-button-group usa-button-group--segmented margin-0 padding-0">
{(['list', 'map'] as const).map((p) => (
<li className="usa-button-group__item" key={p}>
<button type="button" aria-pressed={pane === p} className={`usa-button usa-button--small ${pane === p ? '' : 'usa-button--outline'}`} onClick={() => setPane(p)}>
<Icon name={p === 'list' ? 'list' : 'map'} size={3} className="margin-right-05" />
{t(p === 'list' ? 'view.list' : 'view.map')}
</button>
</li>
))}
</ul>
</div>
</div>
)}
{guiding && view === 'route' && route && route.status !== 'none' && (
<GuideBanner result={route} onStop={() => setGuiding(false)} onPos={setUserPos} onReroute={reroute} />
)}
<main id="main" ref={mainRef} tabIndex={-1} className={`kbb-main ${split ? `kbb-main--split is-${pane}` : ''}`}>
<main id="main" ref={mainRef} tabIndex={-1} className={`kbb-main ${split ? `kbb-main--split is-${sheet}` : ''}`}>
{loadError && <div className="grid-container padding-y-4"><Alert kind="error" role="alert">{t('common.loadError')}</Alert></div>}
{!loadError && !ready && !needsProfile && view !== 'profile' && (
<div className="grid-container padding-y-4" role="status">{t('common.loading')}</div>
@ -451,6 +444,8 @@ export default function App() {
{split && ready && (
<>
<div className="kbb-panel">
{sheetMode && <SheetHandle sheet={sheet} onSheet={setSheet} />}
<div className="kbb-panel__body" {...(sheetMode && sheet === 'peek' ? { inert: true } : {})}>
{view === 'places' && hotMode && (
<div className="margin-bottom-2">
<Alert kind="info" slim>
@ -495,6 +490,7 @@ export default function App() {
)}
<p className="font-sans-3xs text-base-dark margin-top-4">{t('footer.attr')}</p>
</div>
</div>
<MapView
key={lang}
places={mapPlaces}
@ -505,7 +501,7 @@ export default function App() {
routeEnds={view === 'route' ? routeEnds : null}
altRoutes={view === 'route' ? altRoutes : []}
focus={focus}
visible={pane === 'map'}
visible={sheet}
bbox={snap.meta.bbox}
online={online}
basemap={basemap}

View file

@ -50,8 +50,8 @@ interface Props {
/** Non-selected route options, drawn faintly for comparison. */
altRoutes: LatLon[][];
focus: { at: LatLon; n: number } | null;
/** Re-measure when the pane becomes visible. */
visible: boolean;
/** Changes whenever the layout around the map changes (bottom sheet moved); triggers a re-measure. */
visible: unknown;
bbox: [number, number, number, number];
online: boolean;
basemap: 'map' | 'satellite';

View file

@ -43,6 +43,7 @@ export function PlacesView({ profile, filters, onFilters, results, assessments,
const { t } = useI18n();
const [shown, setShown] = useState(PAGE);
const visible = useMemo(() => results.slice(0, shown), [results, shown]);
const activeFilters = (filters.cat !== DEFAULT_FILTERS.cat ? 1 : 0) + (filters.onlySaved ? 1 : 0) + (filters.onlyFit ? 1 : 0);
const set = (patch: Partial<Filters>) => { setShown(PAGE); onFilters({ ...filters, ...patch }); };
return (
@ -55,6 +56,8 @@ export function PlacesView({ profile, filters, onFilters, results, assessments,
<div role="search">
<label className="usa-label margin-top-0" htmlFor="q">{t('places.search')}</label>
<input id="q" className="usa-input maxw-full" type="search" value={filters.query} autoComplete="off" onChange={(e) => set({ query: e.target.value })} />
<details className="kbb-filters margin-top-1">
<summary>{activeFilters ? t('places.filtersActive', { n: activeFilters }) : t('places.filters')}</summary>
<label className="usa-label" htmlFor="cat">{t('places.category')}</label>
<select id="cat" className="usa-select maxw-full" value={filters.cat} onChange={(e) => set({ cat: e.target.value as Filters['cat'] })}>
{CATS.map((c) => <option key={c} value={c}>{t(`cat.${c}` as Key)}</option>)}
@ -67,6 +70,7 @@ export function PlacesView({ profile, filters, onFilters, results, assessments,
<input className="usa-checkbox__input" id="onlyfit" type="checkbox" checked={filters.onlyFit} onChange={(e) => set({ onlyFit: e.target.checked })} />
<label className="usa-checkbox__label" htmlFor="onlyfit">{t('places.onlyFit')}</label>
</div>
</details>
</div>
<p className="margin-top-3 margin-bottom-1 text-bold" role="status" aria-live="polite">{t('places.count', { n: results.length })}</p>

View file

@ -0,0 +1,33 @@
import { useRef } from 'react';
import { useI18n } from '../lib/i18n';
export type Sheet = 'peek' | 'half' | 'full';
const ORDER: Sheet[] = ['peek', 'half', 'full'];
const SWIPE_PX = 40;
/** Drag handle of the mobile bottom sheet: tap cycles, swipe up/down moves one step. */
export function SheetHandle({ sheet, onSheet }: { sheet: Sheet; onSheet: (s: Sheet) => void }) {
const { t } = useI18n();
const startY = useRef<number | null>(null);
const moved = useRef(false);
const step = (d: 1 | -1) => onSheet(ORDER[Math.min(2, Math.max(0, ORDER.indexOf(sheet) + d))] ?? sheet);
return (
<button
type="button" className="kbb-sheet__handle" aria-expanded={sheet !== 'peek'}
aria-label={t(sheet === 'full' ? 'sheet.collapse' : 'sheet.expand')}
onPointerDown={(e) => { startY.current = e.clientY; moved.current = false; e.currentTarget.setPointerCapture(e.pointerId); }}
onPointerUp={(e) => {
const y0 = startY.current;
startY.current = null;
if (y0 === null) return;
const dy = e.clientY - y0;
if (Math.abs(dy) >= SWIPE_PX) { moved.current = true; step(dy < 0 ? 1 : -1); }
}}
onClick={() => {
if (moved.current) { moved.current = false; return; }
onSheet(sheet === 'full' ? 'half' : sheet === 'half' ? 'full' : 'half');
}}
/>
);
}

View file

@ -178,7 +178,7 @@ export function VettingSummary({ v, detailed = false }: { v: VetState | undefine
if (!v) return null;
if (v.state === 'running') return <p className="font-sans-xs margin-y-05" role="status"><Icon name="update" size={2} className="margin-right-05" />{t('vet.running')}</p>;
if (v.state === 'failed') {
return <p className="font-sans-xs margin-y-05 text-error-dark" role="status"><Icon name="error_outline" size={2} className="margin-right-05" />{t(v.reason === 'no-credits' ? 'vet.noCredits' : 'vet.error')}</p>;
return <p className="font-sans-xs margin-y-05 text-error-dark" role="status"><Icon name="error_outline" size={2} className="margin-right-05" />{t(v.reason === 'no-credits' ? 'vet.noCredits' : v.reason === 'skipped' ? 'vet.skipped' : 'vet.error')}</p>;
}
const r = v.result;
return (

View file

@ -40,6 +40,10 @@ const pl = {
'places.search': 'Nazwa lub adres',
'places.category': 'Rodzaj miejsca',
'places.onlyFit': 'Pokaż tylko pasujące lub częściowo pasujące',
'places.filters': 'Filtry',
'places.filtersActive': 'Filtry ({n} aktywne)',
'sheet.expand': 'Rozwiń panel',
'sheet.collapse': 'Zwiń panel',
'places.count': 'Wyniki: {n}',
'places.more': 'Pokaż więcej',
'places.empty': 'Brak miejsc dla tych kryteriów.',
@ -577,6 +581,8 @@ const pl = {
'vet.running': 'Ocena automatyczna w toku…',
'vet.noCredits': 'Ocena automatyczna niedostępna: Brak kredytów (No Credits left).',
'vet.error': 'Ocena automatyczna nie powiodła się.',
'vet.skipped': 'Ocena automatyczna pominięta (połączenie mobilne). Zgłoszenie trafi do urzędu bez wstępnej oceny.',
'vet.metered': 'Ocena zdjęcia pobiera model AI (ok. 90 MB). Masz połączenie mobilne lub tryb oszczędzania danych. Pobrać teraz? Anuluj = wyślij zgłoszenie bez oceny.',
'vet.validity.valid': 'wiarygodne',
'vet.validity.unclear': 'niejednoznaczne',
'vet.validity.invalid': 'nieprawidłowe',
@ -699,6 +705,10 @@ const en: Dict = {
'places.search': 'Name or address',
'places.category': 'Type of place',
'places.onlyFit': 'Show only matching or partly matching',
'places.filters': 'Filters',
'places.filtersActive': 'Filters ({n} active)',
'sheet.expand': 'Expand panel',
'sheet.collapse': 'Collapse panel',
'places.count': 'Results: {n}',
'places.more': 'Show more',
'places.empty': 'No places for these criteria.',
@ -1236,6 +1246,8 @@ const en: Dict = {
'vet.running': 'Automatic assessment running…',
'vet.noCredits': 'Automatic assessment unavailable: No Credits left.',
'vet.error': 'Automatic assessment failed.',
'vet.skipped': 'Automatic assessment skipped (mobile connection). The report goes to the city office without a first look.',
'vet.metered': 'Assessing the photo downloads an AI model (about 90 MB). You are on a mobile connection or data saver. Download now? Cancel = submit the report without the assessment.',
'vet.validity.valid': 'plausible',
'vet.validity.unclear': 'unclear',
'vet.validity.invalid': 'invalid',

View file

@ -51,3 +51,22 @@ export function useOnline() {
}
export const newId = () => crypto.randomUUID();
/** Live `matchMedia` result; used to switch between the bottom-sheet and side-by-side layouts. */
export function useMedia(query: string) {
const [match, setMatch] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mq = window.matchMedia(query);
const on = () => setMatch(mq.matches);
on();
mq.addEventListener('change', on);
return () => mq.removeEventListener('change', on);
}, [query]);
return match;
}
/** True on a metered link (cellular or data saver): large downloads should ask first. */
export function isMetered(): boolean {
const c = (navigator as Navigator & { connection?: { saveData?: boolean; type?: string } }).connection;
return !!c && (c.saveData === true || c.type === 'cellular');
}

View file

@ -185,4 +185,4 @@ export interface Vetting {
export type VetState =
| { state: 'running' }
| { state: 'done'; result: Vetting }
| { state: 'failed'; reason: 'no-credits' | 'error' };
| { state: 'failed'; reason: 'no-credits' | 'error' | 'skipped' };

View file

@ -11,7 +11,7 @@
*, *::before, *::after { box-sizing: border-box; }
html, body, #root { height: 100%; }
body { margin: 0; }
.kbb-app { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.kbb-app { display: flex; flex-direction: column; height: 100%; height: 100dvh; min-height: 0; }
.kbb-main { flex: 1; min-height: 0; overflow: auto; background: #fff; }
.kbb-main:focus { outline: none; }
.kbb-narrow { max-width: 44rem; }
@ -24,9 +24,10 @@ body { margin: 0; }
.kbb-main--split { grid-template-columns: minmax(24rem, 30rem) minmax(0, 1fr); }
.kbb-panel { border-right: 1px solid #dfe1e2; }
}
@media (max-width: 63.99em) {
.kbb-main--split.is-list .kbb-map-wrap { display: none; }
.kbb-main--split.is-map .kbb-panel { display: none; }
/* phone held sideways: keep list and map side by side */
@media (max-width: 63.99em) and (orientation: landscape) {
.kbb-main--split { grid-template-columns: minmax(16rem, 22rem) minmax(0, 1fr); }
.kbb-panel { border-right: 1px solid #dfe1e2; padding-bottom: 1rem; }
}
.kbb-place-btn { appearance: none; cursor: pointer; font: inherit; color: inherit; }
@ -143,3 +144,83 @@ body { margin: 0; }
.kbb-guide__voice { display: flex; }
.kbb-guide__select { margin: 0; min-height: 2.5rem; padding: 0.4rem 2rem 0.4rem 0.6rem; background-color: transparent; color: #fff; border: 2px solid #fff; font-size: 0.95rem; }
.kbb-guide__select option { color: #1b1b1b; }
/* ================= mobile ================= */
.usa-tag { white-space: nowrap; }
.kbb-sheet__handle { display: none; }
.kbb-nav__icon { display: none; }
.kbb-filters > summary { cursor: pointer; font-weight: 700; color: #005ea2; line-height: 2.75rem; }
.kbb-filters > summary:focus-visible { outline: 0.25rem solid #2491ff; }
@media (max-width: 63.99em), (pointer: coarse) {
/* touch targets >= 44 px (WCAG 2.5.5 / platform guidance) */
.usa-button--small { min-height: 2.75rem; padding-left: 1rem; padding-right: 1rem; }
.usa-button--unstyled { display: inline-flex; align-items: center; min-height: 2.75rem; }
.usa-checkbox__label, .usa-radio__label { min-height: 2.75rem; padding-top: 0.6rem; padding-bottom: 0.6rem; }
details > summary { min-height: 2.75rem; }
.kbb-legend summary { display: flex; align-items: center; min-height: 2.75rem; }
.kbb-head__tools .usa-button { min-width: 2.75rem; min-height: 2.75rem; }
.maplibregl-ctrl-group button { width: 2.75rem; height: 2.75rem; }
.usa-button { min-height: 2.75rem; }
.usa-input, .usa-select { height: 2.75rem; }
.kbb-pin::after { content: ''; position: absolute; inset: -0.45rem; }
.font-sans-3xs, .font-sans-2xs { font-size: 0.875rem !important; }
}
@media (max-width: 63.99em) {
/* chrome budget: slim header + bottom tab bar, prototype banner moves out of the way */
.kbb-proto { display: none; }
.kbb-app { padding-bottom: calc(3.5rem + env(safe-area-inset-bottom)); }
.kbb-head { flex-wrap: nowrap; padding: 0.35rem 0.75rem; }
.kbb-head__brand { font-size: 1.05rem; line-height: 1.2; min-width: 0; }
.kbb-head__tools { gap: 0.5rem; flex: none; }
.kbb-head__nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; width: auto; order: 0; overflow: visible; background: #fff; border-top: 1px solid #a9aeb1; padding-bottom: env(safe-area-inset-bottom); }
.kbb-head__nav .kbb-nav { display: flex; width: 100%; box-shadow: none; }
.kbb-head__nav .usa-button-group__item { flex: 1 1 0; min-width: 0; margin: 0; }
.kbb-head__nav .kbb-nav__btn.usa-button { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.1rem; width: 100%; min-height: 3.5rem; margin: 0; padding: 0.25rem 0.1rem; border-radius: 0; box-shadow: none; background: #fff; color: #3d4551; font-size: 0.8125rem; font-weight: 400; line-height: 1.1; }
.kbb-head__nav .kbb-nav__btn.usa-button[aria-current='page'] { background: #e7f6f8; color: #00548a; font-weight: 700; box-shadow: inset 0 0.25rem 0 #005ea2; }
.kbb-head__nav .kbb-nav__btn.usa-button:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: -0.25rem; }
.kbb-nav__icon { display: block; fill: currentColor; }
.kbb-narrow .usa-intro { font-size: 1.05rem; line-height: 1.4; }
.kbb-legend { bottom: auto; top: 7rem; left: auto; right: 0.5rem; max-width: min(17rem, calc(100vw - 4rem)); }
.kbb-map-note { top: 3.9rem; right: 3.75rem; }
.kbb-guide { padding: 0.5rem 0.75rem; gap: 0.5rem; }
.kbb-guide .usa-button, .kbb-guide__select { min-height: 2.75rem; }
.kbb-guide__actions { flex-wrap: nowrap; width: 100%; gap: 0.4rem; }
.kbb-guide__actions > * { flex: 1 1 0; min-width: 0; }
.kbb-guide__actions > .kbb-guide__voice { flex-grow: 1.5; }
.kbb-guide .usa-button { padding-left: 0.4rem; padding-right: 0.4rem; font-size: 0.9rem; line-height: 1.1; }
.kbb-guide__select { width: 100%; font-size: 0.9rem; padding-right: 1.6rem; }
.kbb-guide__headline { font-size: 1.25rem; }
.is-guiding { padding-bottom: 0; }
.is-guiding .kbb-head { display: none; }
}
/* ---- bottom sheet (phone, portrait) ---- */
@media (max-width: 63.99em) and (orientation: portrait) {
.kbb-main--split { display: block; position: relative; --sheet-h: 55%; }
.kbb-main--split.is-peek { --sheet-h: 6.5rem; }
.kbb-main--split.is-half { --sheet-h: 55%; }
.kbb-main--split.is-full { --sheet-h: 100%; }
.kbb-map-wrap { position: absolute; inset: 0; height: auto; }
.kbb-panel { position: absolute; left: 0; right: 0; bottom: 0; z-index: 5; height: var(--sheet-h); padding: 0 1rem 2rem; border-radius: 1rem 1rem 0 0; box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.3); overscroll-behavior: contain; transition: height 0.2s ease; }
.kbb-main--split.is-peek .kbb-panel { overflow: hidden; }
.kbb-sheet__handle { display: flex; align-items: center; justify-content: center; position: sticky; top: 0; z-index: 2; width: calc(100% + 2rem); min-height: 2.75rem; margin: 0 -1rem; padding: 0; border: 0; background: #fff; border-radius: 1rem 1rem 0 0; touch-action: none; cursor: grab; }
.kbb-sheet__handle::before { content: ''; width: 2.75rem; height: 0.3rem; border-radius: 0.15rem; background: #71767a; }
.kbb-sheet__handle:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: -0.25rem; }
/* keep OSM attribution and map controls above the sheet */
.kbb-map-wrap .maplibregl-ctrl-bottom-left, .kbb-map-wrap .maplibregl-ctrl-bottom-right { bottom: var(--sheet-h); transition: bottom 0.2s ease; }
.is-guiding .kbb-panel { display: none; }
.is-guiding .kbb-map-wrap .maplibregl-ctrl-bottom-left, .is-guiding .kbb-map-wrap .maplibregl-ctrl-bottom-right { bottom: 0; }
}
/* ---- short screens (phone sideways): reclaim height ---- */
@media (max-height: 30em) and (orientation: landscape) {
.kbb-app { padding-bottom: calc(3rem + env(safe-area-inset-bottom)); }
.kbb-head__nav .kbb-nav__btn.usa-button { min-height: 3rem; flex-direction: row; gap: 0.4rem; }
.kbb-head__brand { font-size: 1rem; }
.kbb-legend { top: 0.5rem; right: 3.75rem; }
}
@media (prefers-reduced-motion: reduce) { .kbb-panel, .kbb-map-wrap .maplibregl-ctrl-bottom-left, .kbb-map-wrap .maplibregl-ctrl-bottom-right { transition: none; } }