diff --git a/README.md b/README.md
index 51a56700..1ab470d0 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,11 @@
-### Your trips. Your plan. Your server.
+
+
+
+
+
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx
index 1933982c..36d543c1 100644
--- a/client/src/components/Layout/Navbar.tsx
+++ b/client/src/components/Layout/Navbar.tsx
@@ -61,11 +61,25 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
navigate('/login', { state: { noRedirect: true } })
}
+ // Keep track of the pending theme-transition cleanup so we can cancel it
+ // on unmount. Without this the timer fires after jsdom teardown in unit
+ // tests (document is gone) and triggers an unhandled ReferenceError that
+ // trips vitest's exit code.
+ const themeTransitionTimer = useRef(null)
+ useEffect(() => () => {
+ if (themeTransitionTimer.current !== null) {
+ window.clearTimeout(themeTransitionTimer.current)
+ themeTransitionTimer.current = null
+ }
+ }, [])
+
const toggleDarkMode = () => {
document.documentElement.classList.add('trek-theme-transitioning')
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
- window.setTimeout(() => {
+ if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current)
+ themeTransitionTimer.current = window.setTimeout(() => {
document.documentElement.classList.remove('trek-theme-transitioning')
+ themeTransitionTimer.current = null
}, 360)
}
@@ -89,6 +103,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
touchAction: 'manipulation',
paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)',
+ top: 'var(--offline-banner-h, 0px)',
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */}
diff --git a/client/src/components/Layout/OfflineBanner.tsx b/client/src/components/Layout/OfflineBanner.tsx
index 04b58cf1..5c3b8010 100644
--- a/client/src/components/Layout/OfflineBanner.tsx
+++ b/client/src/components/Layout/OfflineBanner.tsx
@@ -40,6 +40,22 @@ export default function OfflineBanner(): React.ReactElement | null {
}, [])
const hidden = isOnline && pendingCount === 0
+
+ // When the banner is visible, reserve space at the top of the page so it
+ // doesn't cover the nav/header. Uses a CSS var on so we can offset
+ // via a global `body` rule instead of rewiring every layout.
+ useEffect(() => {
+ const root = document.documentElement
+ if (hidden) {
+ root.style.removeProperty('--offline-banner-h')
+ } else {
+ // 32px for icon+text row + the top safe-area inset that the banner adds
+ // in its own padding. Kept in one place so it's easy to tweak.
+ root.style.setProperty('--offline-banner-h', 'calc(env(safe-area-inset-top, 0px) + 32px)')
+ }
+ return () => { root.style.removeProperty('--offline-banner-h') }
+ }, [hidden])
+
if (hidden) return null
const offline = !isOnline
diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx
index d1bdb03d..9311cbc1 100644
--- a/client/src/components/Packing/PackingListPanel.tsx
+++ b/client/src/components/Packing/PackingListPanel.tsx
@@ -208,9 +208,14 @@ interface ArtikelZeileProps {
canEdit?: boolean
}
+// A category's first item is seeded with this sentinel because the server
+// rejects empty names. Treat it as a placeholder in the UI.
+const PACKING_PLACEHOLDER_NAME = '...'
+
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
+ const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
- const [editName, setEditName] = useState(item.name)
+ const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
const [hovered, setHovered] = useState(false)
const [showCatPicker, setShowCatPicker] = useState(false)
const [showBagPicker, setShowBagPicker] = useState(false)
@@ -223,7 +228,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleSaveName = async () => {
- if (!editName.trim()) { setEditing(false); setEditName(item.name); return }
+ if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
catch { toast.error(t('packing.toast.saveError')) }
}
@@ -275,9 +280,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
{editing && canEdit ? (
setEditName(e.target.value)}
onBlur={handleSaveName}
- onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }}
+ onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
/>
) : (
@@ -286,7 +292,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
style={{
flex: 1, fontSize: 13.5,
cursor: !canEdit || item.checked ? 'default' : 'text',
- color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
+ color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'),
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
textDecoration: item.checked ? 'line-through' : 'none',
}}
diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx
index 528c3c4d..8ef2282b 100644
--- a/client/src/components/Planner/DayDetailPanel.tsx
+++ b/client/src/components/Planner/DayDetailPanel.tsx
@@ -168,7 +168,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return (
-
+
+
+
+
+ }
>
)}
- {/* Actions */}
-
-
-
-
)
diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx
index 7710a188..4cf9c208 100644
--- a/client/src/components/Planner/ReservationModal.test.tsx
+++ b/client/src/components/Planner/ReservationModal.test.tsx
@@ -203,8 +203,10 @@ describe('ReservationModal', () => {
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
fireEvent.change(timePickers[1], { target: { value: '09:00' } });
- // When isEndBeforeStart=true the submit button is disabled, so submit the form directly
- const form = screen.getByRole('button', { name: /^Add$/i }).closest('form')!;
+ // When isEndBeforeStart=true the submit button is disabled, so fire submit on the form directly.
+ // The Save button now lives in the Modal's sticky footer (outside the
+ }
+ >
)
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index ff98434a..00c105c2 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -148,13 +148,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
>
- {/* Header */}
+ {/* Header — wraps to a second row on narrow screens so the status/category chips
+ never collide with the title. */}
-
+
+
+
+
+ }
>
- {/* Actions */}
-
-
-
-
)
diff --git a/client/src/components/Settings/DisplaySettingsTab.test.tsx b/client/src/components/Settings/DisplaySettingsTab.test.tsx
index 4c770e6c..d0ff467e 100644
--- a/client/src/components/Settings/DisplaySettingsTab.test.tsx
+++ b/client/src/components/Settings/DisplaySettingsTab.test.tsx
@@ -155,7 +155,9 @@ describe('DisplaySettingsTab', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
render();
- await user.click(screen.getByText('24h (14:30)'));
+ // The label is split across a text node ('24h') and a responsive span (' (14:30)').
+ // Click the button that contains the 24h text instead of matching the full string.
+ await user.click(screen.getByRole('button', { name: /24h/ }));
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
});
diff --git a/client/src/components/Settings/DisplaySettingsTab.tsx b/client/src/components/Settings/DisplaySettingsTab.tsx
index 0eaec1ca..575041f9 100644
--- a/client/src/components/Settings/DisplaySettingsTab.tsx
+++ b/client/src/components/Settings/DisplaySettingsTab.tsx
@@ -188,8 +188,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
+ {/* Experimental badge only on ≥sm; on mobile there's no room next to the title. */}
+
+ {t('settings.mapExperimental')}
+
diff --git a/client/src/components/Settings/PhotoProvidersSection.tsx b/client/src/components/Settings/PhotoProvidersSection.tsx
index 042d5e6a..bd1659a7 100644
--- a/client/src/components/Settings/PhotoProvidersSection.tsx
+++ b/client/src/components/Settings/PhotoProvidersSection.tsx
@@ -5,6 +5,7 @@ import { useToast } from '../../components/shared/Toast'
import apiClient from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import Section from './Section'
+import ToggleSwitch from './ToggleSwitch'
interface ProviderField {
key: string
@@ -222,15 +223,13 @@ export default function PhotoProvidersSection(): React.ReactElement {
{fields.map(field => (
{field.input_type === 'checkbox' ? (
-
))}
-
+ {/* Wraps on mobile so the connection badge drops to its own row
+ instead of clipping off the side of the card. */}
+
+ {/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */}
{connected ? (
-
+
{t('memories.connected')}
) : (
-
+
{t('memories.disconnected')}
diff --git a/client/src/components/Settings/ToggleSwitch.tsx b/client/src/components/Settings/ToggleSwitch.tsx
index 562d7238..b74e5513 100644
--- a/client/src/components/Settings/ToggleSwitch.tsx
+++ b/client/src/components/Settings/ToggleSwitch.tsx
@@ -2,9 +2,10 @@ import React from 'react'
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return (
-