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:
parent
1e0902f6b9
commit
e632285702
3 changed files with 106 additions and 98 deletions
24
AGENTS.md
24
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue