fix: clear every open Sonar security issue, and half the reliability ones (#2037)

* fix(security): mint idempotency keys properly, and resolve the extractor by path

Three findings out of a sweep of every open Sonar security issue. The other 49
turned out to be false positives and are documented in sonar-project.properties
rather than changed — each entry scoped to a file, never a whole rule, so a
future Math.random() on an auth path still fails.

The idempotency key is the one that can lose data. Both producers fell back to
Math.random when crypto.randomUUID was missing, and randomUUID needs a secure
context — TREK's own quickstart is http://localhost:3000 and LAN installs run on
http://192.168.x.x, so on the deployments the README documents the fallback is
what actually runs. The axios one was
`Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a
single character and 0 yields the empty string, which the interceptor reads as
"no key" and skips deduplication for entirely. Two writes sharing a key collapse
into one — the second gets the first one's cached response and the edit is gone
with no error anywhere. Both now come from utils/randomId, which has the
getRandomValues rung that works in insecure contexts too.

The extractor probe stored the bare binary name, so every extraction re-resolved
it through whatever PATH held at that moment. The official image sets
KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install
without it ran an unqualified name from a PATH it does not control. It now walks
the search path itself and keeps the absolute path of the file it verified;
execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through
app-config because process.env is walled off in src/.

* fix: four tests and sorts that were not doing what they claimed

Sonar reported 43 high-impact reliability issues. Reading all of them, four are
real; the rest are cosmetic or would be made worse by the suggested change.

budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid
? 1 : 0` and the handler stores the value verbatim — the test asserted a payload
the server never sends. The line below it already used a number.

admin.controller.test.ts had a `.resolves` assertion with no await. The promise
was dropped, so that line could not fail the test.

The two to-do category lists sorted with a bare .sort(), which is byte order:
every accented name files behind the whole ASCII range, so in a German trip
"Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data.
One side effect worth knowing: katColor picks a category's dot colour by its
index, so re-sorting can move which category shows which colour — it already
moves whenever a category is added, and the colour carries no meaning.

Deliberately not changed: the two `| 0` truncations (Math.trunc is not
equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the
two .map() callbacks whose second parameter is declared and used, and the sha1
in thumbnail.service (its own comment says a swap would orphan every existing
thumbnail).

* refactor: give every button a type, and prefer the Number statics

Two mechanical sweeps, no behaviour change intended in either.

A <button> with no type attribute is type="submit" per the HTML spec. Outside a
form that does nothing today, but it turns into a surprise the moment the markup
is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get
an explicit type="button". Two things were left alone on purpose: buttons that
take a {...spread}, because the spread may carry a type and the merge order
decides which wins, and anything inside a <form> — there the answer is a
judgement call, and checking all eleven form files found every real submit button
already carrying type="submit".

The Number sweep covers only the three globals that are exactly equivalent to
their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global
coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false,
and renaming it would change what the code does. It reaches server and plugin-sdk
as well as the client; both copies of egress-policy.ts got the identical edit, so
the mirror the SDK's parity test guards stays intact.
This commit is contained in:
Maurice
2026-08-25 15:22:38 +02:00
committed by GitHub
parent 7da0080557
commit 2c2d008dc2
239 changed files with 1303 additions and 1097 deletions
@@ -33,10 +33,10 @@ export function SaveIndicator({
pretending otherwise would be worse than saying so: one of these
documents is going to be the book.
*/}
<button onClick={() => onAcceptTheirs(state.current)}>
<button type="button" onClick={() => onAcceptTheirs(state.current)}>
{t('journey.studio.saveTakeTheirs')}
</button>
<button onClick={() => onKeepMine(state.current)}>
<button type="button" onClick={() => onKeepMine(state.current)}>
{t('journey.studio.saveKeepMine')}
</button>
</div>
@@ -59,7 +59,7 @@ export function SaveIndicator({
if (state.status === 'error') {
return (
<button className="st-save is-error" onClick={onRetry} title={t('journey.studio.saveRetry')}>
<button type="button" className="st-save is-error" onClick={onRetry} title={t('journey.studio.saveRetry')}>
<CloudOff size={13} />
<span>{t('journey.studio.saveFailed')}</span>
</button>
+1 -1
View File
@@ -38,7 +38,7 @@ const round2 = (n: number) => Math.round(n * 100) / 100
/** #rrggbb plus an alpha, for the fades a cover panel needs. */
function hexToRgba(hex: string, alpha: number): string {
const n = parseInt(hex.slice(1), 16)
const n = Number.parseInt(hex.slice(1), 16)
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
}
@@ -394,15 +394,15 @@ export function StudioCanvas({
style={{ left: (x0 + x1) / 2, top: Math.max(6, y0 - 12) }}
onPointerDown={e => e.stopPropagation()}
>
<button onClick={() => duplicate(spreadIndex, selection)} title={t('journey.studio.duplicate')}>
<button type="button" onClick={() => duplicate(spreadIndex, selection)} title={t('journey.studio.duplicate')}>
<Copy size={14} />
</button>
{sel.length === 1 && (
<>
<button onClick={() => raise(spreadIndex, sel[0].id, 'front')} title={t('journey.studio.toFront')}>
<button type="button" onClick={() => raise(spreadIndex, sel[0].id, 'front')} title={t('journey.studio.toFront')}>
<ChevronsUp size={14} />
</button>
<button onClick={() => raise(spreadIndex, sel[0].id, 'back')} title={t('journey.studio.toBack')}>
<button type="button" onClick={() => raise(spreadIndex, sel[0].id, 'back')} title={t('journey.studio.toBack')}>
<ChevronsDown size={14} />
</button>
</>
@@ -417,13 +417,13 @@ export function StudioCanvas({
a rotation past the angle you wanted and back is a worse way to
arrive at either.
*/}
<button
<button type="button"
onClick={e => rotateBy(e.shiftKey ? -1 : -15)}
title={t('journey.studio.rotateLeft')}
>
<RotateCcw size={14} />
</button>
<button
<button type="button"
onClick={e => rotateBy(e.shiftKey ? 1 : 15)}
title={t('journey.studio.rotateRight')}
>
@@ -443,7 +443,7 @@ export function StudioCanvas({
{locked ? <Unlock size={14} /> : <Lock size={14} />}
</button>
<span className="st-quickbar-sep" />
<button
<button type="button"
className="is-danger"
onClick={() => { const ids = deletable(spread, selection); if (ids.length) removeElements(spreadIndex, ids) }}
title={t('journey.studio.delete')}
@@ -46,7 +46,7 @@ export function Section({ label, children, defaultOpen = true, hint }: {
const [open, setOpen] = useState(defaultOpen)
return (
<div className={`st-section is-fold ${open ? '' : 'is-folded'}`}>
<button className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}>
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}>
<span className="st-section-label">{label}</span>
<ChevronDown size={13} className="st-section-caret" />
</button>
@@ -85,7 +85,7 @@ export function Choice<T extends string | number>({ value, options, onPick, aria
return (
<div className="st-row" role="radiogroup" aria-label={ariaLabel}>
{options.map(o => (
<button
<button type="button"
key={String(o.value)}
role="radio"
aria-checked={o.value === value}
@@ -118,7 +118,7 @@ export function Picks<T extends string>({ value, options, onToggle }: {
{options.map(o => {
const on = value.includes(o.value)
return (
<button
<button type="button"
key={o.value}
role="checkbox"
aria-checked={on}
@@ -81,7 +81,7 @@ function FrameStyleGlyph({ style }: { style: FrameStyle }) {
function IconCell({ name, onPick }: { name: string; onPick: (name: string) => void }) {
const Icon = iconComponent(name)
return (
<button
<button type="button"
className="st-shape-btn is-glyph"
onClick={() => onPick(name)}
aria-label={name}
@@ -216,16 +216,16 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
<PanelHead label={t('journey.studio.elements')} />
<div className="st-tabs">
<button className={tab === 'shapes' ? 'is-on' : ''} onClick={() => setTab('shapes')}>
<button type="button" className={tab === 'shapes' ? 'is-on' : ''} onClick={() => setTab('shapes')}>
{t('journey.studio.shapes')}
</button>
<button className={tab === 'frames' ? 'is-on' : ''} onClick={() => setTab('frames')}>
<button type="button" className={tab === 'frames' ? 'is-on' : ''} onClick={() => setTab('frames')}>
{t('journey.studio.frames')}
</button>
<button className={tab === 'grids' ? 'is-on' : ''} onClick={() => setTab('grids')}>
<button type="button" className={tab === 'grids' ? 'is-on' : ''} onClick={() => setTab('grids')}>
{t('journey.studio.grids')}
</button>
<button className={tab === 'icons' ? 'is-on' : ''} onClick={() => setTab('icons')}>
<button type="button" className={tab === 'icons' ? 'is-on' : ''} onClick={() => setTab('icons')}>
{t('journey.studio.icons')}
</button>
</div>
@@ -236,18 +236,18 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
<div className="st-section">
<div className="st-section-label">{t('journey.studio.text')}</div>
<div className="st-stack">
<button className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
<button type="button" className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
{t('journey.studio.styleTitle')}
</span>
</button>
<button className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
<button type="button" className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
</button>
<button className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
<button type="button" className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
</button>
<button
<button type="button"
className="st-tile"
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
>
@@ -261,17 +261,17 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
<div className="st-section">
<div className="st-section-label">{t('journey.studio.lines')}</div>
<div className="st-shape-grid">
<button className="st-shape-btn" onClick={() => addShape('line')} title={t('journey.studio.shapeKind.line')}>
<button type="button" className="st-shape-btn" onClick={() => addShape('line')} title={t('journey.studio.shapeKind.line')}>
<Minus size={20} strokeWidth={2} />
</button>
<button
<button type="button"
className="st-shape-btn"
onClick={() => addShape('rect', { outline: true })}
title={t('journey.studio.shapeKind.outline')}
>
<Square size={20} strokeWidth={1.1} style={{ opacity: .5 }} />
</button>
<button
<button type="button"
className="st-shape-btn"
onClick={() => addText(46, 700, '“', { color: '#c9c2b4', leading: 0.9 })}
title={t('journey.studio.quoteMark')}
@@ -286,7 +286,7 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
<div className="st-section-label">{t(`journey.studio.shapeGroup.${group.id}`)}</div>
<div className="st-shape-grid">
{group.shapes.map(shape => (
<button
<button type="button"
key={shape}
className="st-shape-btn is-glyph"
onClick={() => addShape(shape)}
@@ -314,7 +314,7 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
['film', 'filmFrame'],
['tape', 'tapeFrame'],
] as [FrameStyle, string][]).map(([style, key]) => (
<button
<button type="button"
key={style}
className="st-shape-btn is-frame"
onClick={() => addFrame(null, style)}
@@ -331,7 +331,7 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
<div className="st-section-label">{t('journey.studio.frameShapes')}</div>
<div className="st-shape-grid">
{FRAME_SHAPES.map(shape => (
<button
<button type="button"
key={shape}
className="st-shape-btn is-glyph"
onClick={() => addFrame(shape === 'rect' ? null : shape)}
@@ -408,7 +408,7 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
spellCheck={false}
/>
{iconQuery && (
<button onClick={() => { setIconQuery(''); setIconLimit(ICON_PAGE) }} aria-label={t('common.clear')}>
<button type="button" onClick={() => { setIconQuery(''); setIconLimit(ICON_PAGE) }} aria-label={t('common.clear')}>
<X size={13} />
</button>
)}
@@ -81,7 +81,7 @@ export function StudioExport({
<div className="st-ex-card">
<div className="st-ex-head">
<span>{t('journey.studio.export')}</span>
<button className="st-ex-x" onClick={onClose} aria-label={t('common.close')}>
<button type="button" className="st-ex-x" onClick={onClose} aria-label={t('common.close')}>
<X size={15} />
</button>
</div>
@@ -109,7 +109,7 @@ export function StudioExport({
<div className="st-ex-field">
<span className="st-ex-label">{t('journey.studio.exportFinishing')}</span>
<button
<button type="button"
className={`st-ex-opt ${marks ? 'is-on' : ''}`}
onClick={() => setMarks(!marks)}
aria-pressed={marks}
@@ -134,8 +134,8 @@ export function StudioExport({
</div>
<div className="st-ex-foot">
<button className="st-ex-btn" onClick={onClose}>{t('common.cancel')}</button>
<button className="st-ex-btn is-primary" onClick={() => setBuilding(true)} disabled={building}>
<button type="button" className="st-ex-btn" onClick={onClose}>{t('common.cancel')}</button>
<button type="button" className="st-ex-btn is-primary" onClick={() => setBuilding(true)} disabled={building}>
<Printer size={14} />
<span>{t('journey.studio.exportOpen')}</span>
</button>
@@ -176,7 +176,7 @@ function Choice<T extends string>({
<div className="st-ex-field">
<span className="st-ex-label">{label}</span>
{options.map(opt => (
<button
<button type="button"
key={opt.id}
className={`st-ex-opt ${value === opt.id ? 'is-on' : ''}`}
onClick={() => onPick(opt.id)}
@@ -204,7 +204,7 @@ export function StudioInspector({
{sel.length > 1 && <em>{sel.length}</em>}
</span>
<span className="st-head-acts">
<button
<button type="button"
className="st-act"
onClick={() => duplicate(spreadIndex, selection)}
title={t('journey.studio.duplicate')}
@@ -212,7 +212,7 @@ export function StudioInspector({
>
<Copy size={14} />
</button>
<button
<button type="button"
className={`st-act ${el.locked ? 'is-on' : ''}`}
onClick={() => set({ locked: !el.locked })}
title={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
@@ -221,7 +221,7 @@ export function StudioInspector({
>
{el.locked ? <Lock size={14} /> : <Unlock size={14} />}
</button>
<button
<button type="button"
className="st-act is-danger"
onClick={() => removeElements(spreadIndex, selection)}
title={t('journey.studio.delete')}
@@ -285,7 +285,7 @@ export function StudioInspector({
{BOOK_FONT_ORDER.map(id => {
const font = BOOK_FONTS[id]
return (
<button
<button type="button"
key={id}
className={`st-font ${el.font === id ? 'is-on' : ''}`}
style={{ fontFamily: font.stack }}
@@ -319,7 +319,7 @@ export function StudioInspector({
<Line label={t('journey.studio.align')}>
<div className="st-row">
{([['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ['justify', AlignJustify]] as const).map(([a, Icon]) => (
<button
<button type="button"
key={a}
className={`st-chip is-icon ${el.align === a ? 'is-on' : ''}`}
onClick={() => set({ align: a })}
@@ -331,7 +331,7 @@ export function StudioInspector({
{/* Italic sits with the alignment because both are things
you do to a line rather than to a typeface. It had no
control at all before, though the renderer drew it. */}
<button
<button type="button"
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
onClick={() => set({ italic: !el.italic })}
title={t('journey.studio.italic')}
@@ -450,7 +450,7 @@ export function StudioInspector({
<Section label={t('journey.studio.mask')} defaultOpen={false}>
<div className="st-mini-shapes">
<button
<button type="button"
className={`st-mini-shape ${el.mask ? '' : 'is-on'}`}
onClick={() => set({ mask: null })}
title={t('journey.studio.maskNone')}
@@ -458,7 +458,7 @@ export function StudioInspector({
<span className="st-mini-none" />
</button>
{FRAME_SHAPES.filter(sh => sh !== 'rect').map(sh => (
<button
<button type="button"
key={sh}
className={`st-mini-shape ${el.mask === sh ? 'is-on' : ''}`}
onClick={() => set({ mask: sh })}
@@ -480,7 +480,7 @@ export function StudioInspector({
deleting and re-adding would not. */}
<div className="st-mini-shapes">
{SHAPE_GROUPS.flatMap(g => g.shapes).map((sh: BookShapeId) => (
<button
<button type="button"
key={sh}
className={`st-mini-shape ${el.shape === sh ? 'is-on' : ''}`}
onClick={() => set({ shape: sh })}
@@ -608,7 +608,7 @@ export function StudioInspector({
{iconChoices.map(name => {
const Glyph = iconComponent(name)
return (
<button
<button type="button"
key={name}
className={`st-mini-shape ${el.name === name ? 'is-on' : ''}`}
onClick={() => set({ name })}
@@ -683,16 +683,16 @@ export function StudioInspector({
<Section label={t('journey.studio.arrange')} defaultOpen={false}>
<div className="st-row">
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'front')} title={t('journey.studio.toFront')}>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'front')} title={t('journey.studio.toFront')}>
<ChevronsUp size={14} />
</button>
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'up')} title={t('journey.studio.forward')}>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'up')} title={t('journey.studio.forward')}>
<ArrowUp size={14} />
</button>
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'down')} title={t('journey.studio.backward')}>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'down')} title={t('journey.studio.backward')}>
<ArrowDown size={14} />
</button>
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'back')} title={t('journey.studio.toBack')}>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'back')} title={t('journey.studio.toBack')}>
<ChevronsDown size={14} />
</button>
</div>
+15 -15
View File
@@ -46,7 +46,7 @@ export default function StudioShell() {
<div style={{ fontSize: 13.5, color: 'var(--ink-3)', maxWidth: 320, lineHeight: 1.55 }}>
{s.t('journey.studio.desktopOnlyHint')}
</div>
<button
<button type="button"
onClick={() => navigate(s.backTo, { replace: true })}
className="st-tool is-primary"
style={{ marginTop: 6 }}
@@ -122,7 +122,7 @@ function StudioBar({
}: { s: Studio; bookView: boolean; setBookView: (v: boolean) => void; onExport: () => void }) {
return (
<div className="st-bar">
<button onClick={s.close} className="st-back" title={s.t('journey.studio.backToJourney')}>
<button type="button" onClick={s.close} className="st-back" title={s.t('journey.studio.backToJourney')}>
<ArrowLeft size={16} />
{s.coverUrl
? <img src={s.coverUrl} alt="" className="st-cover" />
@@ -154,7 +154,7 @@ function StudioBar({
/>
<div className="st-bar-group">
<button
<button type="button"
className={`st-tool ${bookView ? 'is-on' : ''}`}
onClick={() => setBookView(!bookView)}
title={s.t('journey.studio.bookView')}
@@ -169,7 +169,7 @@ function StudioBar({
<div className="st-sep" />
<button
<button type="button"
className="st-tool is-icon"
disabled={!s.canUndo}
onClick={s.undo}
@@ -178,7 +178,7 @@ function StudioBar({
>
<Undo2 size={15} />
</button>
<button
<button type="button"
className="st-tool is-icon"
disabled={!s.canRedo}
onClick={s.redo}
@@ -191,7 +191,7 @@ function StudioBar({
<div className="st-sep" />
<AutoLayoutButton s={s} />
<button
<button type="button"
className="st-tool is-primary"
onClick={onExport}
disabled={!s.doc}
@@ -235,7 +235,7 @@ function AutoLayoutButton({ s }: { s: Studio }) {
return (
<div className="st-picker" ref={box}>
<button
<button type="button"
className="st-tool"
onClick={() => setOpen(o => !o)}
aria-haspopup="menu"
@@ -249,7 +249,7 @@ function AutoLayoutButton({ s }: { s: Studio }) {
{open && (
<div className="st-menu" role="menu">
<button
<button type="button"
className="st-menu-item"
role="menuitem"
disabled={!s.canRelayoutSpread}
@@ -262,7 +262,7 @@ function AutoLayoutButton({ s }: { s: Studio }) {
</span>
</span>
</button>
<button
<button type="button"
className="st-menu-item"
role="menuitem"
onClick={() => { s.relayoutBook(); setOpen(false) }}
@@ -312,7 +312,7 @@ function FormatPicker({ s }: { s: Studio }) {
return (
<div className="st-picker" ref={box}>
<button
<button type="button"
className="st-tool"
onClick={() => setOpen(o => !o)}
aria-haspopup="listbox"
@@ -333,7 +333,7 @@ function FormatPicker({ s }: { s: Studio }) {
const p = PAGE_PRESETS[id]
const on = id === s.preset
return (
<button
<button type="button"
key={id}
role="option"
aria-selected={on}
@@ -434,14 +434,14 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
</div>
<div className="st-zoom">
<button onClick={() => s.stepZoom(-1)} disabled={!s.canZoomOut} aria-label={s.t('journey.studio.zoomOut')}>
<button type="button" onClick={() => s.stepZoom(-1)} disabled={!s.canZoomOut} aria-label={s.t('journey.studio.zoomOut')}>
<Minus size={15} />
</button>
<span className="st-zoom-value">{s.zoomPercent}%</span>
<button onClick={() => s.stepZoom(1)} disabled={!s.canZoomIn} aria-label={s.t('journey.studio.zoomIn')}>
<button type="button" onClick={() => s.stepZoom(1)} disabled={!s.canZoomIn} aria-label={s.t('journey.studio.zoomIn')}>
<Plus size={15} />
</button>
<button onClick={s.zoomToFit} aria-label={s.t('journey.studio.zoomFit')} title={s.t('journey.studio.zoomFit')}>
<button type="button" onClick={s.zoomToFit} aria-label={s.t('journey.studio.zoomFit')} title={s.t('journey.studio.zoomFit')}>
<Maximize2 size={14} />
</button>
{/*
@@ -453,7 +453,7 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
photographs, which is the thing worth passing on.
*/}
<span className="st-zoom-sep" />
<button
<button type="button"
onClick={() => s.spread && downloadSpread(s.spread, s.page, s.journey?.title || 'spread')}
disabled={!s.spread}
aria-label={s.t('journey.studio.downloadSpread')}
+21 -21
View File
@@ -98,7 +98,7 @@ export function StudioSidebar({
<>
<nav className="st-rail" aria-label={t('journey.studio.sections')}>
{SECTIONS.map(sec => (
<button
<button type="button"
key={sec.id}
className={`st-rail-btn ${section === sec.id ? 'is-on' : ''}`}
onClick={() => setSection(sec.id)}
@@ -157,7 +157,7 @@ function PagesPanel({
const editable = canEditSpread(i)
return (
<div className="st-thumb-row" key={sp.id}>
<button
<button type="button"
className={`st-thumb ${i === active ? 'is-active' : ''}`}
onClick={() => setActive(i)}
>
@@ -190,7 +190,7 @@ function PagesPanel({
*/}
{editable && (
<div className="st-thumb-actions">
<button
<button type="button"
onClick={() => moveSpread(i, -1)}
disabled={!canEditSpread(i - 1)}
title={t('journey.studio.movePageUp')}
@@ -198,7 +198,7 @@ function PagesPanel({
>
<ChevronUp size={13} />
</button>
<button
<button type="button"
onClick={() => moveSpread(i, 1)}
disabled={!canEditSpread(i + 1)}
title={t('journey.studio.movePageDown')}
@@ -206,14 +206,14 @@ function PagesPanel({
>
<ChevronDown size={13} />
</button>
<button
<button type="button"
onClick={() => duplicateSpread(i)}
title={t('journey.studio.duplicatePage')}
aria-label={t('journey.studio.duplicatePage')}
>
<Copy size={12} />
</button>
<button
<button type="button"
className="is-danger"
onClick={() => removeSpread(i)}
title={t('journey.studio.deletePage')}
@@ -227,7 +227,7 @@ function PagesPanel({
{/* Insert between two spreads, where the new one will go — a
single "add" at the end cannot express "another page here". */}
{editable && (
<button
<button type="button"
className="st-thumb-insert"
onClick={() => addSpread(i)}
title={t('journey.studio.addPageAfter')}
@@ -247,11 +247,11 @@ function PagesPanel({
not a setting buried in a menu, it is the other half of "add".
*/}
<div className="st-add-row">
<button className="st-add-page" onClick={() => addSpread(lastInner)}>
<button type="button" className="st-add-page" onClick={() => addSpread(lastInner)}>
<Plus size={14} />
{t('journey.studio.addPage')}
</button>
<button
<button type="button"
className="st-add-page is-import"
onClick={() => fileInput.current?.click()}
title={t('journey.studio.importSpreadHint')}
@@ -429,17 +429,17 @@ function ContentPanel({
spellCheck={false}
/>
{query && (
<button onClick={() => setQuery('')} aria-label={t('common.clear')}>
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
<X size={13} />
</button>
)}
</div>
<div className="st-tabs">
<button className={tab === 'photos' ? 'is-on' : ''} onClick={() => setTab('photos')}>
<button type="button" className={tab === 'photos' ? 'is-on' : ''} onClick={() => setTab('photos')}>
{t('journey.studio.photos')} <em>{photos.length}</em>
</button>
<button className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
<button type="button" className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
{t('journey.studio.entries')} <em>{entries.length}</em>
</button>
</div>
@@ -448,7 +448,7 @@ function ContentPanel({
{tab === 'photos' ? (
<div className="st-photo-grid">
{photos.map(p => (
<button
<button type="button"
key={p.photoId}
className="st-photo-cell"
onClick={() => dropPhoto(p.photoId)}
@@ -488,17 +488,17 @@ function ContentPanel({
)}
<div className="st-row" style={{ marginTop: 7 }}>
{e.title && (
<button className="st-chip" onClick={() => dropText(e.title!, 22, 700, e.id, 'entry.title')}>
<button type="button" className="st-chip" onClick={() => dropText(e.title!, 22, 700, e.id, 'entry.title')}>
{t('journey.studio.addTitle')}
</button>
)}
{e.story && (
<button className="st-chip" onClick={() => dropText(e.story!, 10, 400, e.id, 'entry.story')}>
<button type="button" className="st-chip" onClick={() => dropText(e.story!, 10, 400, e.id, 'entry.story')}>
{t('journey.studio.addStory')}
</button>
)}
{e.location && (
<button className="st-chip" onClick={() => dropText(e.location!, 8, 700, e.id, 'entry.location')}>
<button type="button" className="st-chip" onClick={() => dropText(e.location!, 8, 700, e.id, 'entry.location')}>
{t('journey.studio.addPlace')}
</button>
)}
@@ -508,7 +508,7 @@ function ContentPanel({
mark placed here and one the auto layout placed read alike.
*/}
{e.date && (
<button
<button type="button"
className="st-chip"
onClick={() => dropText(
formatBookDate(e.date!, locale), 8, 700, e.id, 'entry.date',
@@ -519,7 +519,7 @@ function ContentPanel({
</button>
)}
{e.lat != null && e.lng != null && (
<button
<button type="button"
className="st-chip"
onClick={() => dropText(
formatBookCoords(e.lat!, e.lng!, 'dms'), 8, 700, e.id, 'entry.location',
@@ -535,7 +535,7 @@ function ContentPanel({
what you are about to place; "Weather" does not.
*/}
{e.mood && MOOD_CONFIG[e.mood] && (
<button
<button type="button"
className="st-chip"
onClick={() => dropMark('mood', e.mood!, t(MOOD_CONFIG[e.mood!].label))}
>
@@ -543,7 +543,7 @@ function ContentPanel({
</button>
)}
{e.weather && WEATHER_CONFIG[e.weather] && (
<button
<button type="button"
className="st-chip"
onClick={() => dropMark('weather', e.weather!, t(WEATHER_CONFIG[e.weather!].label))}
>
@@ -551,7 +551,7 @@ function ContentPanel({
</button>
)}
{(e.pros.length > 0 || e.cons.length > 0) && (
<button className="st-chip" onClick={() => dropProsCons(e.pros, e.cons)}>
<button type="button" className="st-chip" onClick={() => dropProsCons(e.pros, e.cons)}>
{t('journey.studio.addProsCons')}
<em>{e.pros.length + e.cons.length}</em>
</button>
@@ -20,7 +20,7 @@ export function Swatches({ value, onPick }: { value: string; onPick: (c: string)
return (
<div className="st-swatches">
{SWATCHES.map(c => (
<button
<button type="button"
key={c}
className={`st-swatch ${value.toLowerCase() === c ? 'is-on' : ''}`}
style={{ background: c }} // theme-lint-disable — a colour picker shows the colours themselves
@@ -52,7 +52,7 @@ export function TravelInspector({ el, stats, set, t }: TravelInspectorProps) {
{stale && (
<div className="st-section">
<p className="st-stale">{t('journey.studio.staleHint')}</p>
<button
<button type="button"
className="st-chip"
onClick={() => {
if (!stats) return
@@ -85,7 +85,7 @@ export function TravelInspector({ el, stats, set, t }: TravelInspectorProps) {
{BOOK_FONT_ORDER.map(id => {
const font = BOOK_FONTS[id]
return (
<button
<button type="button"
key={id}
className={`st-font ${el.font === id ? 'is-on' : ''}`}
style={{ fontFamily: font.stack }}
@@ -480,7 +480,7 @@ function MapProps({ el, set, t, stats }: Props<BookMapElement> & { stats: Journe
</Line>
{hasRoads && roadState !== 'busy' && (
<div className="st-row" style={{ marginTop: 8 }}>
<button className="st-chip" onClick={() => void loadRoads()}>
<button type="button" className="st-chip" onClick={() => void loadRoads()}>
{t('journey.studio.roadsAgain')}
</button>
</div>
@@ -68,7 +68,7 @@ function Card({ label, onClick, children, recommended = false, title }: {
children: React.ReactNode
}) {
return (
<button
<button type="button"
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
onClick={onClick}
title={title ? `${label} — ${title}` : label}
@@ -100,7 +100,7 @@ const tileOverlapMm = (size: number) => Math.max(0.8, size * 0.04)
/** #rrggbb plus an alpha. */
function rgba(hex: string, alpha: number): string {
const n = parseInt(hex.slice(1), 16)
const n = Number.parseInt(hex.slice(1), 16)
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
}
+1 -1
View File
@@ -49,7 +49,7 @@ export interface FolioInk {
export function brightness(hex: string): number {
const clean = hex.replace('#', '')
const full = clean.length === 3 ? clean.split('').map(c => c + c).join('') : clean
const n = parseInt(full.slice(0, 6), 16)
const n = Number.parseInt(full.slice(0, 6), 16)
if (!Number.isFinite(n)) return 1
const r = (n >> 16) & 255
const g = (n >> 8) & 255