diff --git a/client/src/components/Studio/StudioControls.tsx b/client/src/components/Studio/StudioControls.tsx index 468f8a8b2..37e626006 100644 --- a/client/src/components/Studio/StudioControls.tsx +++ b/client/src/components/Studio/StudioControls.tsx @@ -36,18 +36,26 @@ import ToggleSwitch from '../Settings/ToggleSwitch' * remembered: coming back to an element and finding its panel folded up * differently from last time is worse than opening one group again. */ -export function Section({ label, children, defaultOpen = true, hint }: { +export function Section({ label, children, defaultOpen = true, hint, badge }: { label: string children: ReactNode defaultOpen?: boolean /** A line under the label, for a group whose name cannot carry the whole idea. */ hint?: string + /** + * A figure beside the label, for a group worth reading while it is folded. + * The stops list is the case: how many of them count is the thing you came + * to check, and opening a section to read one number is a section that + * cannot usefully be closed. + */ + badge?: string }) { const [open, setOpen] = useState(defaultOpen) return (
{open && ( diff --git a/client/src/components/Studio/StudioTravelPanel.tsx b/client/src/components/Studio/StudioTravelPanel.tsx index a77ec7e0e..0f2a3629c 100644 --- a/client/src/components/Studio/StudioTravelPanel.tsx +++ b/client/src/components/Studio/StudioTravelPanel.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState } from 'react' +import { Check, RouteOff } from 'lucide-react' import type { BookElement, BookMetric, BookPageSetup, JourneyStats } from '@trek/shared' import { BOOK_METRICS } from '@trek/shared' import { useStudioStore } from '../../store/studioStore' -import { formatDate } from '../../utils/formatters' import { PanelHead } from './StudioPanelHead' +import { Section } from './StudioControls' import { TravelPreview } from './TravelPreview' import { formatBookCoords } from './entryText' import { routeFor } from './travelRefresh' @@ -238,6 +239,20 @@ export function StudioTravelPanel({ } return a.label.localeCompare(b.label) }) + const excludedCount = stops.filter(s => s.excluded).length + /** Whether the country is worth a badge, or the same word on every row. */ + const manyCountries = stats.countries.length > 1 + + /** + * The day of a stop, without the weekday. + * + * `formatDate` is the journal's format and carries "Tue," because a journal + * entry is about a day somebody lived. A row here is about a leg of a route, + * and it has 236px to hold a place name in as well: the weekday costs a + * third of the badge and answers nothing anybody asks of this list. + */ + const stopDay = (iso: string) => + new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) const toggleStop = async (entryId: number, excluded: boolean) => { setPending(prev => new Set(prev).add(entryId)) @@ -458,42 +473,73 @@ export function StudioTravelPanel({ place; they are the input every tile below reads. */} {stops.length > 0 && ( -
-
{t('journey.studio.stops')}
-

{t('journey.studio.stopsHint')}

+
    {stops.map((stop, i) => { const entryId = stop.entryId const on = !stop.excluded const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff') - const chip = `st-chip is-pick ${on ? 'is-on' : ''}` + const chip = `st-chip is-pick is-icon ${on ? 'is-on' : ''}` + /* + * A mark rather than the word. + * + * "Counts" and "Left out" are different lengths, so a column of + * them jitters, and together they cost a third of a row that + * has a place name to fit. The tick and the crossed-out route + * say the same thing at a glance, and the word is still there + * for anyone who hovers or reads with a screen reader. + */ + const mark = on ? : return (
  • -
    - {stop.label || t('journey.studio.untitled')} - - {stop.date && {formatDate(stop.date, locale)}} - {stop.country && {countryName(stop.country)}} - -
    + {/* + One line: the day, then the name, then the switch. The + name and the day were stacked, which doubled the height + of a list whose whole job is being read down. + */} + {stop.date && {stopDay(stop.date)}} + {stop.label || t('journey.studio.untitled')} + {/* + The country only where there is more than one to tell + apart. On a journey inside one country it is the same + word fourteen times, in the row that has least room. + */} + {manyCountries && stop.country && ( + {countryName(stop.country)} + )} {canEdit && entryId != null ? ( ) : ( - {state} + {mark} )}
  • ) })}
-
+ )}
diff --git a/client/src/styles/studio.css b/client/src/styles/studio.css index f69be9b15..32a7f5d7b 100644 --- a/client/src/styles/studio.css +++ b/client/src/styles/studio.css @@ -969,6 +969,15 @@ margin-bottom: 10px; } .trek-dash .st-section-head .st-section-label { margin-bottom: 0; } +/* A figure in the head, for a section worth reading while it is folded. It + sits against the caret rather than the label, so the labels stay aligned. */ +.trek-dash .st-section-badge { + margin-left: auto; + font-size: 10.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--ink-3); +} .trek-dash .st-section-caret { flex: none; opacity: 0.5; transition: transform .18s, opacity .14s; } .trek-dash .st-section-head:hover .st-section-caret { opacity: 1; } .trek-dash .st-section.is-folded .st-section-caret { transform: rotate(-90deg); } @@ -1936,34 +1945,37 @@ * The route as rows, in the Travel panel. * * Not tiles: a stop is not something to place, it is the input every map and - * figure below reads. A quiet row with the name, the day and the country, and - * the switch on the right, so the eye runs down the switches to find the one - * that is off. A left-out row is dimmed as a whole rather than struck through: - * it is still a day of the journal, just not a leg of the route. + * figure below reads. One line per stop, the day first and the switch last, so + * the eye runs down the right-hand edge to find the one that is off. A + * left-out row is dimmed as a whole rather than struck through: it is still a + * day of the journal, just not a leg of the route. + * + * A row is a row rather than a card: fourteen bordered boxes down a 236px + * panel is a wall, and what the list needs is to be read quickly, not to have + * each of its entries framed. The separators do the same work for a fifth of + * the height. */ .trek-dash .st-stops { display: flex; flex-direction: column; - gap: 4px; - margin: 4px 0 0; + margin: 2px 0 0; padding: 0; list-style: none; } .trek-dash .st-stop { display: flex; align-items: center; - gap: 8px; + gap: 6px; min-width: 0; - padding: 7px 8px 7px 10px; - border-radius: var(--r-sm); - background: color-mix(in oklch, var(--surface) 70%, transparent); - border: 1px solid var(--line); + padding: 4px 0; + border-bottom: 1px solid var(--line); transition: opacity .16s; } +.trek-dash .st-stop:last-child { border-bottom: 0; } .trek-dash .st-stop.is-off { opacity: .55; } -.trek-dash .st-stop-body { flex: 1 1 auto; min-width: 0; } .trek-dash .st-stop-label { - display: block; + flex: 1 1 auto; + min-width: 0; font-size: 12px; font-weight: 600; letter-spacing: -0.01em; @@ -1972,11 +1984,13 @@ overflow: hidden; text-overflow: ellipsis; } -.trek-dash .st-stop-meta { display: flex; align-items: center; gap: 5px; margin-top: 3px; min-width: 0; } /* The day keeps its width, as on the entry cards; the country gives way. */ -.trek-dash .st-stop-meta > .st-badge { flex: none; } -.trek-dash .st-stop-meta > .st-badge.is-quiet { flex: 0 1 auto; } -.trek-dash .st-stop > .st-chip { flex: none; height: 24px; font-size: 11px; } +.trek-dash .st-stop > .st-badge { flex: none; } +.trek-dash .st-stop > .st-badge.is-quiet { flex: 0 1 auto; } +/* The switch, as a mark: a square the size of the badges beside it. The + padding is reset because `is-pick` sets its own further down the file. */ +.trek-dash .st-stop > .st-chip.is-icon { flex: none; width: 22px; height: 22px; padding: 0; } +.trek-dash .st-stop > .st-chip:not(.is-on) { color: var(--ink-3); } /* The same chip as a plain label: a viewer's, or a stop with no entry to switch. */ .trek-dash .st-stop > span.st-chip { pointer-events: none; } diff --git a/client/tests/unit/studio/travelStops.test.tsx b/client/tests/unit/studio/travelStops.test.tsx index 1829914ba..1a3b5b074 100644 --- a/client/tests/unit/studio/travelStops.test.tsx +++ b/client/tests/unit/studio/travelStops.test.tsx @@ -44,7 +44,7 @@ const stats = (over: Partial = {}): JourneyStats => ({ type Toggle = (entryId: number, excluded: boolean) => Promise -function open(journeyStats: JourneyStats, over: { canEdit?: boolean; onToggleStop?: Toggle } = {}) { +function open(journeyStats: JourneyStats, over: { canEdit?: boolean; onToggleStop?: Toggle; folded?: boolean } = {}) { const onToggleStop = over.onToggleStop ?? vi.fn(async () => true) render( , ) - return { onToggleStop } + // The section opens folded, since a fortnight of rows is longer than the + // rest of the panel together. Every case below is about those rows, so + // unfold unless the case is about the fold itself. + const head = document.querySelector('.st-section-head') + if (head && !over.folded) fireEvent.click(head) + return { onToggleStop, head } } const rows = () => Array.from(document.querySelectorAll('.st-stop')) const labels = () => rows().map(r => r.querySelector('.st-stop-label')?.textContent) const switches = () => screen.queryAllByRole('button', { name: 'journey.studio.stopToggle' }) +/** The switch of a row reads its state from the title, since it draws a mark. */ +const stateOf = (row: Element) => row.querySelector('.st-chip')?.getAttribute('title') ?? '' beforeEach(() => { useStudioStore.getState().load({ @@ -78,9 +85,9 @@ describe('what the stops section lists', () => { const [keflavik, reykjavik] = rows() expect(keflavik.className).toContain('is-off') - expect(keflavik.textContent).toContain('journey.studio.stopOff') + expect(stateOf(keflavik)).toContain('journey.studio.stopOff') expect(reykjavik.className).not.toContain('is-off') - expect(reykjavik.textContent).toContain('journey.studio.stopOn') + expect(stateOf(reykjavik)).toContain('journey.studio.stopOn') }) it('puts an undated stop last', () => { @@ -89,14 +96,42 @@ describe('what the stops section lists', () => { expect(labels()).toEqual(['Reykjavík', 'Akureyri', 'Somewhere']) }) - it('names the country in the reader\'s language, and only for a stop that has one', () => { + /* + * The country is the first thing a 236px row gives up, and on a journey + * inside one country it was the same word on every line anyway. + */ + it('leaves the country off a journey that only went to one', () => { open(stats()) + expect(rows().map(r => r.querySelector('.st-badge.is-quiet'))).toEqual([null, null, null]) + }) + + it('names the country in the reader\'s language once there is more than one', () => { + open(stats({ + countries: [ + { code: 'IS', name: 'Iceland', places: 2, firstVisit: '2026-06-02' }, + { code: 'DK', name: 'Denmark', places: 1, firstVisit: '2026-06-10' }, + ], + })) + const quiet = rows().map(r => r.querySelector('.st-badge.is-quiet')?.textContent ?? null) // The left-out stop carries no country: it is not on the route to have one. expect(quiet).toEqual([null, 'Iceland', 'Iceland']) }) + /* + * The fold is the section's own contract: it costs one line when closed and + * still answers the question anyone opens it for, which is how many stops + * are counting. + */ + it('starts folded, with the count of what still counts in its head', () => { + const { head } = open(stats(), { folded: true }) + + expect(rows()).toHaveLength(0) + expect(head).toHaveAttribute('aria-expanded', 'false') + expect(head!.querySelector('.st-section-badge')?.textContent).toBe('2/3') + }) + it('is left out entirely when the journey has no stops at all', () => { open(stats({ points: [], excluded: [] })) @@ -147,7 +182,7 @@ describe('the switch', () => { expect(switches()).toHaveLength(0) expect(rows()).toHaveLength(3) - expect(rows()[1].textContent).toContain('journey.studio.stopOn') + expect(stateOf(rows()[1])).toBe('journey.studio.stopOn') }) it('is a label for a stop taken from a trip place, which has no entry to switch', () => { @@ -162,6 +197,6 @@ describe('the switch', () => { expect(labels()).toEqual(['Vík', 'Akureyri']) expect(switches()).toHaveLength(1) expect(rows()[0].querySelector('button')).toBeNull() - expect(rows()[0].textContent).toContain('journey.studio.stopOn') + expect(stateOf(rows()[0])).toBe('journey.studio.stopOn') }) })