From ba70ad28b88e0e3bf51a3bbea97c198bd3335a40 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 7 Oct 2026 22:33:58 +0000 Subject: [PATCH] fix(client): spacing and alignment follow the reading direction Arabic runs the app right to left, but margins, paddings, insets, text alignment, borders and corner radii were written for the left side, so icons sat against the wrong edge of their labels, indents and badges pointed the wrong way and amount columns aligned left. They now use the logical forms (ms-, ps-, start-, text-start, marginInlineStart, insetInlineEnd and their CSS equivalents), which are the very same sides in a left to right language. Geometry stays physical on purpose: maps, positions measured from the pointer or a bounding box, time axes, sliders, transforms and print output. Amounts drawn as several spans keep their order in a right to left row, the time picker keeps hours before minutes, and two switches whose thumb slid out of its track in Arabic now move the right way. lint:rtl counts what is still physical per file against a baseline that may only shrink (1512 uses in 370 files before, 319 in 113 now), and CI runs it, so new code reaches for the logical forms. --- .github/workflows/test.yml | 6 + CLAUDE.md | 4 +- client/CLAUDE.md | 1 + client/package.json | 1 + client/scripts/rtl-baseline.json | 115 ++++++++++++ client/scripts/rtl-lint.mjs | 166 ++++++++++++++++++ .../components/Admin/AdminMcpTokensPanel.tsx | 2 +- .../components/Admin/AdminPluginsPanel.tsx | 14 +- client/src/components/Admin/AuditLogPanel.tsx | 2 +- .../Admin/DefaultUserSettingsTab.tsx | 2 +- .../Admin/DevNotificationsPanel.tsx | 6 +- client/src/components/Admin/GitHubPanel.tsx | 4 +- .../components/Admin/GoogleDailyLimitRow.tsx | 4 +- .../Admin/PackingTemplateManager.tsx | 12 +- .../components/Admin/SchoolHolidayCatalog.tsx | 4 +- .../Admin/storage/AdminStoragePanel.tsx | 2 +- .../components/Atlas/AtlasCountryPlaces.tsx | 4 +- .../BackgroundTasks/BackgroundTasksWidget.tsx | 2 +- client/src/components/Budget/BudgetPanel.tsx | 4 +- .../Budget/BudgetPanelCategoryTable.tsx | 6 +- .../Budget/BudgetPanelInlineEditCell.tsx | 2 +- .../Budget/BudgetPanelMemberChips.tsx | 2 +- .../components/Budget/BudgetPanelSummary.tsx | 13 +- client/src/components/Budget/CostsPanel.tsx | 57 +++--- client/src/components/Budget/CostsTable.tsx | 14 +- client/src/components/Budget/CostsToolbar.tsx | 10 +- .../components/Budget/ReceiptPreviewModal.tsx | 2 +- client/src/components/Collab/CollabChat.tsx | 6 +- .../Collab/CollabChatAttachment.tsx | 2 +- .../Collab/CollabChatMessages.test.tsx | 4 +- .../components/Collab/CollabChatMessages.tsx | 18 +- .../src/components/Collab/CollabNotesCard.tsx | 2 +- .../CollabNotesCategorySettingsModal.tsx | 2 +- .../Collab/CollabNotesEditableCatName.tsx | 2 +- .../src/components/Collab/CollabPanelHead.tsx | 2 +- client/src/components/Collab/CollabPolls.tsx | 12 +- .../src/components/Collab/WhatsNextWidget.tsx | 2 +- .../Collections/AddPlaceToCollectionModal.tsx | 12 +- .../Collections/BulkAssignLabelModal.tsx | 2 +- .../Collections/CollectionExportMenu.tsx | 2 +- .../Collections/CollectionPicker.tsx | 12 +- .../CollectionPlaceDetail.test.tsx | 2 +- .../Collections/CollectionPlaceDetail.tsx | 2 +- .../Collections/CopyToTripModal.tsx | 6 +- .../Collections/ImportCollectionModal.tsx | 10 +- .../Collections/ImportFromTripModal.tsx | 10 +- .../components/Collections/LabelManager.tsx | 4 +- .../Collections/MSaveToCollectionSheet.tsx | 2 +- .../Collections/MoveToListModal.tsx | 6 +- .../Collections/SaveToCollectionModal.tsx | 2 +- .../Collections/SaveTripPlacesToListModal.tsx | 6 +- .../Collections/ShareCollectionModal.tsx | 2 +- .../Dawarich/DawarichAtlasDialog.tsx | 2 +- .../Dawarich/DawarichSuggestionsPanel.tsx | 4 +- .../Files/FileManagerAssignModal.tsx | 12 +- .../components/Files/FileManagerFilesView.tsx | 2 +- .../Files/FileManagerImageLightbox.tsx | 2 +- .../src/components/Files/FileManagerRow.tsx | 2 +- .../components/Files/FileManagerToolbar.tsx | 2 +- .../Files/docsync/DocSyncBinding.tsx | 2 +- .../components/Files/docsync/DocSyncFlow.tsx | 2 +- .../components/Files/docsync/DocSyncPanel.tsx | 4 +- .../Files/docsync/DocSyncScopeModal.tsx | 6 +- client/src/components/Help/HelpBadge.tsx | 4 +- client/src/components/Help/HelpGuideView.tsx | 12 +- client/src/components/Help/HelpHome.tsx | 10 +- client/src/components/Help/HelpLightbox.tsx | 2 +- client/src/components/Help/HelpNav.tsx | 24 +-- client/src/components/Help/HelpPanel.tsx | 2 +- .../Journey/ContributorInviteDialog.tsx | 8 +- client/src/components/Journey/JournalBody.tsx | 8 +- .../components/Journey/JourneyDayDawarich.tsx | 2 +- .../src/components/Journey/JourneyDayJump.tsx | 4 +- .../JourneyDetailPageAddTripDialog.tsx | 4 +- .../Journey/JourneyDetailPageDatePicker.tsx | 4 +- .../Journey/JourneyDetailPageEntryCard.tsx | 12 +- .../Journey/JourneyDetailPageEntryEditor.tsx | 14 +- .../Journey/JourneyDetailPageGalleryView.tsx | 6 +- .../Journey/JourneyDetailPageMapView.tsx | 4 +- .../Journey/JourneyDetailPagePhotoGrid.tsx | 2 +- .../JourneyDetailPageProviderPicker.tsx | 16 +- .../JourneyDetailPageSettingsDialog.tsx | 2 +- .../JourneyDetailPageVerdictSection.tsx | 4 +- .../components/Journey/JourneyEntryCover.tsx | 8 +- .../Journey/JourneyShareSection.tsx | 4 +- .../components/Journey/MobileEntryView.tsx | 2 +- .../components/Journey/MobileMapTimeline.tsx | 10 +- .../src/components/Journey/PhotoLightbox.tsx | 2 +- client/src/components/Layout/BottomNav.tsx | 2 +- client/src/components/Layout/DemoBanner.tsx | 5 +- .../Layout/InAppNotificationBell.tsx | 6 +- client/src/components/Layout/MobileTopBar.tsx | 10 +- client/src/components/Layout/Navbar.tsx | 4 +- client/src/components/Layout/PageSidebar.tsx | 4 +- client/src/components/Map/PlaceHoverCard.tsx | 2 +- .../src/components/Map/TripRouteOverview.tsx | 2 +- .../Notifications/InAppNotificationItem.tsx | 2 +- .../src/components/OAuth/ScopeGroupPicker.tsx | 4 +- .../Packing/ApplyTemplateButton.tsx | 4 +- .../components/Packing/PackingExportMenu.tsx | 4 +- .../Packing/PackingListPanelBagCard.tsx | 2 +- .../Packing/PackingListPanelBagSidebar.tsx | 6 +- .../Packing/PackingListPanelCategoryGroup.tsx | 10 +- .../Packing/PackingListPanelHeader.tsx | 6 +- .../Packing/PackingListPanelImportModal.tsx | 2 +- .../Packing/PackingListPanelItemRow.tsx | 8 +- .../Packing/PackingListPanelQuantityInput.tsx | 2 +- .../Packing/PackingListPanelViewTabs.tsx | 2 +- .../src/components/Packing/PackingPopover.tsx | 2 +- .../Packing/PackingShareControl.tsx | 6 +- .../src/components/Planner/AddressInput.tsx | 4 +- .../Planner/AirTrailImportModal.tsx | 4 +- .../src/components/Planner/AirportSelect.tsx | 4 +- .../components/Planner/BookingFileButtons.tsx | 4 +- .../Planner/BookingLinkAndFiles.tsx | 4 +- .../src/components/Planner/DayDetailPanel.tsx | 6 +- .../src/components/Planner/DayPlanSidebar.tsx | 20 +-- .../DayPlanSidebarMobileAddPlaceButton.tsx | 2 +- .../DayPlanSidebarTransportDetailModal.tsx | 2 +- .../components/Planner/DayReorderPopup.tsx | 2 +- .../components/Planner/FileImportModal.tsx | 2 +- .../src/components/Planner/LocationSelect.tsx | 4 +- .../components/Planner/PlaceContactFields.tsx | 8 +- .../components/Planner/PlaceDetailsColumn.tsx | 6 +- .../src/components/Planner/PlaceFormModal.tsx | 4 +- .../src/components/Planner/PlaceInspector.tsx | 24 +-- .../Planner/PlacesBulkCategoryModal.tsx | 2 +- .../Planner/PlacesSidebarHeader.tsx | 26 +-- .../Planner/PlacesSidebarMobileDayPicker.tsx | 14 +- .../Planner/PlacesSidebarSelectionBar.tsx | 2 +- .../components/Planner/ReservationModal.tsx | 2 +- .../Planner/TransitJourneyModal.tsx | 4 +- .../components/Planner/TransitSearchPanel.tsx | 14 +- .../src/components/Planner/TransportModal.tsx | 10 +- .../src/components/Planner/TravelerPicker.tsx | 16 +- .../components/Planner/TripExportModal.tsx | 2 +- .../components/Planner/TripWarningsBanner.tsx | 2 +- .../Planner/bookings/BookingDetailDialog.tsx | 8 +- .../Planner/bookings/BookingsHeader.tsx | 10 +- .../Planner/bookings/BookingsList.tsx | 4 +- .../Planner/bookings/BookingsTimeline.tsx | 2 +- .../Planner/bookings/bookingParts.tsx | 16 +- .../src/components/Planner/transitDisplay.tsx | 4 +- .../Plugins/PluginContributions.tsx | 4 +- .../src/components/Roadtrip/ChargingInfo.tsx | 6 +- client/src/components/Settings/AccountTab.tsx | 4 +- .../components/Settings/ApiKeysSection.tsx | 6 +- .../Settings/AppearanceSettingsTab.tsx | 4 +- .../Settings/DawarichConnectionSection.tsx | 2 +- .../Settings/DisplaySettingsTab.tsx | 2 +- .../components/Settings/IntegrationsTab.tsx | 2 +- .../components/Settings/MapSettingsTab.tsx | 6 +- .../components/Settings/NotificationsTab.tsx | 2 +- .../Settings/PluginActivityPanel.tsx | 10 +- .../components/Settings/PluginSettingsTab.tsx | 2 +- .../src/components/Settings/settingsKit.tsx | 2 +- client/src/components/Studio/StudioExport.tsx | 2 +- .../SystemNotices/SystemNoticeBanner.tsx | 10 +- .../SystemNotices/SystemNoticeModal.tsx | 8 +- .../SystemNotices/releaseNotice.css | 8 +- client/src/components/Todo/TodoListPanel.tsx | 4 +- client/src/components/Todo/TodoRow.tsx | 2 +- .../src/components/Tours/TourDetailDialog.tsx | 6 +- client/src/components/Tours/ToursSidebar.tsx | 8 +- .../Tours/planner/TourPlannerPanels.tsx | 4 +- client/src/components/Tours/tourParts.tsx | 2 +- .../src/components/Trips/TripDateReview.tsx | 2 +- client/src/components/Trips/TripFormModal.tsx | 2 +- .../src/components/Trips/TripMembersModal.tsx | 2 +- .../src/components/Trips/TripShareDialog.tsx | 2 +- client/src/components/Vacay/VacayCalendar.tsx | 8 +- .../src/components/Vacay/VacayMonthCard.tsx | 9 +- client/src/components/Vacay/VacayPersons.tsx | 4 +- client/src/components/Vacay/VacaySettings.tsx | 22 +-- .../components/Vacay/VacaySharedCalendars.tsx | 4 +- client/src/components/Vacay/VacayStats.tsx | 4 +- client/src/components/shared/ContextMenu.tsx | 2 +- client/src/components/shared/CustomSelect.tsx | 4 +- .../components/shared/CustomTimePicker.tsx | 9 +- client/src/components/shared/DialogShell.tsx | 4 +- .../src/components/shared/NavigationMenu.tsx | 2 +- .../components/shared/PlaceAvatarUpload.tsx | 4 +- .../shared/PublicLanguagePicker.tsx | 2 +- client/src/components/shared/StarRating.tsx | 4 +- client/src/components/shared/dialogParts.tsx | 2 +- client/src/index.css | 17 +- client/src/mobile/components/MBottomNav.tsx | 6 +- .../src/mobile/components/MDropdownPanel.tsx | 2 +- client/src/mobile/components/MGlassBar.tsx | 2 +- client/src/mobile/components/MListRow.tsx | 2 +- client/src/mobile/components/MSheet.tsx | 4 +- client/src/mobile/components/MToggle.tsx | 2 +- client/src/mobile/screens/admin/MAdmin.tsx | 4 +- .../screens/admin/MAdminAddonManager.tsx | 6 +- .../screens/admin/MAdminBackupPanel.tsx | 4 +- .../admin/MAdminDefaultUserSettings.tsx | 2 +- .../admin/MAdminDevNotificationsPanel.tsx | 2 +- .../screens/admin/MAdminGitHubPanel.tsx | 8 +- .../admin/MAdminPackingTemplateManager.tsx | 4 +- .../screens/admin/MAdminPluginsPanel.tsx | 28 +-- .../screens/admin/MAdminSettingsSection.tsx | 2 +- client/src/mobile/screens/admin/MAdminUi.tsx | 4 +- .../screens/admin/MAdminUsersSection.tsx | 4 +- .../screens/admin/MApiProviderBlocks.tsx | 8 +- client/src/mobile/screens/atlas/MAtlas.tsx | 2 +- .../screens/atlas/MAtlasBucketSheet.tsx | 10 +- .../screens/atlas/MAtlasCountryPopup.tsx | 4 +- .../src/mobile/screens/atlas/MAtlasSearch.tsx | 2 +- .../mobile/screens/atlas/MAtlasStatsCard.tsx | 2 +- .../screens/collections/MCollAddSheet.tsx | 6 +- .../collections/MCollCategoryPicker.tsx | 6 +- .../screens/collections/MCollEditSheet.tsx | 4 +- .../screens/collections/MCollLabelsSheet.tsx | 2 +- .../collections/MCollListPickerSheet.tsx | 2 +- .../screens/collections/MCollPlaceRow.tsx | 2 +- .../screens/collections/MCollPlaceSheet.tsx | 2 +- .../screens/collections/MCollShareSheet.tsx | 8 +- .../collections/MCollTripPickerSheet.tsx | 2 +- .../screens/collections/MCollections.tsx | 18 +- .../mobile/screens/dashboard/MDashWidgets.tsx | 8 +- .../mobile/screens/dashboard/MDashboard.tsx | 18 +- .../screens/dashboard/MNewTripSheet.tsx | 4 +- .../mobile/screens/dashboard/MUserMenu.tsx | 2 +- .../src/mobile/screens/journey/MJourney.tsx | 8 +- .../screens/journey/MJourneyCreateSheet.tsx | 4 +- .../mobile/screens/journey/MJourneyDetail.tsx | 6 +- .../screens/journey/MJourneyEntrySheet.tsx | 16 +- .../screens/journey/MJourneySettingsSheet.tsx | 4 +- .../screens/settings/MScopeGroupPicker.tsx | 8 +- .../screens/settings/MSetPickerSheet.tsx | 2 +- .../src/mobile/screens/settings/MSettings.tsx | 4 +- .../screens/settings/MSettingsAccount.tsx | 4 +- .../screens/settings/MSettingsAppearance.tsx | 6 +- .../mobile/screens/settings/MSettingsMap.tsx | 2 +- .../mobile/screens/settings/MSettingsMcp.tsx | 4 +- .../mobile/screens/settings/MSettingsUi.tsx | 2 +- client/src/mobile/screens/trip/MTripShell.tsx | 14 +- .../src/mobile/screens/trip/map/MMapArea.tsx | 6 +- .../screens/trip/places/MPlacesBrowser.tsx | 8 +- .../trip/places/MPlacesBulkCategorySheet.tsx | 4 +- .../trip/places/MPlacesFilterControls.tsx | 2 +- .../places/MPlacesSaveToCollectionSheet.tsx | 2 +- .../screens/trip/plan/MPlanTimeline.tsx | 12 +- .../screens/trip/plan/MPlanTimelineRows.tsx | 10 +- .../screens/trip/roadtrip/MRoadtripTab.tsx | 8 +- .../screens/trip/roadtrip/MRtCorridorBar.tsx | 2 +- .../trip/roadtrip/MRtCorridorSheet.tsx | 2 +- .../screens/trip/roadtrip/MRtDraftSheet.tsx | 2 +- .../screens/trip/roadtrip/MRtStaySheet.tsx | 4 +- .../screens/trip/roadtrip/MRtStopSheet.tsx | 2 +- .../screens/trip/sheets/ImpFileStep.tsx | 2 +- .../trip/sheets/MAccommodationSheet.tsx | 2 +- .../trip/sheets/MBrowseActionsSheet.tsx | 4 +- .../mobile/screens/trip/sheets/MCostSheet.tsx | 20 +-- .../mobile/screens/trip/sheets/MDaySheet.tsx | 10 +- .../screens/trip/sheets/MExportSheet.tsx | 2 +- .../screens/trip/sheets/MImportSheet.tsx | 2 +- .../screens/trip/sheets/MLinkedCosts.tsx | 6 +- .../mobile/screens/trip/sheets/MMehrSheet.tsx | 4 +- .../mobile/screens/trip/sheets/MNoteSheet.tsx | 2 +- .../screens/trip/sheets/MPlaceSheet.tsx | 16 +- .../screens/trip/sheets/MReservationSheet.tsx | 6 +- .../trip/sheets/MTransportFormSheet.tsx | 4 +- .../screens/trip/sheets/MTransportSheet.tsx | 2 +- .../screens/trip/sheets/PlFileAttach.tsx | 2 +- .../screens/trip/sheets/PlPlaceSearch.tsx | 6 +- .../screens/trip/sheets/PlSheetChrome.tsx | 2 +- .../mobile/screens/trip/tabs/MBagsSheet.tsx | 8 +- .../mobile/screens/trip/tabs/MBookingsTab.tsx | 6 +- .../mobile/screens/trip/tabs/MCollabChat.tsx | 14 +- .../mobile/screens/trip/tabs/MCollabNotes.tsx | 10 +- .../mobile/screens/trip/tabs/MCollabPolls.tsx | 6 +- .../mobile/screens/trip/tabs/MCostsTab.tsx | 38 ++-- .../screens/trip/tabs/MDocSyncSheet.tsx | 10 +- .../screens/trip/tabs/MFileLightbox.tsx | 2 +- .../screens/trip/tabs/MFileLinkSheet.tsx | 2 +- .../mobile/screens/trip/tabs/MFilesTab.tsx | 4 +- .../screens/trip/tabs/MPackItemSheet.tsx | 2 +- .../screens/trip/tabs/MPackingListTab.tsx | 22 +-- .../mobile/screens/trip/tabs/MTodoListTab.tsx | 6 +- .../screens/trip/tabs/MTransportsTab.tsx | 6 +- .../mobile/screens/trip/tabs/tabChrome.tsx | 8 +- client/src/mobile/screens/vacay/MVacay.tsx | 4 +- .../screens/vacay/MVacayInviteSheet.tsx | 6 +- .../src/mobile/screens/vacay/MVacayMonth.tsx | 4 +- .../screens/vacay/MVacaySettingsSheet.tsx | 14 +- .../mobile/screens/vacay/MVacayShareSheet.tsx | 4 +- client/src/pages/AtlasPage.tsx | 15 +- client/src/pages/ForgotPasswordPage.tsx | 6 +- client/src/pages/HelpPage.test.tsx | 2 +- client/src/pages/HelpPage.tsx | 18 +- client/src/pages/InAppNotificationsPage.tsx | 2 +- client/src/pages/JourneyDetailPage.tsx | 8 +- client/src/pages/JourneyPage.tsx | 12 +- client/src/pages/JourneyPublicPage.tsx | 14 +- client/src/pages/LoginPage.tsx | 26 +-- client/src/pages/LoginPage.wiring.test.tsx | 2 +- client/src/pages/OAuthAuthorizePage.tsx | 4 +- client/src/pages/ResetPasswordPage.tsx | 12 +- client/src/pages/TripPlannerPage.tsx | 20 +-- client/src/pages/VacayPage.tsx | 2 +- client/src/pages/admin/AdminSettingsTab.tsx | 8 +- client/src/pages/admin/AdminStatCard.tsx | 2 +- client/src/pages/admin/AdminUserModals.tsx | 4 +- client/src/pages/admin/AdminUsersTab.tsx | 6 +- client/src/pages/atlas/AtlasCountrySearch.tsx | 6 +- client/src/pages/sharedTrip/SharedChrome.tsx | 4 +- client/src/pages/sharedTrip/SharedDayCard.tsx | 6 +- .../src/pages/sharedTrip/SharedListsViews.tsx | 4 +- .../pages/sharedTrip/SharedUnplannedCard.tsx | 2 +- client/src/styles/collections.css | 40 ++--- client/src/styles/dashboard.css | 50 +++--- client/src/styles/studio.css | 56 +++--- .../unit/mobile/dashboard/MDashboard.test.tsx | 4 +- .../journey/MJourneyEntrySheet.test.tsx | 4 +- 315 files changed, 1300 insertions(+), 1004 deletions(-) create mode 100644 client/scripts/rtl-baseline.json create mode 100644 client/scripts/rtl-lint.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9300ba259..7b0ff6a42 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -232,6 +232,12 @@ jobs: - name: Page pattern check run: cd client && npm run lint:pages + - name: Reading direction check + # Arabic runs right to left. A file may hold no more physical + # left/right classes and styles than scripts/rtl-baseline.json allows, + # so new code uses the logical start/end forms (scripts/rtl-lint.mjs). + run: cd client && npm run lint:rtl + plugin-sdk-quality: name: Plugin SDK Types runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index d88aa9aea..4beea8616 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,7 @@ npm run test:cov # coverage (lcov) for all fou cd client && npm run test # client vitest run cd client && npm run e2e # Playwright (local only) cd client && npm run lint:pages # enforce the Page pattern (CI gate) +cd client && npm run lint:rtl # physical left/right sides may only shrink per file (CI gate) cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only) npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests) ``` @@ -77,6 +78,7 @@ Offline-first, with a layered data flow. A Page never owns state directly: - **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use()` hook; it must not call React state/effect/memo hooks itself. - **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly. - **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` flags bypasses; suppress intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment. +- **Reading direction**: Arabic runs the app right to left, so spacing, insets and alignment that follow the text use the logical forms (`ms-`/`me-`/`ps-`/`pe-`/`start-`/`end-`/`text-start`/`border-s`/`rounded-s`, `marginInlineStart`, `insetInlineEnd`, `textAlign: 'start'`). `lint:rtl` counts the physical ones per file against `client/scripts/rtl-baseline.json`, which may only shrink; geometry that is physical on purpose in new code (maps, measured positions, time axes) carries an `rtl-lint-disable` comment. - **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and uploads; `prebuild` generates icons. ## Shared contracts & i18n @@ -106,7 +108,7 @@ These principles come out of a full-repo audit and shape all new code: ## Quality gates in CI -`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint`, Playwright, `check:gl-split` — run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. +`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `lint:rtl`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint`, Playwright, `check:gl-split` — run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. **SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it: diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 49fcc8518..e2d41f49c 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -10,6 +10,7 @@ npm run build # prebuild generates PWA icons, then vite build npm run typecheck # tsc --noEmit (CI) npm run lint # eslint . (lint:check in CI) npm run lint:pages # enforce the Page pattern (CI gate) +npm run lint:rtl # physical left/right sides may only shrink per file (CI gate; --list shows them, --update lowers the baseline) npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage npm run e2e # Playwright (local only; e2e:report opens the last report) npm run shots # Playwright screenshot project (shots:promote to accept) diff --git a/client/package.json b/client/package.json index 47202b784..9da0bacbe 100644 --- a/client/package.json +++ b/client/package.json @@ -18,6 +18,7 @@ "lint": "eslint .", "lint:check": "eslint .", "lint:pages": "node scripts/check-page-pattern.mjs", + "lint:rtl": "node scripts/rtl-lint.mjs", "check:gl-split": "node scripts/check-gl-split.mjs", "theme:lint": "node scripts/theme-lint.mjs", "theme:lint:strict": "node scripts/theme-lint.mjs --strict", diff --git a/client/scripts/rtl-baseline.json b/client/scripts/rtl-baseline.json new file mode 100644 index 000000000..82e209e06 --- /dev/null +++ b/client/scripts/rtl-baseline.json @@ -0,0 +1,115 @@ +{ + "components/Admin/AdminPluginsPanel.tsx": 2, + "components/Budget/BudgetPanelMemberChips.tsx": 6, + "components/Budget/BudgetPanelPieChart.tsx": 1, + "components/Budget/BudgetPanelSummary.tsx": 2, + "components/Budget/CostsPanel.tsx": 3, + "components/Budget/ReceiptPreviewModal.tsx": 2, + "components/Collab/CollabChatEmojiPicker.tsx": 3, + "components/Collab/CollabChatReactionBadge.tsx": 3, + "components/Collab/CollabChatReactionMenu.tsx": 1, + "components/Collab/CollabPolls.tsx": 1, + "components/Collections/CollectionPlaceDetail.tsx": 2, + "components/Files/FileManagerAvatarChip.tsx": 3, + "components/Journey/JourneyDayJump.tsx": 1, + "components/Journey/JourneyDetailPageDatePicker.tsx": 1, + "components/Journey/JourneyDetailPageEntryEditor.tsx": 1, + "components/Journey/JourneyDetailPageGalleryView.tsx": 1, + "components/Journey/JourneyDetailPagePhotoGrid.tsx": 1, + "components/Journey/JourneyDetailPageProviderPicker.tsx": 1, + "components/Journey/JourneyEntryCover.tsx": 1, + "components/Journey/JourneyMap.tsx": 1, + "components/Journey/JourneyMapGL.tsx": 2, + "components/Journey/journeyPhotoLayer.ts": 1, + "components/Journey/MobileEntryView.tsx": 1, + "components/Journey/PhotoLightbox.tsx": 3, + "components/Layout/Navbar.tsx": 1, + "components/Layout/OfflineBanner.tsx": 1, + "components/Map/DawarichTrailPill.tsx": 1, + "components/Map/hazardPopup.css": 2, + "components/Map/hazardPopup.ts": 1, + "components/Map/LocationButton.tsx": 1, + "components/Map/locationMarkerMapbox.ts": 2, + "components/Map/MapTogglePill.tsx": 1, + "components/Map/MapView.tsx": 6, + "components/Map/MapViewGL.tsx": 8, + "components/Map/nightPauseMarker.ts": 4, + "components/Map/NightPauseTooltip.tsx": 1, + "components/Map/PlaceHoverCard.tsx": 1, + "components/Map/PoiCategoryPill.tsx": 1, + "components/Map/ratingBadge.ts": 1, + "components/Packing/PackingListPanelBagCard.tsx": 1, + "components/Packing/PackingListPanelCategoryGroup.tsx": 3, + "components/Packing/PackingListPanelItemRow.tsx": 2, + "components/Packing/PackingShareControl.tsx": 3, + "components/PDF/tripMapImage.ts": 1, + "components/PDF/TripPDF.tsx": 8, + "components/Planner/bookings/BookingsTimeline.tsx": 24, + "components/Planner/DayDetailPanel.tsx": 1, + "components/Planner/PlaceInspector.tsx": 1, + "components/Planner/TransitSearchPanel.tsx": 3, + "components/Roadtrip/AnchoredPopover.tsx": 2, + "components/Settings/IntegrationsTab.tsx": 2, + "components/Settings/PluginSettingsTab.tsx": 2, + "components/Settings/ToggleSwitch.tsx": 1, + "components/shared/ContextMenu.tsx": 3, + "components/shared/CustomDateTimePicker.tsx": 2, + "components/shared/CustomSelect.tsx": 4, + "components/shared/CustomTimePicker.tsx": 3, + "components/shared/DetailShell.tsx": 1, + "components/shared/dialogParts.tsx": 1, + "components/shared/ElevationProfile.tsx": 2, + "components/shared/NavigationMenu.tsx": 2, + "components/shared/PlaceAvatarUpload.tsx": 3, + "components/shared/PublicLanguagePicker.tsx": 1, + "components/shared/SlidingTabs.tsx": 3, + "components/shared/StarRating.tsx": 3, + "components/shared/Toast.tsx": 1, + "components/shared/Tooltip.tsx": 2, + "components/Studio/BookSheetsView.tsx": 3, + "components/Studio/PageNumbers.tsx": 1, + "components/Studio/PeerCursors.tsx": 1, + "components/Studio/SpreadView.tsx": 8, + "components/Studio/StudioCanvas.tsx": 22, + "components/Studio/StudioElementsPanel.tsx": 1, + "components/Studio/StudioExport.tsx": 1, + "components/Studio/StudioInspector.tsx": 1, + "components/Studio/StudioSavedLayouts.tsx": 1, + "components/Studio/StudioSidebar.tsx": 3, + "components/Studio/TravelElements.tsx": 4, + "components/Studio/TravelPreview.tsx": 1, + "components/SystemNotices/releaseNotice.css": 2, + "components/SystemNotices/ReleaseNoticeVisuals.tsx": 3, + "components/Vacay/VacayCalendar.tsx": 3, + "components/Vacay/VacayMonthCard.tsx": 7, + "components/Vacay/VacaySettings.tsx": 1, + "help/contexts/tripMap.ts": 1, + "hooks/useAnchoredPosition.ts": 3, + "hooks/useElementRect.ts": 3, + "mobile/components/MSegmented.tsx": 3, + "mobile/components/MSheet.tsx": 1, + "mobile/screens/dashboard/MDashboard.tsx": 1, + "mobile/screens/journey/MJourneyDetail.tsx": 3, + "mobile/screens/journey/MJourneyEntrySheet.tsx": 1, + "mobile/screens/trip/map/MMapArea.tsx": 3, + "mobile/screens/trip/MTripShell.tsx": 3, + "mobile/screens/trip/tabs/MCollabPolls.tsx": 1, + "mobile/screens/trip/tabs/MFileLightbox.tsx": 2, + "mobile/screens/vacay/MVacay.tsx": 1, + "mobile/screens/vacay/MVacayMonth.tsx": 1, + "pages/admin/AdminNotificationsTab.tsx": 1, + "pages/admin/AdminSettingsTab.tsx": 1, + "pages/atlas/AtlasLayerToggle.tsx": 1, + "pages/AtlasPage.tsx": 2, + "pages/JourneyDetailPage.tsx": 1, + "pages/JourneyPublicPage.tsx": 3, + "pages/LoginPage.tsx": 23, + "pages/sharedTrip/SharedMap.tsx": 4, + "pages/TripPlannerPage.tsx": 17, + "pages/VacayPage.tsx": 1, + "styles/collections.css": 1, + "styles/studio.css": 7, + "utils/formatters.ts": 1, + "utils/mapViewport.ts": 4, + "utils/touchDragBridge.ts": 1 +} diff --git a/client/scripts/rtl-lint.mjs b/client/scripts/rtl-lint.mjs new file mode 100644 index 000000000..0b68a1696 --- /dev/null +++ b/client/scripts/rtl-lint.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/* + * lint:rtl: keeps the layout following the reading direction. + * + * Arabic runs the app with dir="rtl". A physical class or style (ml-2, + * text-left, paddingLeft, left: 0) stays on the same side in either direction, + * so a gap, an indent or an alignment that follows the text in English lands + * on the wrong side in Arabic. The logical forms (ms-2, text-start, + * paddingInlineStart, insetInlineStart) follow the direction, and in a + * left-to-right language they are exactly the physical ones. + * + * Some things are physical on purpose: a map, a chart axis, a drag handle, a + * popover placed at measured coordinates. Those stay. Every file's physical + * uses are counted against scripts/rtl-baseline.json, and the check fails when + * a file holds more than its entry (a file without an entry holds none), so + * new code reaches for the logical forms. A line that is physical on purpose + * in new code carries `rtl-lint-disable` in a comment. + * + * npm run lint:rtl check against the baseline (CI) + * npm run lint:rtl -- --list print every counted use, file by file + * npm run lint:rtl -- --update lower the baseline to what the files hold now; + * it never raises an entry + */ +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SRC = fileURLToPath(new URL('../src', import.meta.url)) +const BASELINE = fileURLToPath(new URL('./rtl-baseline.json', import.meta.url)) + +// Tailwind utilities that name a physical side, with any variant prefix +// (sm:, hover:, group-hover:) and the negative form (-ml-2). +const CLASS = new RegExp( + String.raw`(? { + let code = '' + let rest = line + while (rest) { + if (inBlock) { + const end = rest.indexOf('*/') + if (end < 0) return code + rest = rest.slice(end + 2) + inBlock = false + continue + } + const block = rest.indexOf('/*') + const lineComment = rest.search(/(?= 0 && (block < 0 || lineComment < block)) return code + rest.slice(0, lineComment) + if (block < 0) return code + rest + code += rest.slice(0, block) + rest = rest.slice(block + 2) + inBlock = true + } + return code + }) +} + +/** Every physical use in one file, as `line: match`. */ +export function physicalUses(source, isCss) { + const uses = [] + const lines = source.split('\n') + codeLines(source).forEach((code, i) => { + if (lines[i].includes(DISABLE)) return + const patterns = isCss ? [CSS] : [CLASS, STYLE] + for (const re of patterns) for (const m of code.match(re) ?? []) uses.push(`${i + 1}: ${m.trim()}`) + }) + return uses +} + +function scan() { + const counts = {} + const listed = {} + for (const file of walk(SRC)) { + const uses = physicalUses(readFileSync(file, 'utf8'), file.endsWith('.css')) + if (!uses.length) continue + const key = relative(SRC, file).replace(/\\/g, '/') + counts[key] = uses.length + listed[key] = uses + } + return { counts, listed } +} + +function readBaseline() { + try { + return JSON.parse(readFileSync(BASELINE, 'utf8')) + } catch { + return {} + } +} + +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1] +if (isCli) { + const { counts, listed } = scan() + const baseline = readBaseline() + + if (process.argv.includes('--list')) { + for (const [file, uses] of Object.entries(listed).sort()) { + console.log(`${file} (${uses.length}, baseline ${baseline[file] ?? 0})`) + for (const use of uses) console.log(` ${use}`) + } + } + + if (process.argv.includes('--update')) { + const lowered = {} + for (const [file, allowed] of Object.entries(baseline)) { + const now = counts[file] ?? 0 + if (now > 0) lowered[file] = Math.min(allowed, now) + } + const sorted = Object.fromEntries(Object.entries(lowered).sort(([a], [b]) => a.localeCompare(b))) + writeFileSync(BASELINE, JSON.stringify(sorted, null, 2) + '\n') + } + + const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0)) + const total = Object.values(counts).reduce((a, b) => a + b, 0) + const allowed = Object.values(baseline).reduce((a, b) => a + b, 0) + const lowerable = Object.entries(baseline).filter(([file, n]) => (counts[file] ?? 0) < n) + + for (const [file, n] of grown) { + console.error( + `FAIL ${file}: ${n} physical side(s), baseline ${baseline[file] ?? 0}. ` + + 'Use ms-/me-/ps-/pe-/start-/end-/text-start/text-end/border-s/border-e/rounded-s/rounded-e ' + + 'or marginInlineStart/paddingInlineEnd/insetInlineStart/textAlign: start. ' + + `If the side is geometry, mark the line with ${DISABLE}.`, + ) + } + if (lowerable.length && !process.argv.includes('--update')) { + console.log(`${lowerable.length} file(s) now hold fewer physical sides than the baseline: run with --update to lower it.`) + } + console.log(`rtl: ${total} physical side(s) in ${Object.keys(counts).length} file(s), baseline allows ${allowed}`) + if (grown.length) process.exit(1) +} diff --git a/client/src/components/Admin/AdminMcpTokensPanel.tsx b/client/src/components/Admin/AdminMcpTokensPanel.tsx index a23a96234..73c84e1c8 100644 --- a/client/src/components/Admin/AdminMcpTokensPanel.tsx +++ b/client/src/components/Admin/AdminMcpTokensPanel.tsx @@ -56,7 +56,7 @@ function ListState({ loading, icon, text }: { loading: boolean; icon: ReactNode; /** One fact on the right of a row: an eyebrow over its value. */ function Meta({ label, children }: { label: string; children: ReactNode }) { return ( -
+
{label}
{children}
diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 38cdf3c12..eab547c67 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -1176,7 +1176,7 @@ function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMe {options.map(([v, lbl]) => ( ))} @@ -1215,7 +1215,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn - +
@@ -1311,7 +1311,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn {t('admin.plugins.updateTo', { version: latestVer })} )} - + {p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')} @@ -1444,11 +1444,11 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
{item.reviewedAt && ( - + }>{t('admin.plugins.reviewed')} )} - +
@@ -2111,7 +2111,7 @@ function KeyRow({ label, value, highlight }: { label: string; value: string; hig return (
{label} - {value} + {value}
) } @@ -2249,7 +2249,7 @@ function SecurityInfo({ t }: { t: T }) { icon={Lock} title={( diff --git a/client/src/components/Admin/AuditLogPanel.tsx b/client/src/components/Admin/AuditLogPanel.tsx index c3c73cb85..093ebc4e3 100644 --- a/client/src/components/Admin/AuditLogPanel.tsx +++ b/client/src/components/Admin/AuditLogPanel.tsx @@ -21,7 +21,7 @@ interface AuditLogPanelProps { serverTimezone?: string } -const TH = 'whitespace-nowrap px-3.5 py-2.5 text-left font-geist font-bold uppercase tracking-[.08em] text-content-faint' +const TH = 'whitespace-nowrap px-3.5 py-2.5 text-start font-geist font-bold uppercase tracking-[.08em] text-content-faint' const TD = 'px-3.5 py-2.5 align-top' const CODE = 'font-geist tabular-nums' diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index 84a2d200c..e7d9a7269 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -172,7 +172,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement { isSet(field) ? ( + )} {tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')} @@ -257,13 +257,13 @@ export default function PackingTemplateManager() { {/* Expanded content: the template's categories as the packing list draws them */} {expanded && ( -
+
{categories.map(cat => { const catItems = items.filter(i => i.category_id === cat.id) return (
{/* Category header */} -
+
{editingCatId === cat.id ? ( setEditCatName(e.target.value)} onBlur={() => handleRenameCategory(cat.id)} @@ -292,7 +292,7 @@ export default function PackingTemplateManager() { {(catItems.length > 0 || addingItemToCatId === cat.id) && (
{catItems.map(item => ( -
+
{editingItemId === item.id ? ( <> setEditItemName(e.target.value)} @@ -328,7 +328,7 @@ export default function PackingTemplateManager() { {/* Add item inline */} {addingItemToCatId === cat.id && ( -
+
setNewItemName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) void handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }} placeholder={t('admin.packingTemplates.itemName')} diff --git a/client/src/components/Admin/SchoolHolidayCatalog.tsx b/client/src/components/Admin/SchoolHolidayCatalog.tsx index 46bcc00d9..bd348db0b 100644 --- a/client/src/components/Admin/SchoolHolidayCatalog.tsx +++ b/client/src/components/Admin/SchoolHolidayCatalog.tsx @@ -82,8 +82,8 @@ function Catalog() { {regions.length === 0 ?
{t('schoolCatalog.empty')}
: - {regions.map(region =>
-
-
{bigMoney(totals.outstanding, 16, 'var(--c-ink3)')}
+
{bigMoney(totals.outstanding, 16, 'var(--c-ink3)')}
@@ -739,7 +740,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0) return (
- {!dayFilter &&
{g.day}{t('costs.spent', { amount: fmt(dtot) })}
} + {!dayFilter &&
{g.day}{t('costs.spent', { amount: fmt(dtot) })}
}
{g.entries.map(en => en.kind === 'expense' ? {ExpenseRow({ e: en.e })} : {SettlementRow({ s: en.s })})}
@@ -819,7 +820,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {/* The category rides the card edge as a tab, the way the mobile card does, so it stops taking up a slot inside the row itself. */} {!isMobile && ( - @@ -886,7 +887,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{payers.map(p => ( - + {fmt(booked(p.amount, e))} @@ -929,12 +930,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const chip = 'inline-flex items-center gap-[5px] rounded-full bg-surface-tertiary text-content-secondary' return (
- {t('costs.split')} + {t('costs.split')} {shown.map(m => { const share = fmt(shareOf(e, m.user_id)) return ( - + {share} {m.paid ? : null} @@ -960,7 +961,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
@@ -1694,7 +1695,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const PERSON_LIST = 'divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card' const PERSON_ROW = 'grid min-h-[48px] grid-cols-[minmax(0,1fr)_130px] items-center gap-2.5 px-3 py-1.5' const AMOUNT_BOX = 'flex items-center gap-1 rounded-[8px] border border-edge bg-surface-input px-2.5' - const AMOUNT_INPUT = 'w-full border-0 bg-transparent py-2 text-right font-semibold text-content outline-none dark:bg-transparent' + const AMOUNT_INPUT = 'w-full border-0 bg-transparent py-2 text-end font-semibold text-content outline-none dark:bg-transparent' const nameOf = (p: TripMember) => (p.id === me ? t('costs.you') : p.username) // Who is in, shown as a real box to tick instead of a row that only fades. @@ -1741,7 +1742,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const on = payerId === p.id return (
- {t('costs.ticketSplitting')} + {t('costs.ticketSplitting')} {people.map((p, pIdx) => { const active = item.participants.has(p.id) return ( @@ -1862,7 +1863,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo role="checkbox" aria-checked={active} onClick={() => handleToggleItemParticipant(item.id, p.id)} - className={`inline-flex items-center gap-1.5 rounded-full border py-[3px] pl-1 pr-2 font-medium ${active ? 'border-[color:var(--text-primary)] bg-surface-card text-content' : 'border-edge bg-surface-secondary text-content-faint'}`} + className={`inline-flex items-center gap-1.5 rounded-full border py-[3px] ps-1 pe-2 font-medium ${active ? 'border-[color:var(--text-primary)] bg-surface-card text-content' : 'border-edge bg-surface-secondary text-content-faint'}`} style={fs(11.5)} > {personFace(p, pIdx, active, 16)} @@ -1897,14 +1898,14 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo return (
{!on ? excludedNote : splitMode === 'equally' ? ( - {badge(money(equalShares[p.id] || 0))} + {badge(money(equalShares[p.id] || 0))} ) : inPercent ? (
@@ -1981,7 +1982,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo {receipts.map((r, rIdx) => (
@@ -2052,7 +2053,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo onValueChange={onTotalChange} signToggleLabel={t('costs.toggleSign')} disabled={isTicketMode} - className="w-full flex-1 border-0 bg-transparent pl-1.5 font-semibold text-content outline-none dark:bg-transparent" style={fs(15, 'subtitle')} /> + className="w-full flex-1 border-0 bg-transparent ps-1.5 font-semibold text-content outline-none dark:bg-transparent" style={fs(15, 'subtitle')} />
diff --git a/client/src/components/Budget/CostsTable.tsx b/client/src/components/Budget/CostsTable.tsx index 850b50f63..324a88d76 100644 --- a/client/src/components/Budget/CostsTable.tsx +++ b/client/src/components/Budget/CostsTable.tsx @@ -107,7 +107,7 @@ export default function CostsTable(p: CostsTableProps) { - {t('budget.table.name')} + {t('budget.table.name')} {t('budget.table.date')} {t('budget.table.total')} {t('budget.table.persons')} @@ -124,9 +124,9 @@ export default function CostsTable(p: CostsTableProps) { return ( - + {open && ( -
{ if (e.key === 'Escape') close() }} className="absolute right-0 top-11 z-30" style={{ ...POPOVER, width: 310 }}> +
{ if (e.key === 'Escape') close() }} className="absolute end-0 top-11 z-30" style={{ ...POPOVER, width: 310 }}>
{owners.map(([o, label]) => (
)}
} + {imagePreviews.length > 0 &&
{imagePreviews.map((url, i) =>
)}
} {uploadProgress > 0 && uploadProgress < 100 &&
{t('collab.chat.uploading', { percent: uploadProgress })}
}
{/* Emoji button */} diff --git a/client/src/components/Collab/CollabChatAttachment.tsx b/client/src/components/Collab/CollabChatAttachment.tsx index f6036fa2e..64b6ba150 100644 --- a/client/src/components/Collab/CollabChatAttachment.tsx +++ b/client/src/components/Collab/CollabChatAttachment.tsx @@ -8,6 +8,6 @@ export function CollabChatAttachment({ attachment }: { attachment: { url: string const alt = attachment.original_name || t('collab.chat.attachedImage') return <> {alt} setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} /> - {open &&
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>{alt} e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} />
} + {open &&
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>{alt} e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} />
} } diff --git a/client/src/components/Collab/CollabChatMessages.test.tsx b/client/src/components/Collab/CollabChatMessages.test.tsx index 83a923c70..1eb04fd38 100644 --- a/client/src/components/Collab/CollabChatMessages.test.tsx +++ b/client/src/components/Collab/CollabChatMessages.test.tsx @@ -129,8 +129,8 @@ describe('ChatMessages', () => { ]) const first = screen.getByText('first').closest('div[style]')! const last = screen.getByText('second').closest('div[style]')! - expect(first.getAttribute('style')).toContain('border-radius: 18px 18px 18px 18px') - expect(last.getAttribute('style')).toContain('border-radius: 18px 18px 4px 18px') + expect(first.getAttribute('style')).toContain('border-start-start-radius: 18px; border-start-end-radius: 18px; border-end-end-radius: 18px; border-end-start-radius: 18px') + expect(last.getAttribute('style')).toContain('border-start-start-radius: 18px; border-start-end-radius: 18px; border-end-end-radius: 4px; border-end-start-radius: 18px') }) it('FE-W5CCM-007: the avatar image is rendered for a foreign author who has one', () => { diff --git a/client/src/components/Collab/CollabChatMessages.tsx b/client/src/components/Collab/CollabChatMessages.tsx index 3ac899bc9..91001f39f 100644 --- a/client/src/components/Collab/CollabChatMessages.tsx +++ b/client/src/components/Collab/CollabChatMessages.tsx @@ -69,9 +69,11 @@ export function ChatMessages(props: any) { } // Bubble border radius — iMessage style tails - const br = own - ? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px` - : `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}` + const tail = isLastInGroup ? 4 : 18 + const br = { + borderStartStartRadius: 18, borderStartEndRadius: 18, + borderEndEndRadius: own ? tail : 18, borderEndStartRadius: own ? 18 : tail, + } return ( @@ -92,7 +94,7 @@ export function ChatMessages(props: any) { display: 'flex', alignItems: own ? 'flex-end' : 'flex-start', flexDirection: own ? 'row-reverse' : 'row', gap: 6, marginTop: isNewGroup ? 10 : 1, - paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40, + paddingInlineStart: own ? 40 : 0, paddingInlineEnd: own ? 0 : 40, transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease', ...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}), }}> @@ -118,7 +120,7 @@ export function ChatMessages(props: any) {
{/* Username for others at group start */} {!own && isNewGroup && ( - + {msg.username} )} @@ -150,7 +152,7 @@ export function ChatMessages(props: any) { background: own ? 'var(--accent)' : 'var(--bg-card)', color: own ? 'var(--accent-text)' : 'var(--text-primary)', border: own ? 'none' : '1px solid var(--border-faint)', - borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px', + ...br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap', }}> {/* Inline reply quote */} @@ -185,7 +187,7 @@ export function ChatMessages(props: any) { opacity: hoveredId === msg.id ? 1 : 0, pointerEvents: hoveredId === msg.id ? 'auto' : 'none', transition: 'opacity .1s', - ...(own ? { left: -6 } : { right: -6 }), + ...(own ? { insetInlineStart: -6 } : { insetInlineEnd: -6 }), }}> diff --git a/client/src/components/Collab/CollabPanelHead.tsx b/client/src/components/Collab/CollabPanelHead.tsx index 25c243daf..0d38d9b15 100644 --- a/client/src/components/Collab/CollabPanelHead.tsx +++ b/client/src/components/Collab/CollabPanelHead.tsx @@ -23,7 +23,7 @@ export default function CollabPanelHead({ icon: Icon, title, count, actions }: { {count != null && count > 0 && ( {count} )} - {actions &&
{actions}
} + {actions &&
{actions}
}
) } diff --git a/client/src/components/Collab/CollabPolls.tsx b/client/src/components/Collab/CollabPolls.tsx index 0ee5d9afb..15a695660 100644 --- a/client/src/components/Collab/CollabPolls.tsx +++ b/client/src/components/Collab/CollabPolls.tsx @@ -64,10 +64,10 @@ const pollMarkdownComponents: Components = {

{children}

), ul: ({ children }) => ( -
    {children}
+
    {children}
), ol: ({ children }) => ( -
    {children}
+
    {children}
), } @@ -174,7 +174,7 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) { {t('collab.polls.multiChoice')} @@ -191,7 +191,7 @@ function VoterChip({ voter, offset }: VoterChipProps) { return ( + style={{ ...fs(7), marginInlineStart: offset ? -5 : 0 }}> {voter.avatar_url ? {voter.username : (voter.username || '?')[0].toUpperCase()} @@ -265,7 +265,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: return (
) : results.map((r, i) => ( - {open && ( -
+
{options.map(o => ( ) } @@ -159,7 +159,7 @@ export function AssignModal(S: FileManagerState) { } } }} style={{ - width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none', + width: '100%', textAlign: 'start', paddingBlock: 6, paddingInlineEnd: 10, paddingInlineStart: 20, background: isLinked ? 'var(--bg-hover)' : 'none', border: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400, display: 'flex', alignItems: 'center', gap: 6, @@ -168,7 +168,7 @@ export function AssignModal(S: FileManagerState) { onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}> {r.title} - {isLinked && } + {isLinked && } ) } @@ -197,10 +197,10 @@ export function AssignModal(S: FileManagerState) { const hasBoth = placesSection && bookingsSection return (
-
{placesSection}
+
{placesSection}
{hasBoth &&
} {hasBoth &&
} -
{bookingsSection}
+
{bookingsSection}
) })()} diff --git a/client/src/components/Files/FileManagerFilesView.tsx b/client/src/components/Files/FileManagerFilesView.tsx index 3456a7037..987a95387 100644 --- a/client/src/components/Files/FileManagerFilesView.tsx +++ b/client/src/components/Files/FileManagerFilesView.tsx @@ -55,7 +55,7 @@ export function FilesView(S: FileManagerState) { fontWeight: filterType === tab.id ? 600 : 400, }}>{tab.icon ? : tab.label} ))} - + {t('files.count', { count: filteredFiles.length })}
diff --git a/client/src/components/Files/FileManagerImageLightbox.tsx b/client/src/components/Files/FileManagerImageLightbox.tsx index e66b6dee4..9ad8a2646 100644 --- a/client/src/components/Files/FileManagerImageLightbox.tsx +++ b/client/src/components/Files/FileManagerImageLightbox.tsx @@ -95,7 +95,7 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
{file.original_name} - {index + 1} / {files.length} + {index + 1} / {files.length}
diff --git a/client/src/components/Files/FileManagerRow.tsx b/client/src/components/Files/FileManagerRow.tsx index ed9f667e4..7f3fb261c 100644 --- a/client/src/components/Files/FileManagerRow.tsx +++ b/client/src/components/Files/FileManagerRow.tsx @@ -64,7 +64,7 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea type="button" disabled={isTrash} onClick={() => !isTrash && openFile(file)} - style={{ fontWeight: 500, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer', textAlign: 'left', minWidth: 0 }} + style={{ fontWeight: 500, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer', textAlign: 'start', minWidth: 0 }} > {file.original_name} diff --git a/client/src/components/Files/FileManagerToolbar.tsx b/client/src/components/Files/FileManagerToolbar.tsx index 18ee45da5..a399d4592 100644 --- a/client/src/components/Files/FileManagerToolbar.tsx +++ b/client/src/components/Files/FileManagerToolbar.tsx @@ -15,7 +15,7 @@ export function FileManagerToolbar(S: FileManagerState) { const trashLabel = t('files.trash') || 'Trash' return (
-
+

{showTrash ? trashLabel : t('files.title')}

diff --git a/client/src/components/Files/docsync/DocSyncBinding.tsx b/client/src/components/Files/docsync/DocSyncBinding.tsx index c8419c623..81ef95827 100644 --- a/client/src/components/Files/docsync/DocSyncBinding.tsx +++ b/client/src/components/Files/docsync/DocSyncBinding.tsx @@ -175,7 +175,7 @@ export default function DocSyncBinding({ type="button" onClick={() => setOpen(!open)} aria-expanded={open} - className="flex w-full items-center gap-1.5 border-t border-edge-faint px-4 py-2.5 text-left text-caption text-content-muted transition-colors hover:bg-surface-hover" + className="flex w-full items-center gap-1.5 border-t border-edge-faint px-4 py-2.5 text-start text-caption text-content-muted transition-colors hover:bg-surface-hover" > {t('docsync.binding.settings')} diff --git a/client/src/components/Files/docsync/DocSyncFlow.tsx b/client/src/components/Files/docsync/DocSyncFlow.tsx index 2ddb5ed75..012a78e3f 100644 --- a/client/src/components/Files/docsync/DocSyncFlow.tsx +++ b/client/src/components/Files/docsync/DocSyncFlow.tsx @@ -193,7 +193,7 @@ function Lane({ {/* Explicitly aligned to the arrow's side: the dialog inherits a centred text-align, which would otherwise float the label away from it. */} - + {caption} diff --git a/client/src/components/Files/docsync/DocSyncPanel.tsx b/client/src/components/Files/docsync/DocSyncPanel.tsx index c8fd0a432..546b6cab4 100644 --- a/client/src/components/Files/docsync/DocSyncPanel.tsx +++ b/client/src/components/Files/docsync/DocSyncPanel.tsx @@ -103,7 +103,7 @@ export default function DocSyncPanel({ onAdd={p => (sync.connectionFor(p.id) ? setScopeFor(p.id) : setConnecting(p))} /> -
+
{active ? (
{t('docsync.scope.pickTitle')} {scopes && scopes.length > 6 && ( - + setQuery(e.target.value)} placeholder={t('docsync.scope.search')} - className="w-44 rounded-lg border border-edge bg-surface-input py-1.5 pl-8 pr-2.5 text-caption text-content ring-accent focus:outline-none focus:ring-2" + className="w-44 rounded-lg border border-edge bg-surface-input py-1.5 ps-8 pe-2.5 text-caption text-content ring-accent focus:outline-none focus:ring-2" /> )} @@ -174,7 +174,7 @@ export default function DocSyncScopeModal({ type="button" onClick={() => void bind(s)} disabled={working !== null} - className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover disabled:opacity-50" + className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-start transition-colors hover:bg-surface-hover disabled:opacity-50" > {s.label} diff --git a/client/src/components/Help/HelpBadge.tsx b/client/src/components/Help/HelpBadge.tsx index f82fc9366..1e2a8db89 100644 --- a/client/src/components/Help/HelpBadge.tsx +++ b/client/src/components/Help/HelpBadge.tsx @@ -46,8 +46,8 @@ export default function HelpBadge({ }): React.ReactElement { return ( {Icon && } diff --git a/client/src/components/Help/HelpGuideView.tsx b/client/src/components/Help/HelpGuideView.tsx index 508e26517..466220b11 100644 --- a/client/src/components/Help/HelpGuideView.tsx +++ b/client/src/components/Help/HelpGuideView.tsx @@ -60,7 +60,7 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re {guide.media.steps && {t('help.center.screenshot')}} {/* One way out of the header: into the screen. The docs link waits at the bottom. */} {screen && screenRoute && ( - + {t('help.center.goToScreen', { screen: t(ctxKey(screen.id, 'title')) })} @@ -76,7 +76,7 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
    {/* The rail: runs behind the step numbers from the first to the last. */} -