ui: compact route summary, map-first route dialog, clearer crews filter toggle

- Directions: the Distance/Time/Arrive tiles are now a compact one-line strip
  (StatTile removed), and the dialog's DOM order matches its visual order
  (header, map, vehicle row, From row, details, pinned Start) with redundant
  order-* so a dev CSS-timing hiccup can't scramble it.
- Crews: the filter expand toggle now has a divider and always shows its
  More/Less label, so it no longer reads as another crew category.
This commit is contained in:
SkateSpots 2026-09-29 17:38:01 +07:00
parent 1e0902f6b9
commit e632285702
3 changed files with 106 additions and 98 deletions

View file

@ -296,18 +296,18 @@ session with **no other memory of this project**. So:
screen-off/background navigation needs a native foreground service — the app
must stay foregrounded to guide.
- **Dialog layout (2026-09-29):** app-like flex column —
`flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden`. **Visual order is
set with flex `order-*` utilities, not DOM order** (avoids moving JSX): the
**map** is `order-2` (directly under the header — it's the point of the
screen), the **mode/vehicle row** `order-3` and **"From" row** `order-4` sit
*below* the map, the details are `order-5`, and the **Start/Stop bar** is
`order-6`, pinned at the bottom so it's always reachable. The map is
`shrink-0` at `h-[50vh] min-h-[300px] sm:h-[440px]` (was `38vh`), or
`h-[60vh]`/`sm:h-[62vh]` while following. ⚠️ Don't go back to a single
`overflow-hidden` content box:
that made everything below the fold **unreachable** once the map grew. The
summary is three `StatTile`s (distance / time / arrive) + a detail line
(steps, driving-roads note, avg speed) and the map carries a metric
`flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden`. **DOM order now
matches the visual order** (and `order-*` is set redundantly, so a dev-mode
CSS-timing hiccup can't scramble it): header, then the **map directly under
it** (it's the point of the screen), then the **mode/vehicle row**, then the
**"From" row**, then the scrolling details, with the **Start/Stop bar
pinned** at the bottom. The map is `shrink-0` at
`h-[50vh] min-h-[300px] sm:h-[440px]` (was `38vh`), or `h-[60vh]`/`sm:h-[62vh]`
while following. The **summary is a compact one-line strip**
(distance · time · arrive) — `StatTile` was **removed**; the three ~90px
tiles were eating the map's space. ⚠️ Don't go back to a single
`overflow-hidden` content box: that made everything below the fold
**unreachable** once the map grew. The map carries a metric
`L.control.scale`. **Transit** mode uses **Transitous** (`api.transitous.org`,
MOTIS 2, open CORS) — note origin/destination go to that service, unlike road
modes. **No external map-app handoff anywhere** (product decision) and no

View file

@ -96,18 +96,6 @@ interface RouteDialogProps {
* is drawn here. See src/lib/routing.ts for the honest scope notes.
*/
/** Big number + label tile used in the route summary. */
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="rounded-xl border border-white/8 bg-white/[0.03] px-2 py-2.5 text-center">
<p className="text-lg font-black leading-tight tabular-nums text-white">{value}</p>
<p className="mt-0.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</p>
{sub && <p className="mt-0.5 text-[10px] leading-tight text-muted-foreground/70">{sub}</p>}
</div>
);
}
/** The average speed the routing engine assumed for this mode, e.g. "21 km/h avg". */
function speedLabel(distanceM: number, durationS: number): string | undefined {
if (!(distanceM > 0) || !(durationS > 0)) return undefined;
@ -685,62 +673,6 @@ export function RouteDialog({
</DialogDescription>
</DialogHeader>
{/* Mode toggle (vehicles) — sits BELOW the map via order-3. */}
<div className="order-3 flex shrink-0 gap-1.5 px-4 pb-3">
{TRAVEL_MODES.map((m) => (
<button
key={m.mode}
type="button"
onClick={() => chooseMode(m.mode)}
className={cn(
'flex-1 flex flex-col items-center gap-0.5 rounded-xl py-2 text-xs font-bold uppercase tracking-wide transition-all border-2',
mode === m.mode
? 'bg-[#ccff00]/15 border-[#ccff00] text-[#ccff00]'
: 'border-transparent bg-muted/50 text-muted-foreground hover:bg-muted',
)}
aria-pressed={mode === m.mode}
>
<span className="text-lg leading-none">{m.emoji}</span>
<span className="text-[10px] leading-tight text-center">{m.label}</span>
</button>
))}
</div>
{/* Start point — default is where you are; you can pick another on the map */}
<div className="order-4 flex shrink-0 flex-wrap items-center gap-1.5 px-4 pb-3">
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
From
</span>
<button
type="button"
onClick={() => {
setStartMode('me');
setPickingStart(false);
}}
className={cn(
'rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
startMode === 'me'
? 'border-[#ccff00]/60 bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/10 text-muted-foreground hover:text-foreground',
)}
>
My location
</button>
<button
type="button"
onClick={() => setPickingStart((v) => !v)}
className={cn(
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
pickingStart || startMode === 'picked'
? 'border-[#ccff00]/60 bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/10 text-muted-foreground hover:text-foreground',
)}
>
<Crosshair className="h-3 w-3" />
{pickingStart ? 'Tap the map…' : 'Choose on map'}
</button>
</div>
{/* Map. The height lives on the WRAPPER: the global
`.leaflet-container { height: 100% }` override beats a `h-64` on the
map element itself, and against a height-less parent that resolved to
@ -842,11 +774,67 @@ export function RouteDialog({
)}
</div>
{/* Mode toggle (vehicles) — sits BELOW the map (order-3). */}
<div className="order-3 flex shrink-0 gap-1.5 px-4 pb-3">
{TRAVEL_MODES.map((m) => (
<button
key={m.mode}
type="button"
onClick={() => chooseMode(m.mode)}
className={cn(
'flex-1 flex flex-col items-center gap-0.5 rounded-xl py-2 text-xs font-bold uppercase tracking-wide transition-all border-2',
mode === m.mode
? 'bg-[#ccff00]/15 border-[#ccff00] text-[#ccff00]'
: 'border-transparent bg-muted/50 text-muted-foreground hover:bg-muted',
)}
aria-pressed={mode === m.mode}
>
<span className="text-lg leading-none">{m.emoji}</span>
<span className="text-[10px] leading-tight text-center">{m.label}</span>
</button>
))}
</div>
{/* Start point — default is where you are; you can pick another on the map */}
<div className="order-4 flex shrink-0 flex-wrap items-center gap-1.5 px-4 pb-3">
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
From
</span>
<button
type="button"
onClick={() => {
setStartMode('me');
setPickingStart(false);
}}
className={cn(
'rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
startMode === 'me'
? 'border-[#ccff00]/60 bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/10 text-muted-foreground hover:text-foreground',
)}
>
My location
</button>
<button
type="button"
onClick={() => setPickingStart((v) => !v)}
className={cn(
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
pickingStart || startMode === 'picked'
? 'border-[#ccff00]/60 bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/10 text-muted-foreground hover:text-foreground',
)}
>
<Crosshair className="h-3 w-3" />
{pickingStart ? 'Tap the map…' : 'Choose on map'}
</button>
</div>
{/* Scrollable details */}
<div className="order-5 min-h-0 flex-1 overflow-y-auto">
{/* Summary bar */}
<div className="p-4 border-t bg-muted/30 min-h-[64px] flex items-center">
<div className="flex items-center border-t bg-muted/30 px-4 py-2.5">
{error ? (
<div className="flex items-center gap-2 text-sm text-amber-500">
<AlertTriangle className="h-4 w-4 shrink-0" />
@ -887,22 +875,34 @@ export function RouteDialog({
<p className="text-[10px] text-muted-foreground/70">{TRANSIT_ATTRIBUTION}</p>
</div>
) : route ? (
<div className="w-full space-y-2">
<div className="grid grid-cols-3 gap-2">
<StatTile label="Distance" value={formatDistance(route.distanceM / 1000)} />
<StatTile
label="Time"
value={formatDuration(route.durationS)}
sub={
mode === 'foot'
? `${skateTime(route.distanceM / 1000)} on a board`
: speedLabel(route.distanceM, route.durationS)
}
/>
<StatTile label="Arrive" value={etaClock(route.durationS)} />
<div className="w-full space-y-1.5">
{/* Compact summary — a single line instead of three ~90px tiles,
so the map above keeps the vertical space. */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 leading-none">
<span className="text-base font-black tabular-nums text-white">
{formatDistance(route.distanceM / 1000)}
</span>
<span aria-hidden className="h-3.5 w-px bg-white/15" />
<span className="flex items-baseline gap-1.5">
<span className="text-base font-black tabular-nums text-white">
{formatDuration(route.durationS)}
</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
ride
</span>
</span>
<span aria-hidden className="h-3.5 w-px bg-white/15" />
<span className="flex items-baseline gap-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
arrive
</span>
<span className="text-base font-black tabular-nums text-white">
{etaClock(route.durationS)}
</span>
</span>
</div>
<p className="flex flex-wrap items-center gap-x-1.5 text-[10px] text-muted-foreground">
<span className="text-base leading-none">
<span className="text-sm leading-none">
{TRAVEL_MODES.find((m) => m.mode === mode)?.emoji}
</span>
<span>
@ -910,6 +910,11 @@ export function RouteDialog({
? `${route.steps.length} steps`
: 'direct'}
</span>
{mode === 'foot'
? <span>· {skateTime(route.distanceM / 1000)} on a board</span>
: speedLabel(route.distanceM, route.durationS) && (
<span>· {speedLabel(route.distanceM, route.durationS)}</span>
)}
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
<span>· uses driving roads</span>
)}

View file

@ -310,16 +310,19 @@ export default function CrewsPage() {
</ToggleGroup>
</div>
{/* Expand/collapse the full filter names (remembered across visits) */}
{/* Expand/collapse the filter names (remembered across visits).
A divider + a labelled button keep it from reading as just another
crew category sitting next to the chips. */}
<span aria-hidden className="h-6 w-px shrink-0 bg-white/10" />
<button
type="button"
onClick={() => setFiltersExpanded((v) => !v)}
aria-expanded={filtersExpanded}
title={filtersExpanded ? 'Show fewer filters' : 'Show full filter names'}
className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-2.5 text-xs font-bold text-gray-300 transition-colors hover:border-teal-500/40 hover:text-teal-300"
className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg px-2 text-xs font-bold text-gray-400 transition-colors hover:bg-white/5 hover:text-teal-300"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', filtersExpanded && 'rotate-180')} />
<span className="hidden sm:inline">{filtersExpanded ? 'Less' : 'More'}</span>
{filtersExpanded ? 'Less' : 'More'}
</button>
{/* Near me — mobile only; on desktop it was just clutter in this bar. */}