From 856f5b5b08f7d03494932d3f0c4239c72d114022 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 11 Oct 2026 02:36:58 +0200 Subject: [PATCH] feat(roadtrip): the rail and its dialogs in the planner's card language Day heads take the day plan's layout: the number on a raised tile with the date under it, the name beside it and its facts as pills that never wrap but give way in steps. Rides, terminals and hire desks are cards in their type's colour with their icon on the line and their times in the arrival column; a stop's bookings sit on its own badge row. Every badge on the rail is one pill shape, the drive bands are round, and the stay, stop and driving settings dialogs use the shared dialog frame. --- .../src/components/Roadtrip/EndDayControl.tsx | 4 +- .../src/components/Roadtrip/FigureBadge.tsx | 61 ++-- .../Roadtrip/RoadtripLimitsCard.tsx | 56 ++- .../Roadtrip/RoadtripSidebar.constants.ts | 18 +- .../components/Roadtrip/RoadtripSidebar.tsx | 16 +- .../Roadtrip/RoadtripSidebarBadges.tsx | 8 +- .../Roadtrip/RoadtripSidebarBookingRows.tsx | 279 +++++++++------ .../Roadtrip/RoadtripSidebarDriveBands.tsx | 21 +- .../Roadtrip/RoadtripSidebarHeaders.tsx | 333 ++++++++++-------- .../Roadtrip/RoadtripSidebarStopRows.tsx | 26 +- .../components/Roadtrip/RoadtripStayModal.tsx | 234 ++++++------ .../components/Roadtrip/RoadtripStopPopup.tsx | 259 ++++++-------- .../src/components/Roadtrip/StayPortals.tsx | 6 +- .../src/components/Roadtrip/VisitControls.tsx | 39 +- 14 files changed, 721 insertions(+), 639 deletions(-) diff --git a/client/src/components/Roadtrip/EndDayControl.tsx b/client/src/components/Roadtrip/EndDayControl.tsx index 750315f74..b05c89e6f 100644 --- a/client/src/components/Roadtrip/EndDayControl.tsx +++ b/client/src/components/Roadtrip/EndDayControl.tsx @@ -17,8 +17,8 @@ export default function EndDayControl({ active, onToggle }: EndDayControlProps) const { t } = useTranslation() const [pending, setPending] = useState(false) return ( -
- {t('roadtrip.window.endHere')} +
+ {t('roadtrip.window.endHere')} { if (pending) return setPending(true) diff --git a/client/src/components/Roadtrip/FigureBadge.tsx b/client/src/components/Roadtrip/FigureBadge.tsx index 5d6080b3e..e65ad322c 100644 --- a/client/src/components/Roadtrip/FigureBadge.tsx +++ b/client/src/components/Roadtrip/FigureBadge.tsx @@ -1,41 +1,42 @@ import React from 'react' import { Tooltip } from '../shared/Tooltip' -import { FS } from './typeScale' +import { fs } from '../shared/DialogShell' /** - * A figure in the two-part shell the rail uses everywhere: what it is on the left, the - * number on the right, one hairline between them. + * A fact on a stop's second line: what it is in the quiet ink (an icon or a word), then + * the figure. The day plan's white pill, the one its transports wear for their times, so + * a stop and the ride beside it carry their facts the same way. * * One component because the shell had been typed out six times, the stay, the walk, the * fill-up, the two findings and the limits button each carrying their own copy, and the - * copies had started to drift. The warning tone keeps the border in the warning colour - * itself, not its soft tint: at this size a tinted edge disappears into the card. + * copies had started to drift. */ const TONES = { - neutral: { - shell: 'border-edge', - lead: 'bg-surface-tertiary text-content-faint', - value: 'border-edge text-content-secondary', - }, - warning: { - shell: 'border-warning', - lead: 'bg-warning-soft text-warning', - value: 'border-warning text-warning', - }, + neutral: { lead: 'text-content-faint', value: 'text-content-secondary' }, + warning: { lead: 'text-warning', value: 'text-warning' }, + /** A confirmed booking: the word in the success colour, the figure plain. */ + success: { lead: 'text-success', value: 'text-content-secondary' }, + /** A booking still pending: the word in the warning colour, the figure plain. */ + pending: { lead: 'text-warning', value: 'text-content-secondary' }, } as const +// White with a light shadow and a hairline, so it still stands off the white card it sits on. +const PILL = 'inline-flex h-[20px] flex-none items-center gap-1 self-start whitespace-nowrap rounded-full bg-surface-card px-2 font-geist font-normal leading-none tabular-nums shadow-sm ring-1 ring-inset ring-edge-faint' + interface FigureBadgeProps { - /** An icon, or a word set as a caption (`caption`). */ + /** An icon, or a word (`caption`). */ lead: React.ReactNode /** The figure. Absent draws the lead alone, a badge that is only a sign. */ value?: React.ReactNode - tone?: 'neutral' | 'warning' + tone?: 'neutral' | 'warning' | 'success' | 'pending' tooltip?: string - /** The lead is a word, set small, wide and in capitals like the stay's. */ + /** The lead is a word rather than an icon, set in capitals like the day plan's phase pills. */ caption?: boolean /** The figure in the quiet ink: an invitation, or a value inherited rather than set. */ faint?: boolean valueSize?: 'label' | 'micro' + /** May give up width to the pills beside it, its words cut short rather than wrapping the row. */ + shrink?: boolean dir?: 'ltr' /** * Makes the badge the control that changes its figure. A span with the button role, not @@ -49,25 +50,24 @@ interface FigureBadgeProps { } export default function FigureBadge({ - lead, value, tone = 'neutral', tooltip, caption, faint, valueSize = 'label', dir, onActivate, ariaLabel, + lead, value, tone = 'neutral', tooltip, caption, faint, dir, onActivate, ariaLabel, shrink, }: FigureBadgeProps): React.ReactElement { const colours = TONES[tone] - const shell = `inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border ${colours.shell}` + const pill = shrink ? PILL.replace('flex-none', 'min-w-0 shrink overflow-hidden') : PILL const body = ( <> {lead} {value === undefined ? null : ( - - {value} - + <> + {/* A straight cut between what it is and the figure, the split the dialogs' pills use. */} + + {value} + )} ) @@ -84,12 +84,13 @@ export default function FigureBadge({ e.stopPropagation() onActivate(e.currentTarget) }} - className={`${shell} cursor-pointer transition-colors hover:border-content-faint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`} + className={`${pill} cursor-pointer transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`} + style={fs(10.5)} > {body} ) : ( - {body} + {body} ) return tooltip ? {badge} : badge } diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx index de5214eb9..e8e016916 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx @@ -4,7 +4,7 @@ import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Check, Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette, } from 'lucide-react' -import Modal from '../shared/Modal' +import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import { useTranslation } from '../../i18n/TranslationContext' import { useSettingsStore } from '../../store/settingsStore' import { convertDistance, formatDistance } from '../../utils/units' @@ -110,11 +110,11 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived "min" does — and that column was the reason every section had a right edge of its own. In the flow it simply wraps under a long label and costs nothing. */} - {label} + {label} {/* The unit as a chip rather than as more grey words after the label. "kWh/100 km" set as prose reads as part of the question; on a chip it reads as what the number beside it will be measured in, which is what it is. */} - + {suffix} @@ -129,7 +129,7 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived // screen-reader text. - {value ?? 0} + {value ?? 0} {derived} ) : ( @@ -151,7 +151,8 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }} // The spinners are the other half of the width problem: they eat a third of a // narrow field and nobody sets a driving limit by clicking an arrow. - className="w-full rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-end text-body tabular-nums text-content [appearance:textfield] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + className="w-full rounded-[10px] border border-edge bg-surface-input px-3 py-1.5 text-end tabular-nums text-content outline-none [appearance:textfield] placeholder:text-content-faint focus:ring-2 focus:ring-[color:var(--text-primary)] disabled:opacity-50 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + style={fs(13, 'body')} /> )} @@ -183,7 +184,7 @@ function AvoidRow({ icon: Icon, label, hint, on, disabled, onToggle }: {
- {label} + {label} {hint ? {hint} : null} {disabled @@ -213,7 +214,7 @@ function Panel({ icon: Icon, title, note, children }: { children: React.ReactNode }): React.ReactElement { return ( -
+
{title} {children} {note ? {note} : null} @@ -227,8 +228,8 @@ function SectionTitle({ icon: Icon, children }: { children: React.ReactNode }): React.ReactElement { return ( -

- +

+ {children}

) @@ -286,6 +287,8 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi const settings = useRoadtripSettings(s => s) const unit = useSettingsStore(s => s.settings.distance_unit) const [open, setOpen] = useState(false) + const labelId = useId() + const close = () => { setOpen(false); setPreview(null) } const distanceUnit: DistanceUnit = unit === 'imperial' ? 'imperial' : 'metric' const imperial = distanceUnit === 'imperial' // No way to save is the caller saying the reader may look but not change. Every @@ -428,7 +431,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi {badges.map(({ key, Icon, text, label }) => ( : } + lead={label ? : } value={text} tooltip={label} /> @@ -444,12 +447,27 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi {open ? ( - { setOpen(false); setPreview(null) }} size="4xl" title={ - - - {t('roadtrip.limit.title')} - - }> + } + tint={NEUTRAL_TINT} + labelId={labelId} + onClose={close} + title={t('roadtrip.limit.title')} + /> + )} + footer={( + + + {t('common.close')} + + )} + >
{/* Two columns, kept. One column reads more tidily but turns the dialog into something that has to be scrolled, and a setting you cannot see while you @@ -530,7 +548,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi from the browser instead of from a rebuilt listbox. */}
{t('roadtrip.limit.vehicleLabel')} -
+
{VEHICLES.map(({ key, labelKey, Icon }) => (
-
+ ) : null} ) diff --git a/client/src/components/Roadtrip/RoadtripSidebar.constants.ts b/client/src/components/Roadtrip/RoadtripSidebar.constants.ts index 5dc4049b4..24e2a3dd3 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.constants.ts +++ b/client/src/components/Roadtrip/RoadtripSidebar.constants.ts @@ -21,19 +21,15 @@ export const RAIL_DASH: CSSProperties = { export const DISC = 'grid h-6 w-6 shrink-0 place-items-center rounded-full' /** - * The small capitalised caption over a number, and the badges in a day's header. - * - * Wide letter-spacing and uppercase rather than a size change: the labels have to stay - * legible at a third of the column's width in 23 languages, and shrinking them further - * was what made "Driving time" unreadable before it was ever clipped. + * The small caption over a number: the eyebrow the planner's dialogs and cards use, so + * the trip head reads like the cards beside it. */ -export const STAT_LABEL = 'font-geist font-semibold uppercase tracking-[0.15em] text-content-faint' +export const STAT_LABEL = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint' /** - * A day-header badge: the date, the drive, the count. + * A pill in a day's head: the date, the drive, the count, a finding. * - * Medium weight in the quiet ink, not semibold in the strong one. Three uppercase badges - * with wide tracking already carry as much emphasis as a line can take; adding weight and - * contrast on top made the day's supporting facts shout louder than the day's own name. + * The day plan's pill: round, sentence case, quiet ink on the card colour with a soft + * shadow, so a day head reads the same in both tabs. */ -export const DAY_BADGE = 'inline-flex h-[20px] items-center rounded-lg px-2 font-geist font-medium uppercase tracking-[0.09em] text-content-muted' +export const DAY_BADGE = 'inline-flex h-[20px] items-center gap-1 rounded-full px-2 font-geist font-normal uppercase tracking-[.05em] text-content-secondary' diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index bb7a1bc23..c971b5bef 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -22,7 +22,7 @@ import { dayBookings } from './stopBookings' import type { Reservation } from '../../types' import type { StayDraft } from './RoadtripStayModal' import { missedLeaveOf, stayDraftOf } from './stayReading' -import { DAY_BADGE, RAIL_GRID, STAT_LABEL } from './RoadtripSidebar.constants' +import { RAIL_GRID, STAT_LABEL } from './RoadtripSidebar.constants' import { RefuelBand } from './RoadtripSidebarRefuelBand' import { DriveBand, SpillBlock } from './RoadtripSidebarDriveBands' import { BookendStop, BookingChips, RideBlock, TerminalStop, UndatedRides } from './RoadtripSidebarBookingRows' @@ -294,6 +294,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca if (stop.carrier.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return null const ride = stop.carrier.role === 'departure' && next?.carrier?.role === 'arrival' && sameRide(next) const open = onOpenBooking ? () => onOpenBooking(stop.carrier!.reservationId) : undefined + const booking = reservations?.find(res => res.id === stop.carrier!.reservationId) // The index the road leaves from: the arrival's when the block holds both ends. const tail = ride ? i + 1 : i const after = day.stops[tail + 1] @@ -301,6 +302,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
  • {ride ? ( ) : ( onEditStay(stayDraftOf(stop, day.schedule.entries[i], missedLeaveOf(day, i))) : undefined} onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined} onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} + bookings={bookings.atStop.get(i)} + canEditBookings={canEditBookings} + onOpenBooking={onOpenBooking} /> ) : ( onEditStay(stayDraftOf(stop, day.schedule.entries[i], missedLeaveOf(day, i))) : undefined} onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined} onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} + bookings={bookings.atStop.get(i)} + canEditBookings={canEditBookings} + onOpenBooking={onOpenBooking} /> )} - {bookings.atStop.has(i) ? ( - - ) : null} {i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? ( {day.date ? ( -