diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx
index 42a7991da..62df37d91 100644
--- a/client/src/components/Collections/CollectionPlaceDetail.tsx
+++ b/client/src/components/Collections/CollectionPlaceDetail.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
+import { markdownLinkComponents } from '../shared/markdownLink'
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2 } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared'
import type { Category, TranslationFn } from '../../types'
@@ -306,7 +307,7 @@ export default function CollectionPlaceDetail({
)}
{place.description && (
- {place.description}
+ {place.description}
)}
{place.links && place.links.length > 0 && (
diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx
index f8fde0e19..789ee46c0 100644
--- a/client/src/components/PDF/TripPDF.tsx
+++ b/client/src/components/PDF/TripPDF.tsx
@@ -333,10 +333,14 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
if (item.type === 'note') {
const note = item.data
+ // A coloured note carries its colour into print the same way a
+ // reservation does (#1629) — the rule and the spine, not a fill,
+ // because a tint costs ink and reads as grey on a mono printer.
+ const noteColor = note.color || ''
return `
-
-
- ${noteIconSvg(note.icon)}
+
+
+ ${noteIconSvg(note.icon)}
${escHtml(note.text)}
${note.time ? `
${escHtml(note.time)}
` : ''}
diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx
index c23e760a4..c2ce18028 100644
--- a/client/src/components/Planner/DayPlanSidebar.test.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.test.tsx
@@ -1198,6 +1198,48 @@ describe('DayPlanSidebar', () => {
expect(mockDayNotesState.saveNote).toHaveBeenCalledWith(10)
})
+ // ── Note colours and formatting (#1629) ───────────────────────────────
+
+ it('FE-PLANNER-DAYPLAN-192: the note dialog offers the palette and reports the pick', async () => {
+ const user = userEvent.setup()
+ const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
+ mockDayNotesState.noteUi = {
+ '10': { mode: 'add', text: 'Passport', time: '', icon: 'StickyNote', color: null },
+ }
+ render()
+
+ // Exact, not /red/i: "Ordered list" in the formatting bar matches that too.
+ await user.click(screen.getByRole('button', { name: 'Red' }))
+
+ // The dialog owns no state of its own — it reports upwards, like every other field.
+ expect(mockDayNotesState.setNoteUi).toHaveBeenCalled()
+ })
+
+ it('FE-PLANNER-DAYPLAN-193: a note card is tinted by its colour and renders its body', () => {
+ const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
+ mockDayNotesState.dayNotes = {
+ '10': [{ id: 1, day_id: 10, text: 'Ferry', time: 'book **early**', icon: 'StickyNote', color: '#dc2626', sort_order: 0 }],
+ }
+ render()
+
+ // Markdown, not the asterisks someone typed with the formatting bar.
+ expect(screen.getByText('early').tagName).toBe('STRONG')
+ // jsdom normalises the hex inside color-mix() to rgb().
+ const card = screen.getByText('Ferry').closest('.dp-row') as HTMLElement
+ expect(card.style.background).toContain('220, 38, 38')
+ })
+
+ it('FE-PLANNER-DAYPLAN-194: a note without a colour keeps the neutral card', () => {
+ const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
+ mockDayNotesState.dayNotes = {
+ '10': [{ id: 2, day_id: 10, text: 'Lunch', time: '', icon: 'StickyNote', color: null, sort_order: 0 }],
+ }
+ render()
+
+ const card = screen.getByText('Lunch').closest('.dp-row') as HTMLElement
+ expect(card.style.background).toContain('--bg-hover')
+ })
+
// ── Note modal edit mode title ────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-061: note modal shows Edit title in edit mode', () => {
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index 06b6d137b..d5da0ec50 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -12,6 +12,7 @@ import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
+import remarkBreaks from 'remark-breaks'
import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -33,6 +34,8 @@ import { formatDate, formatTime, dayTotalCost, formatMoneySum, splitReservationD
import { useDayNotes } from '../../hooks/useDayNotes'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
+import { noteSurface } from './noteSurface'
+import { markdownLinkComponents } from '../shared/markdownLink'
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
@@ -1374,7 +1377,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null)
return (
-
+ {/* lineHeight 1, or the digit rides the line box's leading and
+ sits above centre on the plain badge — visible as soon as
+ there is no weather block under it to distract from it. */}
+
+ {/* A link in a note goes to its own tab, and remarkBreaks
+ keeps a single newline a line break — people write notes
+ as lines, not as Markdown paragraphs. */}
+ {note.time}
+
)}
- {canEditDays &&
-
-
-
}
- {canEditDays &&
-
-
-
}
+ {/* Stacked in a capsule of their own, set off from the text:
+ a note card is as tall as its body, so the height is free
+ while the width next to the text is not, and a 10px icon
+ in a 14px box was barely a target. */}
+ {/* Floated over the text rather than sharing the row with
+ it: a note is mostly text, and reserving a column for
+ two buttons that are invisible until you hover costs
+ every note a shorter line for the whole time you are
+ not hovering. */}
+ {canEditDays &&
+
+
+
}
+ {canEditDays &&
+
+
+
}
)
diff --git a/client/src/components/Planner/DayPlanSidebarNoteModal.tsx b/client/src/components/Planner/DayPlanSidebarNoteModal.tsx
index d244ae8b1..7a87b2a44 100644
--- a/client/src/components/Planner/DayPlanSidebarNoteModal.tsx
+++ b/client/src/components/Planner/DayPlanSidebarNoteModal.tsx
@@ -1,82 +1,188 @@
+import { useRef } from 'react'
import { createPortal } from 'react-dom'
-import { NOTE_ICONS } from './DayPlanSidebar.constants'
+import Markdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import remarkBreaks from 'remark-breaks'
+import { NOTE_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
+import NoteFormatToolbar from '../shared/NoteFormatToolbar'
+import NoteColorPicker from '../shared/NoteColorPicker'
+import { noteSurface } from './noteSurface'
+import { markdownLinkComponents } from '../shared/markdownLink'
interface NoteModalUi {
mode: 'add' | 'edit'
icon: string
text: string
time: string
+ color?: string | null
}
+type NoteUiMap = Record
+
interface DayPlanSidebarNoteModalProps {
- noteUi: Record
- setNoteUi: (updater: (prev: any) => any) => void
+ noteUi: NoteUiMap
+ setNoteUi: (updater: (prev: NoteUiMap) => NoteUiMap) => void
noteInputRef: React.RefObject
cancelNote: (dayId: number) => void
saveNote: (dayId: number) => void
- t: (key: string, params?: Record) => string
+ t: (key: string, params?: Record) => string
}
+const BODY_MAX = 2000
+
+/**
+ * Add/edit dialog for a day note (#1629).
+ *
+ * Two columns on anything wider than a phone: how the note looks on the left
+ * (icon, colour, and a preview of the card it will become), what it says on the
+ * right. The preview is the reason the colour picker is worth having — a swatch
+ * row tells you nothing about what a tinted card looks like next to a place.
+ */
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, t }: DayPlanSidebarNoteModalProps) {
return (
<>
{Object.entries(noteUi).map(([dayId, ui]) => ui && createPortal(
-
-
- {place.description || place.address || cat?.name}
-
+ {/* Rendered, like the same line in the day plan: the description is
+ Markdown everywhere else, and printing it raw here was the one
+ place a formatted place read as `_underscores_`. Still clamped to
+ one line — the row is a list entry, not the inspector. */}
+
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index c37d14f1a..24adba0d0 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -16,6 +16,7 @@ import { TransitTitle, TransitLegChips, TransitMetaBadges, fmtTransitDuration }
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
+import { markdownLinkComponents } from '../shared/markdownLink'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
import type { ViewContribution } from '../../api/client'
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
@@ -406,7 +407,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{t('reservations.notes')}
- {r.notes}
+ {r.notes}
)}
diff --git a/client/src/components/Planner/noteSurface.test.ts b/client/src/components/Planner/noteSurface.test.ts
new file mode 100644
index 000000000..d69611653
--- /dev/null
+++ b/client/src/components/Planner/noteSurface.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest'
+import { noteSurface } from './noteSurface'
+
+describe('noteSurface', () => {
+ it('FE-NOTESURFACE-001: no colour keeps the neutral card the notes have always had', () => {
+ const s = noteSurface(null)
+ expect(s.background).toBe('var(--bg-hover)')
+ expect(s.iconColor).toBe('var(--text-muted)')
+ // Nothing colour-derived leaks in: the neutral card must survive a theme swap.
+ expect(Object.values(s).every(v => !v.includes('color-mix'))).toBe(true)
+ })
+
+ it('FE-NOTESURFACE-002: empty string and undefined are "no colour", not a broken mix', () => {
+ expect(noteSurface('')).toEqual(noteSurface(null))
+ expect(noteSurface(undefined)).toEqual(noteSurface(null))
+ })
+
+ it('FE-NOTESURFACE-003: a colour tints the card and fills the icon with the colour itself', () => {
+ const s = noteSurface('#dc2626')
+ expect(s.iconColor).toBe('#dc2626')
+ // Mixed against transparent, not against a fixed white or grey — the card
+ // has to sit on whatever surface it lands on, in either theme.
+ expect(s.background).toBe('color-mix(in srgb, #dc2626 11%, transparent)')
+ expect(s.border).toBe('color-mix(in srgb, #dc2626 38%, transparent)')
+ expect(s.iconBackground).toBe('color-mix(in srgb, #dc2626 22%, transparent)')
+ })
+
+ it('FE-NOTESURFACE-004: the card stays a wash while the icon carries the full colour', () => {
+ const s = noteSurface('#2563eb')
+ const pct = (v: string) => Number(/(\d+)%/.exec(v)![1])
+ // Text is read on top of the background, so it must be the lightest of the three.
+ expect(pct(s.background)).toBeLessThan(pct(s.iconBackground))
+ expect(pct(s.iconBackground)).toBeLessThan(pct(s.border))
+ })
+})
diff --git a/client/src/components/Planner/noteSurface.ts b/client/src/components/Planner/noteSurface.ts
new file mode 100644
index 000000000..97862c1ca
--- /dev/null
+++ b/client/src/components/Planner/noteSurface.ts
@@ -0,0 +1,36 @@
+/**
+ * The four surfaces a note colour produces (#1629).
+ *
+ * One place decides how a colour becomes a card, so the preview in the dialog,
+ * the card in the day plan and the block in the inspector cannot drift apart.
+ *
+ * Tints rather than fills: a note sits in a list of places and must not shout
+ * louder than the trip itself, and body text has to stay readable on top. The
+ * percentages are `color-mix` against `transparent`, so the card picks up
+ * whatever surface it is dropped on and works in both themes without a second
+ * palette. `currentColor` is deliberately not used — the icon takes the full
+ * colour while the card behind it takes a wash of the same hue.
+ */
+export interface NoteSurface {
+ background: string
+ border: string
+ iconBackground: string
+ iconColor: string
+}
+
+const NEUTRAL: NoteSurface = {
+ background: 'var(--bg-hover)',
+ border: 'var(--border-faint)',
+ iconBackground: 'var(--bg-hover)',
+ iconColor: 'var(--text-muted)',
+}
+
+export function noteSurface(color: string | null | undefined): NoteSurface {
+ if (!color) return NEUTRAL
+ return {
+ background: `color-mix(in srgb, ${color} 11%, transparent)`,
+ border: `color-mix(in srgb, ${color} 38%, transparent)`,
+ iconBackground: `color-mix(in srgb, ${color} 22%, transparent)`,
+ iconColor: color,
+ }
+}
diff --git a/client/src/components/shared/NoteColorPicker.tsx b/client/src/components/shared/NoteColorPicker.tsx
new file mode 100644
index 000000000..81a3ece9f
--- /dev/null
+++ b/client/src/components/shared/NoteColorPicker.tsx
@@ -0,0 +1,65 @@
+import { Ban } from 'lucide-react'
+import { NOTE_COLORS } from '@trek/shared'
+import { useTranslation } from '../../i18n'
+
+interface Props {
+ /** The picked colour, or null for the neutral card. */
+ value: string | null
+ onChange: (color: string | null) => void
+}
+
+/**
+ * Swatch row for a note's colour (#1629).
+ *
+ * The palette is deliberately short and lives in @trek/shared, so the picker,
+ * the card and the PDF cannot drift apart. The first cell clears the colour
+ * rather than being a colour of its own — "no colour" is the default state, not
+ * an eighth option.
+ */
+export default function NoteColorPicker({ value, onChange }: Props) {
+ const { t } = useTranslation()
+ const selected = 'outline outline-2 outline-[var(--accent)] outline-offset-2 scale-110'
+
+ return (
+
+
+
+ {NOTE_COLORS.map(color => (
+
+ )
+}
+
+/** Names rather than hex codes in the label, so a screen reader says something useful. */
+const COLOR_NAMES: Record = {
+ '#dc2626': 'red',
+ '#ea580c': 'orange',
+ '#d97706': 'amber',
+ '#16a34a': 'green',
+ '#0891b2': 'cyan',
+ '#2563eb': 'blue',
+ '#9333ea': 'purple',
+}
diff --git a/client/src/components/shared/NoteFormatToolbar.test.tsx b/client/src/components/shared/NoteFormatToolbar.test.tsx
new file mode 100644
index 000000000..13b3becc8
--- /dev/null
+++ b/client/src/components/shared/NoteFormatToolbar.test.tsx
@@ -0,0 +1,113 @@
+import { describe, it, expect, vi } from 'vitest'
+import { screen, waitFor } from '@testing-library/react'
+import { render } from '../../../tests/helpers/render'
+import userEvent from '@testing-library/user-event'
+import { useRef, useState } from 'react'
+import NoteFormatToolbar from './NoteFormatToolbar'
+
+/**
+ * The toolbar edits somebody else's controlled field, so every test drives it
+ * through a real textarea rather than asserting on a callback in isolation —
+ * the selection handling is the part that breaks, and it only exists in the DOM.
+ */
+function Harness({ initial = '', onValue }: { initial?: string; onValue?: (v: string) => void }) {
+ const ref = useRef(null)
+ const [value, setValue] = useState(initial)
+ return (
+ <>
+ { setValue(v); onValue?.(v) }} />
+