Files
TREK/client/src/components/Studio/StudioTravelInspector.tsx
T
Maurice e6930e00ac refactor(studio): share the typeface buttons and the weight line between the inspectors
The text inspector and the travel inspector each drew the same row of
typeface buttons and the same weight choice. Both now render FontButtons
and WeightLine from StudioTypeControls.tsx. The travel inspector keeps
casting its patches to Partial<BookElement>, so each side writes exactly
the fields it wrote before.
2026-10-09 16:15:49 +02:00

742 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import type {
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
BookMetric, BookStatsElement, JourneyStats,
} from '@trek/shared'
import { BOOK_METRICS } from '@trek/shared'
import { isStale, refreshPatch } from './travelRefresh'
import { Swatches } from './StudioSwatches'
import { FontButtons, WeightLine } from './StudioTypeControls'
import { sourceIdOf, useMapSources } from './mapSources'
import { printDpi, tileView } from './mapTiles'
import { useCartoApiKey } from '../../hooks/useTileUrl'
import { routeFor } from './travelRefresh'
import { Choice, Line, Picks, Section, Switch } from './StudioControls'
import { fetchRoads } from './roadRoute'
/**
* Properties of the travel elements.
*
* Its own file because these four are a different kind of thing from a photo or
* a paragraph: what you adjust is *what the element says*, not how a box looks.
* Keeping them here leaves StudioInspector.tsx about the elements that have
* been there since the beginning.
*/
export interface TravelInspectorProps {
el: BookElement
/** Live figures, for the refresh button. Null while loading or on failure. */
stats: JourneyStats | null
set: (patch: Partial<BookElement>) => void
t: (k: string) => string
locale: string
}
interface Props<T> {
el: T
set: (patch: Partial<BookElement>) => void
t: (k: string) => string
}
export function TravelInspector({ el, stats, set, t }: TravelInspectorProps) {
if (
el.kind !== 'map' && el.kind !== 'stats' && el.kind !== 'countries'
&& el.kind !== 'badge' && el.kind !== 'list'
) {
return null
}
const stale = isStale(el, stats)
return (
<>
{stale && (
<div className="st-section">
<p className="st-stale">{t('journey.studio.staleHint')}</p>
<button type="button"
className="st-chip"
onClick={() => {
if (!stats) return
const patch = refreshPatch(el, stats)
if (patch) set(patch)
}}
>
{t('journey.studio.refresh')}
</button>
</div>
)}
{el.kind === 'map' && <MapProps el={el} set={set} t={t} stats={stats} />}
{el.kind === 'stats' && <StatsProps el={el} set={set} t={t} />}
{el.kind === 'countries' && <CountriesProps el={el} set={set} t={t} />}
{el.kind === 'badge' && <BadgeProps el={el} set={set} t={t} />}
{el.kind === 'list' && <ListProps el={el} set={set} t={t} />}
{/*
* Type, for all five alike.
*
* These elements set their own words — a figure over its caption, a
* country's name over its outline — and until now the only thing you
* could change about that was the size. A book set in a serif with its
* stats block in the default sans is a book with one element in the
* wrong typeface and no way to fix it.
*/}
<Section label={t('journey.studio.typography')} defaultOpen={false}>
<FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} />
<div style={{ marginTop: 10 }}>
<WeightLine
font={el.font}
weight={el.weight}
onPick={weight => set({ weight } as Partial<BookElement>)}
t={t}
/>
<Line label={t('journey.studio.textScale')}>
<Choice
value={el.textScale}
options={([0.7, 0.85, 1, 1.2, 1.5] as const).map(scale => ({
value: scale, label: `${scale}×`,
}))}
onPick={textScale => set({ textScale } as Partial<BookElement>)}
/>
</Line>
</div>
</Section>
{/*
* Colour, for all five alike, and in one group.
*
* These were two sections called Accent and Colour, one under the other,
* each ten unlabelled swatches wrapping onto two rows: twenty squares and
* no way to tell which of them painted what. The role now sits beside the
* row and says it — the accent is the mark's own colour, the ink is what
* is written on it.
*
* They ship with one warm accent so a book made without touching anything
* still reads as designed. That is a starting point, not a house style: a
* book printed to match a cover, or one that simply should not be orange,
* needs both of these.
*/}
<Section label={t('journey.studio.colour')}>
<Line label={t('journey.studio.accent')} wide>
<Swatches value={el.accent} onPick={c => set({ accent: c } as Partial<BookElement>)} />
</Line>
<Line label={t('journey.studio.text')} wide>
{/*
Picking a colour is what turns automatic off, so the swatch does
both and the switch below is the way back rather than the way in.
Only marks have it; nothing else here has a fill of its own for the
words to answer to.
*/}
<Swatches
value={el.color}
onPick={c => set(
(el.kind === 'badge' ? { color: c, autoColor: false } : { color: c }) as Partial<BookElement>,
)}
/>
</Line>
{el.kind === 'badge' && (
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.autoColour')}
on={el.autoColor}
onToggle={() => set({ autoColor: !el.autoColor } as Partial<BookElement>)}
/>
</div>
)}
</Section>
</>
)
}
function ListProps({ el, set, t }: Props<BookListElement>) {
return (
<Section label={t('journey.studio.addProsCons')}>
<Line label={t('journey.studio.layout')}>
<Choice
value={el.layout}
options={[
{ value: 'columns' as const, label: t('journey.studio.layoutGrid') },
{ value: 'stacked' as const, label: t('journey.studio.layoutColumn') },
]}
onPick={layout => set({ layout } as Partial<BookElement>)}
/>
</Line>
<div className="st-grid2" style={{ marginTop: 10 }}>
<label className="st-field">
<span>{t('journey.editor.pros')}</span>
<input
className="st-input"
value={el.proLabel}
onChange={e => set({ proLabel: e.target.value } as Partial<BookElement>)}
/>
</label>
<label className="st-field">
<span>{t('journey.editor.cons')}</span>
<input
className="st-input"
value={el.conLabel}
onChange={e => set({ conLabel: e.target.value } as Partial<BookElement>)}
/>
</label>
</div>
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.showMarks')}
on={el.showMarks}
onToggle={() => set({ showMarks: !el.showMarks } as Partial<BookElement>)}
/>
</div>
</Section>
)
}
function MapProps({ el, set, t, stats }: Props<BookMapElement> & { stats: JourneyStats | null }) {
const cartoKey = useCartoApiKey()
const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
const [roadDone, setRoadDone] = useState(0)
const hasRoads = (el.roads ?? []).some(r => r && r.length > 1)
/*
* One leg at a time, paced, and written into the document when it is done
* rather than leg by leg: a hundred commits would be a hundred undo steps and
* a hundred autosaves for one action.
*/
const loadRoads = async () => {
setRoadState('busy')
setRoadDone(0)
try {
const roads = await fetchRoads(
el.points.map(p => ({ lat: p.lat, lng: p.lng })),
{ onProgress: done => setRoadDone(done) },
)
set({ roads } as Partial<BookElement>)
} finally {
setRoadState('idle')
}
}
const sources = useMapSources(el.frame, el.points, { source: el.source, tileUrl: el.tileUrl })
/** Only trips that put stops on the route: a map of the others is a blank. */
const tripsWithRoute = (stats?.trips ?? []).filter(t => t.points > 0)
/*
* The grid this element will really fetch, asked for here so the panel can
* say what it will print at. Same call the renderer makes, same answer.
*/
const view = el.source === 'tiles' && el.tileUrl
? tileView(el.points, { w: el.frame.w, h: el.frame.h }, el.tileUrl, el.zoom, undefined, 'print', cartoKey)
: null
const dpi = view ? printDpi(view, el.tileUrl) : null
return (
<>
{/*
Which trip, when the journey is made of more than one.
A journey of two trips drawn as one route puts a line from the last
stop of the first to the first stop of the second: usually the longest
leg on the page, and one nobody travelled. Switching here re-cuts the
route on the spot rather than at the next refresh, because a control
that changes nothing visible reads as broken.
*/}
{tripsWithRoute.length > 1 && (
<Section label={t('journey.studio.mapScope')}>
<Choice
value={el.tripId ?? 0}
options={[
{ value: 0, label: t('journey.studio.mapWholeJourney') },
...tripsWithRoute.map(trip => ({
value: trip.id,
label: trip.title || t('journey.studio.untitled'),
})),
]}
onPick={id => {
if (!stats) return
const tripId = id === 0 ? null : id
const route = routeFor(stats, tripId)
set({
tripId,
countries: tripId == null
? stats.countries.map(c => c.code)
: [...new Set(route.map(p => p.country).filter((c): c is string => !!c))],
points: route.map(p => ({ lat: p.lat, lng: p.lng, label: p.label, photoId: p.photoId ?? null })),
} as Partial<BookElement>)
}}
/>
</Section>
)}
{/* Where the picture comes from and what it looks like: one question
with two halves, which is one group rather than two. */}
<Section label={t('journey.studio.routeMap')}>
<Line label={t('journey.studio.mapSource')} wide>
<Choice
value={sourceIdOf(el, sources)}
options={sources.map(src => ({ value: src.id, label: t(src.labelKey) }))}
onPick={id => {
const src = sources.find(x => x.id === id)
if (!src) return
set({
source: src.source,
tileUrl: src.url,
// The credit travels with the source. Switching to imagery and
// leaving the previous attribution would print the wrong one.
attribution: src.attribution,
} as Partial<BookElement>)
}}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.style')} wide>
<Choice
value={el.style}
options={(['minimal', 'outline', 'paper', 'dark'] as const).map(style => ({
value: style, label: t(`journey.studio.mapStyle.${style}`),
}))}
onPick={style => set({ style } as Partial<BookElement>)}
/>
</Line>
</div>
{el.source !== 'vector' && (
<>
<p className="st-hint" style={{ paddingTop: 8 }}>
{t(SOURCE_HINT[sourceIdOf(el, sources)] ?? 'journey.studio.mapSourceHint')}
</p>
{/*
What this map will actually print at, at the size it is now.
A number rather than a warning icon: 300 means nothing to argue
with, and somebody who wants a soft map on purpose can have one.
*/}
{dpi != null && (
<p className={`st-hint ${dpi < 150 ? 'st-stale' : ''}`} style={{ paddingTop: 6 }}>
{t('journey.studio.mapPrintDpi')} {dpi} dpi
{dpi < 150 && ` — ${t('journey.studio.mapPrintDpiLow')}`}
</p>
)}
</>
)}
</Section>
{/*
── What the map shows ─────────────────────────────────────────────
Three questions, and they are not the same question: how close the view
is, how much air is left around it, and what shape the picture is cut
to. The first two used to be decided for you, and the decision was
wrong often enough to be the loudest complaint about this element: a
trip that stayed inside one city was drawn as the whole country with
two dots in the middle of it.
*/}
<Section label={t('journey.studio.mapFraming')}>
<Line label={t('journey.studio.mapFit')}>
<Choice
value={el.fitToCountries ? 'country' : 'stops'}
options={[
{ value: 'stops' as const, label: t('journey.studio.mapFitStops') },
{ value: 'country' as const, label: t('journey.studio.mapFitCountry') },
]}
onPick={v => set({ fitToCountries: v === 'country' } as Partial<BookElement>)}
/>
</Line>
{/*
Room around what is drawn, as a share of it: the same setting has to
look right around a walk across a city and a drive across a
continent, and millimetres cannot do that.
*/}
<Line label={t('journey.studio.mapPadding')}>
<Choice
value={([0.04, 0.18, 0.5, 1.2] as const).find(v => Math.abs(el.fitPadding - v) < 0.001) ?? 0.18}
options={[
{ value: 0.04, label: t('journey.studio.mapPadTight') },
{ value: 0.18, label: t('journey.studio.mapPadNormal') },
{ value: 0.5, label: t('journey.studio.mapPadWide') },
{ value: 1.2, label: t('journey.studio.mapPadFar') },
]}
onPick={fitPadding => set({ fitPadding } as Partial<BookElement>)}
/>
</Line>
{/*
And the shape. A rectangle is a map in a box, which is what a full
page wants; cut to the coastline it stops being a figure and becomes
an illustration that can sit next to anything.
*/}
<Line label={t('journey.studio.mapShape')}>
<Choice
value={el.clip}
options={[
{ value: 'rect' as const, label: t('journey.studio.mapClipRect') },
{
value: 'country' as const,
label: t('journey.studio.mapClipCountry'),
disabled: el.countries.length === 0,
title: el.countries.length === 0 ? t('journey.studio.mapClipNeedsCountry') : undefined,
},
]}
onPick={clip => set({ clip } as Partial<BookElement>)}
/>
</Line>
</Section>
{el.source === 'tiles' && (
<Section label={t('journey.studio.mapZoom')} defaultOpen={false}>
<Choice
value={el.zoom ?? -1}
options={[
{ value: -1, label: t('journey.studio.mapZoomAuto') },
...[3, 5, 7, 9, 11, 13, 15, 17].map(z => ({ value: z, label: String(z) })),
]}
onPick={z => set({ zoom: z === -1 ? null : z } as Partial<BookElement>)}
/>
</Section>
)}
{/*
How the line itself is drawn, and whether it follows real roads.
Open by default, unlike the groups below it. It was folded, on the
reasoning that a look is chosen once per book — but a control nobody can
see is a control nobody has, and the first thing anyone wants from a map
of a journey is the line across it.
*/}
<Section label={t('journey.studio.routeLook')}>
<Line label={t('journey.studio.routeStyle')} wide>
<Choice
value={el.routeStyle}
options={[
{ value: 'plain' as const, label: t('journey.studio.routePlain') },
{ value: 'drawn' as const, label: t('journey.studio.routeDrawn') },
]}
onPick={routeStyle => set({ routeStyle } as Partial<BookElement>)}
/>
</Line>
{/*
The roads, fetched once and kept.
Not a switch, because it is not free: it asks a public routing service
one question per leg, from this browser, and the answer is then frozen
into the document so the book prints the same line offline and next
year. A switch would imply it could simply be turned back on later,
on a page that is being exported, which is exactly when it cannot.
*/}
<div style={{ marginTop: 10 }}>
{/*
Which way the line goes between two stops.
A choice rather than a button, because that is what it is from where
the user sits: the roads, or the way a map has always drawn a leg it
does not know. That the roads have to be fetched once and are then
kept in the book is an implementation detail, and the only place it
shows is the counter while it works.
*/}
<Line label={t('journey.studio.roads')} wide>
{roadState === 'busy' ? (
<div className="st-row">
<span className="st-chip is-on">
{t('journey.studio.roadsBusy')} {roadDone}/{Math.max(1, el.points.length - 1)}
</span>
</div>
) : (
<Choice
value={hasRoads ? 'roads' : 'direct'}
options={[
{ value: 'roads' as const, label: t('journey.studio.roadsFollow') },
{ value: 'direct' as const, label: t('journey.studio.roadsDirect') },
]}
onPick={v => {
if (v === 'direct') {
set({ roads: [] } as Partial<BookElement>)
} else {
void loadRoads()
}
}}
/>
)}
</Line>
{hasRoads && roadState !== 'busy' && (
<div className="st-row" style={{ marginTop: 8 }}>
<button type="button" className="st-chip" onClick={() => void loadRoads()}>
{t('journey.studio.roadsAgain')}
</button>
</div>
)}
<p className="st-hint" style={{ paddingTop: 6 }}>
{t(hasRoads ? 'journey.studio.roadsHave' : 'journey.studio.roadsHint')}
</p>
</div>
{el.routeStyle === 'drawn' && (
<>
<Line label={t('journey.studio.routeArc')} wide>
<Choice
value={el.routeArc}
options={[
{ value: 'straight' as const, label: t('journey.studio.routeStraight') },
{ value: 'bow' as const, label: t('journey.studio.routeBow') },
]}
onPick={routeArc => set({ routeArc } as Partial<BookElement>)}
/>
</Line>
{el.routeArc === 'bow' && (
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.routeDashArcs')}
on={el.routeDash === 'arcs'}
onToggle={() => set({
routeDash: el.routeDash === 'arcs' ? 'solid' : 'arcs',
} as Partial<BookElement>)}
/>
</div>
)}
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.mapStops')} wide>
<Choice
value={el.pinStyle}
options={[
{ value: 'dot' as const, label: t('journey.studio.pinDot') },
{ value: 'photo' as const, label: t('journey.studio.pinPhoto') },
]}
onPick={pinStyle => set({ pinStyle } as Partial<BookElement>)}
/>
</Line>
</div>
{el.pinStyle === 'photo' && !el.points.some(p => p.photoId != null) && (
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.pinPhotoNone')}</p>
)}
</>
)}
</Section>
{/* Four things that are either drawn or not. They used to be the same
pill as the style choice above, so a layer looked like an option. */}
<Section label={t('journey.studio.mapLayers')}>
{el.source !== 'tiles' && el.source !== 'static' && (
<Switch label={t('journey.studio.showLand')} on={el.showLand} onToggle={() => set({ showLand: !el.showLand } as Partial<BookElement>)} />
)}
<Switch label={t('journey.studio.showRoute')} on={el.showRoute} onToggle={() => set({ showRoute: !el.showRoute } as Partial<BookElement>)} />
<Switch label={t('journey.studio.showPins')} on={el.showPins} onToggle={() => set({ showPins: !el.showPins } as Partial<BookElement>)} />
<Switch label={t('journey.studio.showLabels')} on={el.showLabels} onToggle={() => set({ showLabels: !el.showLabels } as Partial<BookElement>)} />
</Section>
</>
)
}
function StatsProps({ el, set, t }: Props<BookStatsElement>) {
const toggleMetric = (m: BookMetric) => {
const next = el.metrics.includes(m) ? el.metrics.filter(x => x !== m) : [...el.metrics, m]
// An empty panel would be a blank rectangle nobody could get back out of,
// so the last figure cannot be switched off.
if (!next.length) return
set({ metrics: next } as Partial<BookElement>)
}
return (
<>
{/*
Which figures, out of seven. A set rather than a row of choices, and it
looks like one now: the picked ones carry a tick instead of being the
same filled pill the layout below uses for "this one".
*/}
<Section label={t('journey.studio.metrics')}>
<Picks
value={el.metrics}
options={BOOK_METRICS.map(m => ({ value: m, label: t(`journey.studio.metric.${m}`) }))}
onToggle={toggleMetric}
/>
</Section>
<Section label={t('journey.studio.layout')}>
<Line label={t('journey.studio.layout')}>
<Choice
value={el.layout}
options={(['grid', 'row', 'column'] as const).map(layout => ({
value: layout,
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
}))}
onPick={layout => set({ layout } as Partial<BookElement>)}
/>
</Line>
<Line label={t('journey.studio.units')}>
<Choice
value={el.units}
options={[
{ value: 'metric' as const, label: 'km' },
{ value: 'imperial' as const, label: 'mi' },
]}
onPick={units => set({ units } as Partial<BookElement>)}
/>
</Line>
<div style={{ marginTop: 10 }}>
{/* Its label used to be "Elements", which is the name of a panel on
the other side of the screen and not what this draws. */}
<Switch
label={t('journey.studio.showIcons')}
on={el.showIcons}
onToggle={() => set({ showIcons: !el.showIcons } as Partial<BookElement>)}
/>
</div>
</Section>
</>
)
}
function CountriesProps({ el, set, t }: Props<BookCountriesElement>) {
return (
<Section label={t('journey.studio.countries')}>
<Line label={t('journey.studio.layout')}>
<Choice
value={el.layout}
options={(['list', 'grid', 'column'] as const).map(layout => ({
value: layout,
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
}))}
onPick={layout => set({ layout } as Partial<BookElement>)}
/>
</Line>
{/* These three read as raw English out of the code before: `left`,
`center`, `right`, straight from the value. */}
<Line label={t('journey.studio.align')}>
<Choice
value={el.align}
options={(['left', 'center', 'right'] as const).map(align => ({
value: align, label: t(`journey.studio.align.${align}`),
}))}
onPick={align => set({ align } as Partial<BookElement>)}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Switch label={t('journey.studio.showOutline')} on={el.showOutline} onToggle={() => set({ showOutline: !el.showOutline } as Partial<BookElement>)} />
<Switch label={t('journey.studio.showFlag')} on={el.showFlag} onToggle={() => set({ showFlag: !el.showFlag } as Partial<BookElement>)} />
<Switch label={t('journey.studio.showName')} on={el.showName} onToggle={() => set({ showName: !el.showName } as Partial<BookElement>)} />
</div>
{/*
The names themselves, one per line.
They arrive resolved from the journey and are usually right, but "usually"
is not a thing you can print: a country whose English name nobody uses,
a territory the geocoder names differently from the way the trip talked
about it, a line that wants to read "Scotland" rather than "United
Kingdom". A textarea rather than a field per country because the list is
two names on one page and twenty on another, and lines are how anyone
already edits a list.
Editing only the names is deliberate: the codes stay as they were, so the
outlines and flags keep matching the places actually visited.
*/}
{el.showName && (
<label className="st-field" style={{ marginTop: 10 }}>
<span>{t('journey.studio.countryNames')}</span>
<textarea
className="st-input st-textarea"
rows={Math.min(8, Math.max(2, el.codes.length))}
value={el.names.join('\n')}
onChange={e => {
const lines = e.target.value.split('\n')
// One name per country, in the order they were visited: a removed
// line must not shift every name after it onto the wrong outline.
set({
names: el.codes.map((code, i) => (lines[i] ?? el.names[i] ?? code).slice(0, 80)),
} as Partial<BookElement>)
}}
/>
</label>
)}
</Section>
)
}
/** What each imagery source is for, said once where it is chosen. */
const SOURCE_HINT: Record<string, string> = {
relief: 'journey.studio.mapSourceReliefHint',
satellite: 'journey.studio.mapSourceSatelliteHint',
}
/** The variants that draw something as well as saying something. */
const ICON_VARIANTS = ['flag', 'country', 'mood', 'weather']
function BadgeProps({ el, set, t }: Props<BookBadgeElement>) {
const showIcon = el.showIcon !== false
const showLabel = el.showLabel !== false
const parts = (icon: boolean, label: boolean) =>
set({ showIcon: icon, showLabel: label } as Partial<BookElement>)
return (
<>
<Section label={t('journey.studio.marks')}>
<Line label={t('journey.studio.text')}>
<input
className="st-input"
value={el.text}
onChange={e => set({ text: e.target.value } as Partial<BookElement>)}
/>
</Line>
<Line label={t('journey.studio.styleCaption')}>
<input
className="st-input"
value={el.sub}
onChange={e => set({ sub: e.target.value } as Partial<BookElement>)}
/>
</Line>
{/* These four printed the raw value — `plain`, `chip` — in every one of
the twenty-three languages. */}
<Line label={t('journey.studio.style')} wide>
<Choice
value={el.style}
options={(['plain', 'chip', 'outline', 'stacked'] as const).map(style => ({
value: style, label: t(`journey.studio.markStyle.${style}`),
}))}
onPick={style => set({ style } as Partial<BookElement>)}
/>
</Line>
</Section>
{/*
The icon, for the marks that have one.
Three combinations rather than two switches: a mark with neither its
picture nor its words is an empty box, and offering it as a state people
can reach means offering them a way to lose an element on the page.
*/}
{ICON_VARIANTS.includes(el.variant) && (
<Section label={t('journey.studio.icon')}>
<Line label={t('journey.studio.shows')} wide>
<Choice
value={showIcon && showLabel ? 'both' : showIcon ? 'icon' : 'label'}
options={[
{ value: 'both' as const, label: t('journey.studio.iconAndLabel') },
{ value: 'icon' as const, label: t('journey.studio.iconOnly') },
{ value: 'label' as const, label: t('journey.studio.labelOnly') },
]}
onPick={v => parts(v !== 'label', v !== 'icon')}
/>
</Line>
{showIcon && (
<>
{/*
A mood's icon carries the journal's own colour and the others
carry the element's accent — which is right until it is not, and
until now there was no way past it: the swatch below is the only
thing that reaches the icon, because lucide takes its colour as a
prop rather than inheriting it.
*/}
<Line label={t('journey.studio.colour')} wide>
<Swatches
value={el.iconColor}
onPick={c => set({ iconColor: c, autoIconColor: false } as Partial<BookElement>)}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.autoColour')}
on={el.autoIconColor !== false}
onToggle={() => set({ autoIconColor: el.autoIconColor === false } as Partial<BookElement>)}
/>
</div>
</>
)}
</Section>
)}
</>
)
}