diff --git a/client/src/components/shared/MarkdownText.test.tsx b/client/src/components/shared/MarkdownText.test.tsx
new file mode 100644
index 000000000..afd9ad015
--- /dev/null
+++ b/client/src/components/shared/MarkdownText.test.tsx
@@ -0,0 +1,44 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import MarkdownText from './MarkdownText'
+
+describe('MarkdownText', () => {
+ it('FE-MDTEXT-001: a description written in markdown reads as formatted text, not as syntax (#2337)', () => {
+ render({'# Checkpoint Charlie\n\nThe **most famous** crossing.'})
+ expect(screen.getByRole('heading', { name: 'Checkpoint Charlie' })).toBeInTheDocument()
+ expect(screen.getByText('most famous').tagName).toBe('STRONG')
+ expect(screen.queryByText(/#\s*Checkpoint/)).toBeNull()
+ })
+
+ it('FE-MDTEXT-002: a gfm table is a table', () => {
+ render({'| Day | Stop |\n| --- | --- |\n| 1 | Gate |'})
+ expect(screen.getByRole('table')).toBeInTheDocument()
+ expect(screen.getByRole('columnheader', { name: 'Day' })).toBeInTheDocument()
+ })
+
+ it('FE-MDTEXT-003: a single newline stays a line break, the way the editors show it', () => {
+ const { container } = render({'Opening hours\n9–17'})
+ expect(container.querySelector('br')).not.toBeNull()
+ })
+
+ it('FE-MDTEXT-004: a link in a full view opens in its own tab', () => {
+ render({'[tickets](https://example.com/tickets)'})
+ const link = screen.getByRole('link', { name: 'tickets' })
+ expect(link).toHaveAttribute('target', '_blank')
+ expect(link.getAttribute('rel')).toContain('noopener')
+ })
+
+ it('FE-MDTEXT-005: a clamped row keeps the link text but drops the anchor, so the tap opens the row', () => {
+ render({'[tickets](https://example.com/tickets)'})
+ expect(screen.queryByRole('link')).toBeNull()
+ expect(screen.getByText('tickets')).toBeInTheDocument()
+ })
+
+ it('FE-MDTEXT-006: a clamped row is held to one line so a long description cannot swallow the list', () => {
+ const { container } = render({'# Berlin Wall Memorial\n\nA long second paragraph.'})
+ const wrapper = container.firstElementChild as HTMLElement
+ expect(wrapper.style.maxHeight).toBe('1.2em')
+ expect(wrapper.className).toContain('truncate')
+ expect(wrapper.className).toContain('text-m-muted')
+ })
+})
diff --git a/client/src/components/shared/MarkdownText.tsx b/client/src/components/shared/MarkdownText.tsx
new file mode 100644
index 000000000..f65be4069
--- /dev/null
+++ b/client/src/components/shared/MarkdownText.tsx
@@ -0,0 +1,42 @@
+import Markdown from 'react-markdown'
+import type { Components } from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import remarkBreaks from 'remark-breaks'
+import { markdownLinkComponents } from './markdownLink'
+
+/**
+ * A clamped caption lives inside a tappable row, so an anchor there would eat
+ * the tap that opens the row. The link text stays, the anchor goes.
+ */
+const clampedComponents: Components = {
+ a: ({ children }) => <>{children}>,
+}
+
+/**
+ * A description or note, rendered (#2337). The field is Markdown wherever it is
+ * written — the editors ship a formatting bar — so printing it raw anywhere
+ * shows `# headings` and `**asterisks**` to the reader.
+ *
+ * `clamp` is the list-row variant: one line high, matching what the desktop
+ * sidebar rows do with the same field. The height cap is inline because the row
+ * itself sets a font size, and a Tailwind `leading-*` would race with it.
+ */
+export default function MarkdownText({ children, className = '', clamp = false }: {
+ children: string
+ className?: string
+ clamp?: boolean
+}) {
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx
index a3880d089..6bd4a1d42 100644
--- a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx
+++ b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx
@@ -9,6 +9,7 @@ import { useAddonStore } from '../../../../store/addonStore'
import { useToast } from '../../../../components/shared/Toast'
import { collectionsApi } from '../../../../api/collections'
import PlaceAvatar from '../../../../components/shared/PlaceAvatar'
+import MarkdownText from '../../../../components/shared/MarkdownText'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import { resolveTrackColor } from '../../../../components/Map/trackColors'
import MConfirmSheet from '../../settings/MConfirmSheet'
@@ -350,7 +351,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
{!selectMode && dayNumber != null && (
diff --git a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx
index a3f1cb59e..b72c29f70 100644
--- a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx
+++ b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx
@@ -6,6 +6,7 @@ import {
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useContextMenu, ContextMenu } from '../../../../components/shared/ContextMenu'
+import MarkdownText from '../../../../components/shared/MarkdownText'
import { fmtTransitDuration } from '../../../../components/Planner/transitDisplay'
import { formatTime } from '../../../../utils/formatters'
import { useMPlanTimeline, type MPlanTimelineController } from './useMPlanTimeline'
@@ -323,7 +324,7 @@ function UpNextCard({ tl, t, onOpen }: {
)}
{place?.name}
- {sub &&
{sub}
}
+ {sub && {sub}}
diff --git a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx
index f02f136af..1ea35adc9 100644
--- a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx
+++ b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx
@@ -2,12 +2,9 @@ import { BedDouble, Car, ChevronDown, ChevronUp, Clock, Footprints, Pencil, Rout
import type { ReactNode, MouseEvent, CSSProperties } from 'react'
import PlaceAvatar from '../../../../components/shared/PlaceAvatar'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
-import Markdown from 'react-markdown'
-import remarkGfm from 'remark-gfm'
-import remarkBreaks from 'remark-breaks'
+import MarkdownText from '../../../../components/shared/MarkdownText'
import { RES_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants'
import { noteSurface } from '../../../../components/Planner/noteSurface'
-import { markdownLinkComponents } from '../../../../components/shared/markdownLink'
import { getDisplayTimeForDay, getSpanPhase } from '../../../../utils/dayMerge'
import { formatTime, splitReservationDateTime } from '../../../../utils/formatters'
import { transportSubtitle, type TransitMeta, type TransportEntry } from './planTimelineModel'
@@ -193,7 +190,7 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
{(time || sub) && (
)}
{bookingLines.slice(1).map(line => (
@@ -207,7 +204,7 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
// without opening the place sheet.
- {assignment.notes}
+ {assignment.notes}
)}
@@ -571,9 +568,7 @@ export function NoteRow({ note, chrome, reorder, drag, onEdit }: {
{detail && (
// Rendered, not raw: a note written with the formatting bar would
// otherwise read as `**asterisks**` on the phone.
-
- {detail}
-
+ {detail}
)}
{chrome.editing && {reorder}}
diff --git a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx
index 80457ebd8..eb29d346e 100644
--- a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx
@@ -15,6 +15,7 @@ import { useSaveToCollectionStore } from '../../../../store/saveToCollectionStor
import { collectionTargetFromPlace } from '../lib/collectionTarget'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import PlaceRating from '../../../../components/shared/StarRating'
+import MarkdownText from '../../../../components/shared/MarkdownText'
import TrackColorPicker from '../../../../components/shared/TrackColorPicker'
import { resolveTrackColor, inheritedTrackColor } from '../../../../components/Map/trackColors'
import { avatarSrc } from '../../../../utils/avatarSrc'
@@ -342,7 +343,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
{place.description && (