From 1ad2e92142a56533003e8530ce364ea986a643cd Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 25 Aug 2026 18:28:40 +0200 Subject: [PATCH] fix: make the app keyboard-reachable, and close four DoS-able patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk of what SonarQube still had open on the reliability side, read site by site rather than swept. Two clusters are worth calling out because they are defects, not lint. **Four regexes could be made to hang the process.** The worst is `/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all overlap, unanchored, on an endpoint that needs no login and whose schema is a bare z.string() under a 100kb body cap. A thousand '@' characters had not finished after 100 seconds — one request pins the event loop, and Node has one. EMAIL_REGEX in auth.helpers is the same shape on register and profile update (310ms for 8kb). The two tag strippers over an uploaded KML description and over Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven equivalent by brute force against the original — millions of strings for the coordinate patterns, 671,876 for the tag stripper — because a subtly different regex on a parsing path is worse than the finding. `<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only unambiguous form changes what happens to malformed markup. It is now a scan in common/stripHtmlTags.ts, quirks included — `<>` is left alone, `` is one tag — with the equivalence pinned by a test that compares against the old regex over every string up to length six. **And roughly 500 places could only be reached with a mouse.** Clickable divs became real buttons where the markup allowed (52 of them), took role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops took role="presentation" — the honest role, since a backdrop is a mouse shortcut for a Cancel control that is already in the tab order, and making it a button plants a nameless full-screen tab stop around its own contents. Alongside those: `.sort()` calls that now say the order they always had, `{n && }` guards that rendered a literal "0" for a zero-byte file and for a place at latitude 0, an optimistic budget write that stored a boolean where the contract and both transports use 0/1, and the Number statics. sonar-project.properties documents the six that were deliberately left: two wiki patterns with no overlapping quantifier pair whose "fix" would stop parsing `[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format, two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also coercing undefined to 0 on a path where the API sends no user_id, and the MCP session maps Sonar reads as empty because they are filled from another module. --- client/src/components/Admin/AddonManager.tsx | 1 + .../components/Admin/AdminMcpTokensPanel.tsx | 14 +- .../components/Admin/AdminPluginsPanel.tsx | 28 ++-- client/src/components/Admin/BackupPanel.tsx | 10 +- .../Admin/DefaultUserSettingsTab.tsx | 2 +- .../Admin/PackingTemplateManager.test.tsx | 10 +- .../Admin/PackingTemplateManager.tsx | 2 +- .../components/Budget/BudgetPanel.helpers.ts | 2 +- .../Budget/BudgetPanelCategoryTable.test.tsx | 5 +- .../Budget/BudgetPanelInlineEditCell.tsx | 31 ++-- .../Budget/BudgetPanelMemberChips.test.tsx | 42 +++-- .../Budget/BudgetPanelMemberChips.tsx | 44 ++++-- .../Budget/BudgetPanelRingAvatar.tsx | 2 +- .../components/Budget/BudgetPanelSummary.tsx | 5 + client/src/components/Budget/CostsPanel.tsx | 2 +- .../components/Collab/CollabNotes.test.tsx | 19 ++- client/src/components/Collab/CollabNotes.tsx | 9 +- .../Collab/CollabNotesAuthedImg.tsx | 16 +- .../src/components/Collab/CollabNotesCard.tsx | 5 +- .../CollabNotesCategorySettingsModal.tsx | 4 +- .../Collab/CollabNotesEditableCatName.tsx | 6 +- .../Collab/CollabNotesFilePreviewPortal.tsx | 6 +- .../Collab/CollabNotesFormModal.tsx | 1 + client/src/components/Collab/CollabPolls.tsx | 12 +- .../src/components/Collab/WhatsNextWidget.tsx | 2 +- .../Collections/AddPlaceToCollectionModal.tsx | 5 +- .../Collections/CollectionPlaceDetail.tsx | 4 +- .../Files/FileManagerAssignModal.tsx | 4 +- .../Files/FileManagerAvatarChip.tsx | 2 +- .../Files/FileManagerImageLightbox.tsx | 16 +- .../Files/FileManagerMarkdownPreviewModal.tsx | 2 + .../Files/FileManagerPdfPreviewModal.tsx | 2 + .../src/components/Files/FileManagerRow.tsx | 25 +-- .../Journey/ContributorInviteDialog.tsx | 6 +- .../Journey/JourneyDetailPageDatePicker.tsx | 4 +- .../JourneyDetailPageEntryCard.test.tsx | 8 +- .../Journey/JourneyDetailPageEntryCard.tsx | 10 +- .../Journey/JourneyDetailPageEntryEditor.tsx | 10 +- .../JourneyDetailPageExpandableStory.tsx | 17 +- .../Journey/JourneyDetailPageGalleryView.tsx | 3 + .../Journey/JourneyDetailPageMapView.test.tsx | 6 +- .../Journey/JourneyDetailPageMapView.tsx | 7 +- .../Journey/JourneyDetailPagePhotoGrid.tsx | 25 +-- .../JourneyDetailPageProviderPicker.tsx | 14 +- .../JourneyDetailPageSettingsDialog.tsx | 6 +- .../Journey/JourneyShareSection.tsx | 3 +- .../components/Journey/MobileEntryView.tsx | 32 ++-- client/src/components/Journey/VideoPlayer.tsx | 5 +- .../src/components/Journey/stripMarkdown.ts | 4 +- client/src/components/Layout/DemoBanner.tsx | 11 ++ .../Layout/InAppNotificationBell.tsx | 3 +- client/src/components/Layout/MobileTopBar.tsx | 3 +- client/src/components/Layout/Navbar.tsx | 2 +- client/src/components/Layout/PageSidebar.tsx | 1 + client/src/components/Map/transitGeometry.ts | 2 +- .../Notifications/InAppNotificationItem.tsx | 8 +- .../src/components/OAuth/ScopeGroupPicker.tsx | 6 +- client/src/components/PDF/TripPDF.tsx | 2 +- .../Packing/PackingListPanel.test.tsx | 9 +- .../Packing/PackingListPanelBagCard.tsx | 36 +++-- .../Packing/PackingListPanelBagModal.tsx | 9 +- .../PackingListPanelCategoryGroup.test.tsx | 10 +- .../Packing/PackingListPanelCategoryGroup.tsx | 65 ++++---- .../Packing/PackingListPanelImportModal.tsx | 4 +- .../Packing/PackingListPanelItemRow.tsx | 35 +++-- .../Packing/PackingListPanelViewTabs.tsx | 4 +- .../Packing/PackingShareControl.tsx | 4 +- .../Packing/packingListPanel.helpers.ts | 2 +- .../Planner/AirTrailImportModal.tsx | 2 + .../components/Planner/BookingImportModal.tsx | 7 +- .../src/components/Planner/DayDetailPanel.tsx | 34 ++-- .../src/components/Planner/DayPlanSidebar.tsx | 122 +++++++++++---- .../Planner/DayPlanSidebarNoteModal.tsx | 4 +- .../DayPlanSidebarTimeConfirmModal.tsx | 4 +- .../DayPlanSidebarTransportDetailModal.tsx | 49 ++++-- .../components/Planner/DayReorderPopup.tsx | 2 + .../components/Planner/FileImportModal.tsx | 15 +- .../components/Planner/IcsSubscribeModal.tsx | 1 + .../src/components/Planner/PlaceFormModal.tsx | 4 +- .../Planner/PlaceInspector.test.tsx | 2 +- .../src/components/Planner/PlaceInspector.tsx | 14 +- .../Planner/PlacesBulkCategoryModal.tsx | 3 +- .../Planner/PlacesSidebarListImportModal.tsx | 2 + .../Planner/PlacesSidebarMobileDayPicker.tsx | 2 + .../components/Planner/PlacesSidebarRow.tsx | 27 ++-- .../Planner/ReservationModal.test.tsx | 9 +- .../components/Planner/ReservationModal.tsx | 2 +- .../components/Planner/ReservationsPanel.tsx | 82 +++++----- .../components/Planner/TransitSearchPanel.tsx | 6 +- .../Planner/TransportModal.test.tsx | 7 +- .../src/components/Planner/TransportModal.tsx | 12 +- .../components/Planner/placeHoursFormat.ts | 12 +- .../Plugins/PluginContributions.tsx | 4 +- client/src/components/Settings/AccountTab.tsx | 18 ++- .../Settings/AppearanceSettingsTab.tsx | 2 +- .../components/Settings/IntegrationsTab.tsx | 23 +-- .../components/Settings/MapSettingsTab.tsx | 2 +- client/src/components/Studio/StudioCanvas.tsx | 1 + .../components/Studio/StudioElementsPanel.tsx | 1 + .../src/components/Studio/StudioSidebar.tsx | 1 + .../src/components/Studio/StudioSwatches.tsx | 3 +- .../SystemNotices/ReleaseNoticeModal.tsx | 3 +- .../SystemNotices/SystemNoticeModal.test.tsx | 6 +- .../SystemNotices/SystemNoticeModal.tsx | 11 +- .../components/Todo/TodoListPanel.test.tsx | 34 ++-- client/src/components/Todo/TodoListPanel.tsx | 6 +- client/src/components/Todo/TodoRow.test.tsx | 12 +- client/src/components/Todo/TodoRow.tsx | 16 +- client/src/components/Todo/todoListModel.ts | 2 +- .../components/Trips/TripFormModal.test.tsx | 12 +- client/src/components/Trips/TripFormModal.tsx | 18 +-- .../src/components/Trips/TripMembersModal.tsx | 2 +- .../src/components/Vacay/VacayMonthCard.tsx | 5 + client/src/components/Vacay/VacayPersons.tsx | 27 +++- client/src/components/Vacay/VacaySettings.tsx | 2 +- .../components/Vacay/VacaySharedCalendars.tsx | 5 + client/src/components/Vacay/VacayStats.tsx | 5 +- .../src/components/shared/ConfirmDialog.tsx | 4 + .../src/components/shared/CopyTripDialog.tsx | 8 + .../shared/CustomDateTimePicker.tsx | 2 +- .../components/shared/CustomTimePicker.tsx | 8 +- client/src/components/shared/Modal.tsx | 5 + client/src/components/shared/StarRating.tsx | 2 +- client/src/hooks/useGeolocation.ts | 4 +- client/src/mobile/components/MBottomNav.tsx | 3 +- .../src/mobile/components/MDropdownPanel.tsx | 4 +- client/src/mobile/components/MSheet.tsx | 1 + client/src/mobile/components/MToastHost.tsx | 3 + .../admin/MAdminPackingTemplateManager.tsx | 4 +- .../screens/admin/MAdminPluginsPanel.tsx | 2 +- .../src/mobile/screens/atlas/MAtlasSearch.tsx | 15 +- .../screens/collections/MCollLabelsSheet.tsx | 7 + .../mobile/screens/dashboard/MDashWidgets.tsx | 2 +- .../mobile/screens/dashboard/MDashboard.tsx | 2 +- .../screens/journey/MJourneySettingsSheet.tsx | 5 +- .../notifications/MNotificationRow.tsx | 20 ++- .../screens/settings/MSettingsAccount.tsx | 9 +- .../mobile/screens/settings/MSettingsMap.tsx | 2 +- .../screens/trip/plan/MPlanTimeline.tsx | 7 +- .../screens/trip/plan/MPlanTimelineRows.tsx | 34 +++- .../screens/trip/sheets/MPlaceEditSheet.tsx | 5 +- .../screens/trip/sheets/MPlaceSheet.tsx | 6 + .../screens/trip/sheets/MTransportSheet.tsx | 10 +- .../screens/trip/sheets/MTripSheetUi.tsx | 18 ++- .../screens/trip/sheets/PlPlaceSearch.tsx | 2 +- .../mobile/screens/trip/tabs/MBookingsTab.tsx | 5 + .../screens/trip/tabs/MFileLightbox.tsx | 24 ++- .../screens/trip/tabs/MTransportsTab.tsx | 3 + client/src/pages/AtlasPage.tsx | 16 +- client/src/pages/CollectionsPage.tsx | 2 +- client/src/pages/DashboardPage.tsx | 30 +++- client/src/pages/HelpPage.tsx | 3 +- client/src/pages/JourneyPage.tsx | 36 +++-- client/src/pages/JourneyPublicPage.tsx | 61 +++++--- client/src/pages/LoginPage.tsx | 11 ++ client/src/pages/SharedTripPage.test.tsx | 8 +- client/src/pages/SharedTripPage.tsx | 16 +- client/src/pages/TripPlannerPage.tsx | 10 +- client/src/pages/VacayPage.tsx | 10 +- .../src/pages/admin/AdminNotificationsTab.tsx | 6 +- client/src/pages/admin/AdminSettingsTab.tsx | 9 +- client/src/pages/admin/AdminUserModals.tsx | 2 + client/src/pages/atlas/atlasModel.ts | 8 +- client/src/store/settingsStore.ts | 2 +- client/src/styles/studio.css | 1 - client/src/utils/dayMerge.ts | 2 +- client/src/utils/formatters.ts | 4 +- .../unit/mobile/trip/MFileLightbox.test.tsx | 4 +- .../unit/mobile/trip/MPlaceSheet.test.tsx | 4 +- plugin-sdk/src/cli/checks/context.ts | 6 +- plugin-sdk/src/cli/checks/network.ts | 4 +- plugin-sdk/src/cli/create.ts | 2 +- plugin-sdk/src/cli/json.ts | 2 +- plugin-sdk/src/egress-policy.ts | 7 +- plugin-sdk/src/mock-host.ts | 7 +- plugin-sdk/src/zip.ts | 2 +- server/src/app-config/app-url.ts | 5 +- server/src/app-config/parsers.ts | 8 +- server/src/nest/atlas/atlas.service.ts | 3 +- server/src/nest/auth/auth.helpers.ts | 6 +- server/src/nest/auth/auth.service.ts | 6 +- .../src/nest/auth/webauthn-config.service.ts | 6 +- server/src/nest/backup/backup.impl.ts | 2 +- server/src/nest/calendar/calendar.service.ts | 12 +- .../nest/common/idempotency.interceptor.ts | 2 +- server/src/nest/common/stripHtmlTags.ts | 44 ++++++ server/src/nest/feeds/feeds.service.ts | 2 +- server/src/nest/help/wiki.ts | 15 +- .../src/nest/integrations/airtrail.client.ts | 4 +- .../nest/journey/journey-public.controller.ts | 2 +- .../clients/openai-compatible.client.ts | 4 +- .../src/nest/llm-parse/llm-local.service.ts | 6 +- server/src/nest/llm-parse/mime-email.ts | 6 +- .../llm-parse/router/extraction-router.ts | 15 +- server/src/nest/llm-parse/text-extract.ts | 146 +++++++++++++++++- server/src/nest/maps/maps.helpers.ts | 17 +- server/src/nest/maps/maps.service.ts | 15 +- .../nest/memories/photo-resolver.service.ts | 2 +- .../nest/notifications/mailer/email-html.ts | 10 +- .../notifications/notifications.controller.ts | 2 +- .../notifications/transports/ntfy.service.ts | 10 +- .../src/nest/oauth/oauth-public.controller.ts | 13 +- server/src/nest/oauth/oauth-sdk.provider.ts | 10 +- server/src/nest/oauth/oauth.service.ts | 6 +- server/src/nest/oidc/oidc.service.ts | 12 +- .../place-enrichment.service.ts | 2 +- server/src/nest/places/gpx-export.helpers.ts | Bin 6037 -> 6039 bytes server/src/nest/places/kml-import.helpers.ts | 9 +- server/src/nest/places/places.service.ts | 4 +- .../nest/plugins/host/plugin-data.service.ts | 2 +- server/src/nest/plugins/install/manifest.ts | 2 +- .../src/nest/plugins/install/safe-extract.ts | 4 +- server/src/nest/plugins/install/safe-fetch.ts | 2 +- .../plugins/oauth/plugin-oauth.service.ts | 2 +- .../nest/plugins/plugin-runtime.service.ts | 2 +- .../src/nest/plugins/runtime/egress-policy.ts | 7 +- server/src/nest/plugins/text-sanitize.ts | 7 +- server/src/nest/storage/drivers/s3.driver.ts | 12 +- server/src/nest/storage/storage-keys.ts | 4 +- server/src/nest/transit/transit.service.ts | 2 +- server/src/nest/trips/trips.mcp.ts | 8 +- server/src/nest/weather/weather.impl.ts | 4 +- server/src/utils/ipv6.ts | 7 +- .../tests/unit/nest/strip-html-tags.test.ts | 72 +++++++++ sonar-project.properties | 51 +++++- 225 files changed, 1721 insertions(+), 735 deletions(-) create mode 100644 server/src/nest/common/stripHtmlTags.ts create mode 100644 server/tests/unit/nest/strip-html-tags.test.ts diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 7ae90b72e..c5ea9e6ce 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -451,6 +451,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
Model
diff --git a/client/src/components/Admin/AdminMcpTokensPanel.tsx b/client/src/components/Admin/AdminMcpTokensPanel.tsx index e2c55c86a..221f4da9c 100644 --- a/client/src/components/Admin/AdminMcpTokensPanel.tsx +++ b/client/src/components/Admin/AdminMcpTokensPanel.tsx @@ -214,7 +214,12 @@ export default function AdminMcpTokensPanel() { {/* Revoke OAuth session modal */} {revokeConfirmId !== null && (
{ if (e.target === e.currentTarget) setRevokeConfirmId(null) }}> + role="button" tabIndex={0} aria-label={t('common.close')} + onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }} + onKeyDown={e => { + if (e.target !== e.currentTarget) return + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRevokeConfirmId(null) } + }}>

{t('admin.oauthSessions.revokeTitle')}

{t('admin.oauthSessions.revokeMessage')}

@@ -235,7 +240,12 @@ export default function AdminMcpTokensPanel() { {/* Delete MCP token modal */} {deleteConfirmId !== null && (
{ if (e.target === e.currentTarget) setDeleteConfirmId(null) }}> + role="button" tabIndex={0} aria-label={t('common.close')} + onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }} + onKeyDown={e => { + if (e.target !== e.currentTarget) return + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDeleteConfirmId(null) } + }}>

{t('admin.mcpTokens.deleteTitle')}

{t('admin.mcpTokens.deleteMessage')}

diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 30983414a..8a1630424 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -799,7 +799,7 @@ export default function AdminPluginsPanel() {
)} {/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */} - {menu &&
setMenu(null)} />} + {menu &&
setMenu(null)} />} {/* Header */}
@@ -972,8 +972,8 @@ export default function AdminPluginsPanel() { {/* Error-log modal */} {errorsFor && ( -
setErrorsFor(null)}> -
e.stopPropagation()}> +
setErrorsFor(null)}> +
e.stopPropagation()}>
{errorsFor.id} — {t('admin.plugins.errorLog')} @@ -994,8 +994,8 @@ export default function AdminPluginsPanel() { {/* Operator-supplied egress hosts */} {egressFor && ( -
setEgressFor(null)}> -
e.stopPropagation()}> +
setEgressFor(null)}> +
e.stopPropagation()}>
{egressFor.id} — {t('admin.plugins.allowedHosts')} @@ -1508,8 +1508,8 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local const offer = installOffer(detail ?? item, t) return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>
@@ -1610,7 +1610,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local

{t('admin.plugins.detailsTitle')}

{item.latest && } - {sizeKb && } + {sizeKb !== null && } {/* The range, not just its lower bound: "TREK 3.2.0+" reads as "and anything newer", which is exactly the claim a `<4.0.0` upper bound denies. */} {(item.trek || item.minTrekVersion) && ( @@ -1760,8 +1760,8 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: { : 'admin.plugins.sig.invalidBody' return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>
@@ -1824,8 +1824,8 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: { t: T; onApprove: () => void; onLater: () => void }) { return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>
@@ -1884,8 +1884,8 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl ...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })), ] return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>
diff --git a/client/src/components/Admin/BackupPanel.tsx b/client/src/components/Admin/BackupPanel.tsx index 754cd276a..28ce06181 100644 --- a/client/src/components/Admin/BackupPanel.tsx +++ b/client/src/components/Admin/BackupPanel.tsx @@ -318,9 +318,9 @@ export default function BackupPanel() {
{/* Enable toggle */}
) diff --git a/client/src/components/Budget/BudgetPanelSummary.tsx b/client/src/components/Budget/BudgetPanelSummary.tsx index 6149e813a..0b9d77866 100644 --- a/client/src/components/Budget/BudgetPanelSummary.tsx +++ b/client/src/components/Budget/BudgetPanelSummary.tsx @@ -83,7 +83,12 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has {settlementOpen ? : } {t('budget.settlement')} + {/* Decoration inside the settlement toggle: the hint hangs off + hover, and the click only keeps the toggle from firing. A + role of its own would give the toggle a second name and a + nested control, so it declares itself presentational. */} { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }} onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }} onClick={e => e.stopPropagation()} diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 302a1856d..a4de9d132 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -706,7 +706,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps return (
- +
) } diff --git a/client/src/components/Collab/CollabNotes.test.tsx b/client/src/components/Collab/CollabNotes.test.tsx index b18854590..1ac19112d 100644 --- a/client/src/components/Collab/CollabNotes.test.tsx +++ b/client/src/components/Collab/CollabNotes.test.tsx @@ -858,7 +858,7 @@ describe('CollabNotes', () => { await user.click(screen.getByTitle('Manage Categories')); await screen.findByText('Manage Categories', { selector: 'h3' }); // Find the "Transport" category name span and click to edit - const categoryNameSpan = screen.getAllByText('Transport').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename'); + const categoryNameSpan = screen.getAllByText('Transport').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename'); if (categoryNameSpan) { await user.click(categoryNameSpan); // Now an input with value "Transport" should appear @@ -980,7 +980,7 @@ describe('CollabNotes', () => { await screen.findByText('Manage Categories', { selector: 'h3' }); // Find and click the "OldCat" category name span to enter edit mode - const oldCatSpan = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename'); + const oldCatSpan = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename'); if (oldCatSpan) { await user.click(oldCatSpan); const editInput = screen.getByDisplayValue('OldCat'); @@ -1767,10 +1767,13 @@ describe('CollabNotes details', () => { if (!img) throw new Error('image attachment not rendered yet'); return img; }); - fireEvent.mouseEnter(image); - expect(image.style.transform).toBe('scale(1.06)'); - fireEvent.mouseLeave(image); - expect(image.style.transform).toBe('scale(1)'); + // The clickable thumbnail is a real button wrapped around the image, so the + // hover transform lands on that button. + const imageTile = image.closest('button') as HTMLElement; + fireEvent.mouseEnter(imageTile); + expect(imageTile.style.transform).toBe('scale(1.06)'); + fireEvent.mouseLeave(imageTile); + expect(imageTile.style.transform).toBe('scale(1)'); fireEvent.click(image); await waitFor(() => expect(screen.queryByText('Download itinerary.zip')).not.toBeInTheDocument()); }); @@ -1841,7 +1844,7 @@ describe('CollabNotes details', () => { await user.click(screen.getByTitle('Manage Categories')); await screen.findByText('Manage Categories', { selector: 'h3' }); - const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename')!; + const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename')!; await user.click(oldCat); const editInput = screen.getByDisplayValue('OldCat'); await user.clear(editInput); @@ -1894,7 +1897,7 @@ describe('CollabNotes details', () => { await user.click(screen.getByTitle('Manage Categories')); await screen.findByText('Manage Categories', { selector: 'h3' }); - const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename')!; + const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename')!; await user.click(oldCat); const editInput = screen.getByDisplayValue('OldCat'); await user.clear(editInput); diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index fe484c6ff..c46477bd7 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -403,7 +403,8 @@ function ViewNoteModal(S: NotesState) { display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000, padding: 16, }} - onClick={() => setViewingNote(null)} + role="presentation" + onClick={e => { if (e.target === e.currentTarget) setViewingNote(null) }} >
e.stopPropagation()} >
{ e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} /> ) : ( -
setPreviewFile(a)} +
+ )} {a.original_name}
diff --git a/client/src/components/Collab/CollabNotesAuthedImg.tsx b/client/src/components/Collab/CollabNotesAuthedImg.tsx index c54cd6a49..799eb9820 100644 --- a/client/src/components/Collab/CollabNotesAuthedImg.tsx +++ b/client/src/components/Collab/CollabNotesAuthedImg.tsx @@ -1,10 +1,22 @@ import { useState, useEffect } from 'react' import { getAuthUrl } from '../../api/authUrl' -export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; alt?: string }) { +export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; alt?: string }) { const [authSrc, setAuthSrc] = useState('') useEffect(() => { getAuthUrl(src, 'download').then(setAuthSrc) }, [src]) - return authSrc ? {alt} : null + if (!authSrc) return null + // A clickable thumbnail gets a real button around the image so it is + // reachable by keyboard. The caller's style stays on the outer box, so the + // hover transform/transition still animates the element that carries them. + if (onClick) { + return ( + + ) + } + return {alt} } diff --git a/client/src/components/Collab/CollabNotesCard.tsx b/client/src/components/Collab/CollabNotesCard.tsx index 1b3baaaa2..7eaa2ff11 100644 --- a/client/src/components/Collab/CollabNotesCard.tsx +++ b/client/src/components/Collab/CollabNotesCard.tsx @@ -171,17 +171,18 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} /> ) : ( -
onPreviewFile?.(a)} +
+ ) })} {(note.attachments?.length || 0) > (note.website ? 1 : 2) && ( diff --git a/client/src/components/Collab/CollabNotesCategorySettingsModal.tsx b/client/src/components/Collab/CollabNotesCategorySettingsModal.tsx index 2f8a856ea..8e4482e16 100644 --- a/client/src/components/Collab/CollabNotesCategorySettingsModal.tsx +++ b/client/src/components/Collab/CollabNotesCategorySettingsModal.tsx @@ -69,11 +69,11 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT, - }} onClick={onClose}> + }} role="presentation" onClick={onClose}>
e.stopPropagation()}> + }} role="presentation" onClick={e => e.stopPropagation()}> {/* Header */}

diff --git a/client/src/components/Collab/CollabNotesEditableCatName.tsx b/client/src/components/Collab/CollabNotesEditableCatName.tsx index 35f542608..ac396073a 100644 --- a/client/src/components/Collab/CollabNotesEditableCatName.tsx +++ b/client/src/components/Collab/CollabNotesEditableCatName.tsx @@ -25,10 +25,10 @@ export function EditableCatName({ name, onRename }: EditableCatNameProps) { } return ( - { setValue(name); setEditing(true) }} - style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }} + ) } diff --git a/client/src/components/Collab/CollabNotesFilePreviewPortal.tsx b/client/src/components/Collab/CollabNotesFilePreviewPortal.tsx index f1098b8ba..acf48e058 100644 --- a/client/src/components/Collab/CollabNotesFilePreviewPortal.tsx +++ b/client/src/components/Collab/CollabNotesFilePreviewPortal.tsx @@ -28,10 +28,10 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { const openInNewTab = () => openFile(rawUrl).catch(() => {}) return createPortal( -
+
{isImage ? ( /* Image lightbox — floating controls */ -
e.stopPropagation()}> +
e.stopPropagation()}> {authUrl ? {file.original_name} : @@ -46,7 +46,7 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
) : ( /* Document viewer — card with header */ -
e.stopPropagation()}> +
e.stopPropagation()}>
{file.original_name}
diff --git a/client/src/components/Collab/CollabNotesFormModal.tsx b/client/src/components/Collab/CollabNotesFormModal.tsx index e6b6cd2ac..289995c8e 100644 --- a/client/src/components/Collab/CollabNotesFormModal.tsx +++ b/client/src/components/Collab/CollabNotesFormModal.tsx @@ -84,6 +84,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor }} >
- e.stopPropagation()} onSubmit={handleSubmit}> +
{ if (e.target === e.currentTarget) onClose() }}> +

{t('collab.polls.new')}

@@ -122,13 +122,13 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) { {/* Multi choice toggle */}

{item.latest && } - {sizeKb && } + {sizeKb !== null && } {/* The range, not just its lower bound: "TREK 3.2.0+" reads as "and anything newer", which is exactly the claim a `<4.0.0` upper bound denies. */} {(item.trek || item.minTrekVersion) && ( diff --git a/client/src/mobile/screens/atlas/MAtlasSearch.tsx b/client/src/mobile/screens/atlas/MAtlasSearch.tsx index 703e7700c..34bfc0a49 100644 --- a/client/src/mobile/screens/atlas/MAtlasSearch.tsx +++ b/client/src/mobile/screens/atlas/MAtlasSearch.tsx @@ -53,8 +53,18 @@ export default function MAtlasSearch({ const scrimCls = 'm-fade-in fixed inset-0 z-[70] flex flex-col bg-[rgba(16,16,19,.28)] px-[18px] pt-[calc(var(--m-safe-top,12px)+12px)] backdrop-blur-[22px] backdrop-saturate-[1.6]' // theme-lint-disable — fixed scrim value from the design return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>