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. */} -