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 (
+ {/*
+ 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')
})
})