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.
This commit is contained in:
Maurice
2026-10-07 22:33:58 +00:00
parent de128cc4ae
commit ba70ad28b8
315 changed files with 1300 additions and 1004 deletions
+6
View File
@@ -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
+3 -1
View File
@@ -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<Page>()` 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:
+1
View File
@@ -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)
+1
View File
@@ -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",
+115
View File
@@ -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
}
+166
View File
@@ -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`(?<![\w-])(?:[\w-]+:)*-?(?:` +
[
String.raw`(?:ml|mr|pl|pr|left|right|scroll-m[lr]|scroll-p[lr])-(?:\[[^\]\s]+\]|[\w./]+)`,
String.raw`text-(?:left|right)(?![\w-])`,
String.raw`border-[lr](?:-[\w[\]#./()-]+)?(?![\w-])`,
String.raw`rounded-(?:l|r|tl|tr|bl|br)(?:-[\w[\]./]+)?(?![\w-])`,
String.raw`(?:float|clear)-(?:left|right)(?![\w-])`,
String.raw`space-x-[\w.]+`,
].join('|') +
')',
'g',
)
// The same sides as React style keys, and as properties in a stylesheet.
const STYLE =
/\b(?:marginLeft|marginRight|paddingLeft|paddingRight|borderLeft\w*|borderRight\w*|border(?:Top|Bottom)(?:Left|Right)Radius|left|right)\s*:|\b(?:textAlign|float)\s*:\s*['"](?:left|right)['"]/g
const CSS =
/(?<![\w-])(?:margin-left|margin-right|padding-left|padding-right|border-left[\w-]*|border-right[\w-]*|border-(?:top|bottom)-(?:left|right)-radius|left|right)\s*:|(?<![\w-])(?:text-align|float)\s*:\s*(?:left|right)\b/g
const DISABLE = 'rtl-lint-disable'
function walk(dir, files = []) {
for (const name of readdirSync(dir)) {
const path = join(dir, name)
if (statSync(path).isDirectory()) walk(path, files)
else if (/\.(?:tsx?|css)$/.test(name) && !/\.(?:test|spec)\./.test(name)) files.push(path)
}
return files
}
/**
* The code of each line, without its comments: prose like "top right" or
* "left out" is no side. Good enough for this source, which keeps `//` and
* `/*` out of string literals except in URLs (`https://`).
*/
function codeLines(source) {
let inBlock = false
return source.split('\n').map((line) => {
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(/(?<!:)\/\//)
if (lineComment >= 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)
}
@@ -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 (
<div className="min-w-0 text-right">
<div className="min-w-0 text-end">
<div className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>{label}</div>
<div className="whitespace-nowrap font-geist tabular-nums text-content-muted" style={fs(12, 'body')}>{children}</div>
</div>
@@ -1176,7 +1176,7 @@ function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMe
{options.map(([v, lbl]) => (
<button type="button" key={v} onClick={() => { onPick(v); setMenu(null) }}
className={`${MENU_ITEM} ${value === v ? 'font-semibold text-content' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
<span className="min-w-0 flex-1 truncate text-left">{lbl}</span>
<span className="min-w-0 flex-1 truncate text-start">{lbl}</span>
<Check size={14} className={`flex-none text-content-muted ${value === v ? 'opacity-100' : 'opacity-0'}`} />
</button>
))}
@@ -1215,7 +1215,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
</span>
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
<span className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`} />
<span className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`} />
</Tooltip>
</div>
@@ -1311,7 +1311,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
</button>
)}
<span className={`min-w-[42px] text-right font-medium ${on ? 'text-content-secondary' : 'text-content-faint'}`} style={fs(12)}>
<span className={`min-w-[42px] text-end font-medium ${on ? 'text-content-secondary' : 'text-content-faint'}`} style={fs(12)}>
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
</span>
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
@@ -1444,11 +1444,11 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
<div className="relative">
<Screenshot url={item.screenshotUrl} className="aspect-[16/10]" iconSize={24} />
{item.reviewedAt && (
<span className="absolute right-2.5 top-2.5">
<span className="absolute end-2.5 top-2.5">
<StatusPill icon={<ShieldCheck size={11} className="text-success" />}>{t('admin.plugins.reviewed')}</StatusPill>
</span>
)}
<span className="absolute -bottom-4 left-3.5 z-[1] grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-card shadow-sm">
<span className="absolute -bottom-4 start-3.5 z-[1] grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-card shadow-sm">
<PluginIcon name={item.icon} size={20} className="text-content-secondary" />
</span>
</div>
@@ -2111,7 +2111,7 @@ function KeyRow({ label, value, highlight }: { label: string; value: string; hig
return (
<div className="flex items-center justify-between gap-3 px-3.5 py-2.5">
<span className="flex-none text-content-muted" style={fs(12)}>{label}</span>
<code className={`min-w-0 break-all text-right font-mono ${highlight ? 'font-semibold text-warning' : 'text-content'}`} style={fs(12)}>{value}</code>
<code className={`min-w-0 break-all text-end font-mono ${highlight ? 'font-semibold text-warning' : 'text-content'}`} style={fs(12)}>{value}</code>
</div>
)
}
@@ -2249,7 +2249,7 @@ function SecurityInfo({ t }: { t: T }) {
icon={Lock}
title={(
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open}
className="inline-flex max-w-full items-center gap-1.5 text-left font-bold text-content hover:opacity-80">
className="inline-flex max-w-full items-center gap-1.5 text-start font-bold text-content hover:opacity-80">
<span className="truncate">{t('admin.plugins.security.title')}</span>
<ChevronDown size={15} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
@@ -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'
@@ -172,7 +172,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
isSet(field) ? (
<button type="button"
onClick={() => reset(field)}
className="ml-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{t('admin.defaultSettings.resetToBuiltIn')}
@@ -23,13 +23,13 @@ interface AppUser {
}
/** A native select in the editors' box look, with the chevron the CustomSelect draws. */
const SELECT = `${INPUT} appearance-none pr-9`
const SELECT = `${INPUT} appearance-none pe-9`
function SelectBox({ id, children, ...rest }: React.SelectHTMLAttributes<HTMLSelectElement>): React.ReactElement {
return (
<div className="relative">
<select id={id} {...rest} className={SELECT}>{children}</select>
<ChevronDown size={14} strokeWidth={2.2} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-content-faint" />
<ChevronDown size={14} strokeWidth={2.2} className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-content-faint" />
</div>
)
}
@@ -89,7 +89,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
<button type="button"
onClick={onClick}
disabled={sending !== null}
className="flex w-full min-w-0 items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 text-left transition-colors hover:bg-surface-secondary disabled:cursor-default"
className="flex w-full min-w-0 items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 text-start transition-colors hover:bg-surface-secondary disabled:cursor-default"
>
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary">
<Icon size={15} strokeWidth={2} />
+2 -2
View File
@@ -131,7 +131,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
const flushList = () => {
if (listItems.length > 0) {
elements.push(
<ul key={`ul-${elements.length}`} className="my-1.5 space-y-1 pl-0">
<ul key={`ul-${elements.length}`} className="my-1.5 space-y-1 ps-0">
{listItems.map((item, i) => (
<li key={i} className="flex gap-2 leading-relaxed text-content-secondary" style={fs(12.5, 'body')}>
<span className="mt-[0.6em] h-1 w-1 flex-shrink-0 rounded-full bg-content-faint" />
@@ -279,7 +279,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
{/* Timeline */}
<div className="relative">
{/* Timeline line, from the first dot's middle down to the last one's */}
{shown.length > 1 && <div className="absolute bottom-6 left-[15px] top-4 w-px bg-edge" aria-hidden="true" />}
{shown.length > 1 && <div className="absolute bottom-6 start-[15px] top-4 w-px bg-edge" aria-hidden="true" />}
<div className="flex flex-col gap-5">
{shown.map((release, idx) => {
@@ -30,7 +30,7 @@ export default function GoogleDailyLimitRow() {
{t('admin.googleQuota.reached', { used: status.used_today })}
</StatusPill>
) : (
<StatusPill icon={<span aria-hidden className="mr-0.5 h-1.5 w-1.5 rounded-full bg-success" />}>
<StatusPill icon={<span aria-hidden className="me-0.5 h-1.5 w-1.5 rounded-full bg-success" />}>
{status.daily_limit == null
? t('admin.googleQuota.usedToday', { used: status.used_today })
: t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })}
@@ -50,7 +50,7 @@ export default function GoogleDailyLimitRow() {
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void save() } }}
placeholder={t('admin.googleQuota.placeholder')}
disabled={status === null || saving}
className={`${INPUT} text-right font-geist tabular-nums`}
className={`${INPUT} text-end font-geist tabular-nums`}
/>
</div>
{dirty && (
@@ -9,7 +9,7 @@ import { COMPOSER } from '../Packing/packingPopoverStyles'
import { SettingRows, SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../Settings/settingsKit'
/** The planner's composer (#2541): a framed field on the card with its confirm and cancel beside it. */
const COMPOSER_ROW = 'flex items-center gap-2 rounded-[10px] border border-edge bg-surface-card py-1 pl-3 pr-1'
const COMPOSER_ROW = 'flex items-center gap-2 rounded-[10px] border border-edge bg-surface-card py-1 ps-3 pe-1'
const COMPOSER_INPUT = 'min-w-0 flex-1 border-0 bg-transparent py-1 font-medium text-content outline-none placeholder:text-content-faint'
const COMPOSER_CANCEL = 'grid h-7 w-7 flex-none place-items-center rounded-[8px] text-content-faint hover:bg-surface-secondary hover:text-content'
/** A name being renamed in place, sized to the row it sits in. */
@@ -240,7 +240,7 @@ export default function PackingTemplateManager() {
aria-label={t('common.rename')}
className={`${ROW_INPUT} flex-1 font-semibold`} style={fs(13, 'body')} />
) : (
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="min-w-0 flex-1 truncate text-left font-semibold text-content" style={fs(13, 'body')}>{tmpl.name}</button>
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="min-w-0 flex-1 truncate text-start font-semibold text-content" style={fs(13, 'body')}>{tmpl.name}</button>
)}
<span className="max-sm:hidden">
<StatusPill>{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}</StatusPill>
@@ -257,13 +257,13 @@ export default function PackingTemplateManager() {
{/* Expanded content: the template's categories as the packing list draws them */}
{expanded && (
<div className="flex flex-col gap-2.5 border-t border-edge-faint bg-surface-secondary px-3 py-3 sm:pl-[52px]">
<div className="flex flex-col gap-2.5 border-t border-edge-faint bg-surface-secondary px-3 py-3 sm:ps-[52px]">
{categories.map(cat => {
const catItems = items.filter(i => i.category_id === cat.id)
return (
<div key={cat.id} className="overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{/* Category header */}
<div data-row="category" className="flex items-center gap-1.5 border-b border-edge-faint bg-surface-tertiary py-1.5 pl-3 pr-1.5">
<div data-row="category" className="flex items-center gap-1.5 border-b border-edge-faint bg-surface-tertiary py-1.5 ps-3 pe-1.5">
{editingCatId === cat.id ? (
<input autoFocus value={editCatName} onChange={e => setEditCatName(e.target.value)}
onBlur={() => handleRenameCategory(cat.id)}
@@ -292,7 +292,7 @@ export default function PackingTemplateManager() {
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
<div className="divide-y divide-edge-faint">
{catItems.map(item => (
<div key={item.id} data-row="item" className="group flex min-h-[40px] items-center gap-2 py-1.5 pl-3 pr-1.5">
<div key={item.id} data-row="item" className="group flex min-h-[40px] items-center gap-2 py-1.5 ps-3 pe-1.5">
{editingItemId === item.id ? (
<>
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
@@ -328,7 +328,7 @@ export default function PackingTemplateManager() {
{/* Add item inline */}
{addingItemToCatId === cat.id && (
<div className="flex items-center gap-2 py-1.5 pl-3 pr-1.5">
<div className="flex items-center gap-2 py-1.5 ps-3 pe-1.5">
<input ref={addItemRef} value={newItemName} onChange={e => 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')}
@@ -82,8 +82,8 @@ function Catalog() {
{regions.length === 0
? <div className="rounded-[12px] border border-dashed border-edge px-4 py-5 text-center"><SettingsHint>{t('schoolCatalog.empty')}</SettingsHint></div>
: <SettingRows>
{regions.map(region => <div key={region.id} className="flex items-center gap-2 py-1.5 pl-1.5 pr-3">
<button type="button" className="group flex min-w-0 flex-1 items-center gap-3 rounded-[10px] px-2 py-1.5 text-left transition-colors hover:bg-surface-secondary" onClick={() => void state.openRegion(region.id)}>
{regions.map(region => <div key={region.id} className="flex items-center gap-2 py-1.5 ps-1.5 pe-3">
<button type="button" className="group flex min-w-0 flex-1 items-center gap-3 rounded-[10px] px-2 py-1.5 text-start transition-colors hover:bg-surface-secondary" onClick={() => void state.openRegion(region.id)}>
<span aria-hidden className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary"><MapPinned size={15} strokeWidth={1.9} /></span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(13, 'body')}>{region.name}</span>
<ChevronRight aria-hidden size={15} className="flex-none text-content-faint transition-transform group-hover:translate-x-0.5" />
@@ -79,7 +79,7 @@ const ROW_BUTTON_PRIMARY = 'inline-flex items-center gap-1.5 rounded-[10px] bg-a
/** The icon square on the left of a row. */
const TILE = 'grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-muted'
/** What a row says under its head line, lined up with the name rather than the tile. */
const DETAILS = 'mt-2 flex flex-col items-start gap-1.5 empty:hidden sm:pl-12'
const DETAILS = 'mt-2 flex flex-col items-start gap-1.5 empty:hidden sm:ps-12'
const META = 'm-0 leading-snug text-content-faint'
const TYPE_ICON: Record<string, LucideIcon> = { local: HardDrive, s3: Cloud, mirror: Copy }
@@ -55,7 +55,7 @@ export default function AtlasCountryPlaces({ detail, onOpenTrip, variant = 'desk
<button
type="button"
onClick={() => onOpenTrip(trip.id)}
className={`group flex items-center gap-2 rounded-[10px] px-2 py-1.5 text-left ${rowHover}`}
className={`group flex items-center gap-2 rounded-[10px] px-2 py-1.5 text-start ${rowHover}`}
>
<Briefcase size={13} strokeWidth={2} className={`flex-none ${muted}`} />
<span className={`min-w-0 flex-1 truncate text-[0.8125rem] font-bold ${ink}`}>{trip.title}</span>
@@ -67,7 +67,7 @@ export default function AtlasCountryPlaces({ detail, onOpenTrip, variant = 'desk
key={place.id}
type="button"
onClick={() => onOpenTrip(trip.id)}
className={`flex items-start gap-2 rounded-[10px] py-1.5 pl-7 pr-2 text-left ${rowHover}`}
className={`flex items-start gap-2 rounded-[10px] py-1.5 ps-7 pe-2 text-start ${rowHover}`}
>
<MapPin size={12} strokeWidth={2} className={`mt-[3px] flex-none ${faint}`} />
<span className="min-w-0 flex-1">
@@ -149,7 +149,7 @@ export default function BackgroundTasksWidget() {
return createPortal(
<div
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
style={{ position: 'fixed', insetInlineEnd: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
>
{tasks.map((task) => (
<div
+2 -2
View File
@@ -62,13 +62,13 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
<div style={{
background: 'var(--bg-tertiary)', borderRadius: 18,
padding: '14px 16px 14px 22px',
paddingBlock: 14, paddingInline: '22px 16px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
}}>
<h2 style={{ margin: 0, fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('budget.title')}
</h2>
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginInlineStart: 'auto', flexShrink: 0 }}>
<div className="max-md:!w-full" style={{ width: 150 }}>
<CustomSelect
value={currency}
@@ -84,7 +84,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
setDragCat(null); setDragOverCat(null)
}}
>
{dragOverCat === cat && <div style={{ position: 'absolute', top: -2, left: 0, right: 0, height: 4, background: 'var(--accent)', borderRadius: 2, zIndex: 10 }} />}
{dragOverCat === cat && <div style={{ position: 'absolute', top: -2, insetInline: 0, height: 4, background: 'var(--accent)', borderRadius: 2, zIndex: 10 }} />}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff',
borderRadius: '10px 10px 0 0', padding: '9px 14px',
@@ -137,7 +137,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
<th style={{ ...th, textAlign: 'start', minWidth: 120 }}>{t('budget.table.name')}</th>
<th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 160 }}>{t('budget.table.persons')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 55 }}>{t('budget.table.days')}</th>
@@ -256,7 +256,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
</tr>
{contributions.length > 0 && (
<tr>
<td colSpan={10} style={{ padding: '0 8px 6px 20px' }}>
<td colSpan={10} style={{ paddingBlock: '0 6px', paddingInline: '20px 8px' }}>
<PluginCardFooter items={contributions} tripId={tripId} />
</td>
</tr>
@@ -53,7 +53,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))',
width: '100%', textAlign: 'left', ...style,
width: '100%', textAlign: 'start', ...style,
}
const content = display || placeholder || '-'
@@ -164,7 +164,7 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
<button type="button" key={tm.id} onClick={() => toggleMember(tm.id)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', textAlign: 'left',
fontFamily: 'inherit', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', textAlign: 'start',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
@@ -57,10 +57,11 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
const sep = (0.1).toLocaleString(locale).replace(/\d/g, '')
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
return (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
// dir=ltr: the amount is split into spans, which a right-to-left flex row would reorder.
<div dir="ltr" style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
<span style={{ fontSize: 'calc(38px * var(--fs-scale-title, 1))', fontWeight: 700 }}>{integerPart}</span>
{decimalPart && <span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
<span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
<span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 500, color: theme.sub, marginInlineStart: 2 }}>{SYMBOLS[currency] || currency}</span>
</div>
)
})()}
@@ -82,7 +83,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
}}>
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{t('budget.settlement')}
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
<span style={{ position: 'relative', display: 'inline-flex', marginInlineStart: 2 }}>
{/* Decoration inside the settlement toggle: the hint hangs off
hover, and the click only keeps the toggle from firing. A
role of its own would give the toggle a second name and a
@@ -100,7 +101,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'start',
}}>
{t('budget.settlementInfo')}
</div>
@@ -126,7 +127,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
{fmt(flow.amount, currency)}
</span>
<div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}>
<div style={{ position: 'absolute', right: -1, top: '50%', transform: 'translateY(-50%)', width: 0, height: 0, borderLeft: '6px solid rgba(239,68,68,0.55)', borderTop: '4px solid transparent', borderBottom: '4px solid transparent' }} />
<div style={{ position: 'absolute', insetInlineEnd: -1, top: '50%', transform: 'translateY(-50%)', width: 0, height: 0, borderInlineStart: '6px solid rgba(239,68,68,0.55)', borderTop: '4px solid transparent', borderBottom: '4px solid transparent' }} />
</div>
</div>
<RingAvatar userId={flow.to.user_id} username={flow.to.username} avatarUrl={flow.to.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
@@ -232,7 +233,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
</svg>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
<div style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
<div style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
<div dir="ltr" style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span>{totalInt}</span>
{totalDec && <span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
</div>
+29 -28
View File
@@ -390,7 +390,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div className="flex min-h-[48px] items-center gap-2 border-b border-edge-faint px-4 py-2.5" style={{ background: NEUTRAL_TINT }}>
<span className={EYEBROW} style={fs(11)}>{label}</span>
{extra}
{action && <span className="ml-auto">{action}</span>}
{action && <span className="ms-auto">{action}</span>}
</div>
)
const labelCls = 'text-[11px] font-semibold uppercase tracking-[0.12em] text-content-faint'
@@ -403,7 +403,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
parts = new Intl.NumberFormat(currencyLocale(base), { style: 'currency', currency: base, minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0)
} catch { return <>{formatMoney(amount, base, locale)}</> }
const isBig = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign'
return <>{parts.map((p, i) => <span key={i} style={isBig(p) ? undefined : { fontSize: smallSize, fontWeight: 500, color: mutedColor }}>{p.value}</span>)}</>
// One left-to-right box: the parts are spans, which a right-to-left flex row would reorder.
return <span dir="ltr" className="inline-flex items-baseline">{parts.map((p, i) => <span key={i} style={isBig(p) ? undefined : { fontSize: smallSize, fontWeight: 500, color: mutedColor }}>{p.value}</span>)}</span>
}
// ── category + day filter controls (shared by both layouts) ──────────────
@@ -498,7 +499,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div className="mb-3 flex items-center gap-2 px-0.5">
<span className={EYEBROW} style={fs(11)}>{g.day}</span>
<CountPill>{g.entries.length}</CountPill>
<span className="ml-auto rounded-full bg-surface-tertiary px-2.5 py-[3px] font-geist font-semibold tabular-nums text-content-secondary" style={fs(11.5)}>{t('costs.spent', { amount: fmt(dtot) })}</span>
<span className="ms-auto rounded-full bg-surface-tertiary px-2.5 py-[3px] font-geist font-semibold tabular-nums text-content-secondary" style={fs(11.5)}>{t('costs.spent', { amount: fmt(dtot) })}</span>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -651,8 +652,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', textTransform: 'uppercase', letterSpacing: '0.12em', color: 'rgba(255,255,255,0.6)', fontWeight: 600 }}>{t('costs.totalSpend')}</div>
<div style={{ fontSize: 'calc(44px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.04em', lineHeight: 1, marginTop: 8, display: 'flex', alignItems: 'baseline' }}>{bigMoney(totals.totalSpend, 24, 'rgba(255,255,255,0.6)')}</div>
<div style={{ display: 'flex', gap: 18, marginTop: 12, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'rgba(255,255,255,0.6)', flexWrap: 'wrap' }}>
<span>{t('costs.yourShare')} <b style={{ color: '#fff', fontWeight: 600, marginLeft: 4 }}>{fmt0(totals.myShare)}</b></span>
<span>{t('costs.youPaid')} <b style={{ color: '#fff', fontWeight: 600, marginLeft: 4 }}>{fmt0(totals.myPaid)}</b></span>
<span>{t('costs.yourShare')} <b style={{ color: '#fff', fontWeight: 600, marginInlineStart: 4 }}>{fmt0(totals.myShare)}</b></span>
<span>{t('costs.youPaid')} <b style={{ color: '#fff', fontWeight: 600, marginInlineStart: 4 }}>{fmt0(totals.myPaid)}</b></span>
</div>
{canEdit && (
<button type="button" onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
@@ -692,7 +693,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('costs.outstanding')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('costs.outstandingSub')}</div>
</div>
<div style={{ marginLeft: 'auto', fontSize: 'calc(27px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', color: '#d97706' }}>{bigMoney(totals.outstanding, 16, 'var(--c-ink3)')}</div>
<div style={{ marginInlineStart: 'auto', fontSize: 'calc(27px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', color: '#d97706' }}>{bigMoney(totals.outstanding, 16, 'var(--c-ink3)')}</div>
</div>
</div>
@@ -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 (
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!dayFilter && <div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>}
{!dayFilter && <div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginInlineStart: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
? <Fragment key={'e' + en.e.id}>{ExpenseRow({ e: en.e })}</Fragment>
: <Fragment key={'s' + en.s.id}>{SettlementRow({ s: en.s })}</Fragment>)}</div>
@@ -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 && (
<span aria-hidden="true" style={{ position: 'absolute', left: -1, top: -1, display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 11px 4px 10px', borderRadius: '17px 0 12px 0', background: c.color, color: '#fff', fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', fontWeight: 800, letterSpacing: '0.05em', textTransform: 'uppercase', lineHeight: 1.2 }}>
<span aria-hidden="true" style={{ position: 'absolute', insetInlineStart: -1, top: -1, display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 11px 4px 10px', borderStartStartRadius: 17, borderEndEndRadius: 12, background: c.color, color: '#fff', fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', fontWeight: 800, letterSpacing: '0.05em', textTransform: 'uppercase', lineHeight: 1.2 }}>
<Icon size={10} strokeWidth={2.4} />
{t(c.labelKey)}
</span>
@@ -829,7 +830,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<Icon size={21} />
{isMobile && unfinished && (
<Tooltip label={t('costs.unfinishedHint')}>
<span role="img" aria-label={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
<span role="img" aria-label={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, insetInlineEnd: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
</Tooltip>
)}
</span>
@@ -886,7 +887,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginTop: 6 }}>
{payers.map(p => (
<Tooltip key={p.user_id} label={personName(p.user_id)}>
<span className="bg-surface-card border border-edge-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>
<span className="bg-surface-card border border-edge-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, paddingBlock: 3, paddingInline: '3px 10px', borderRadius: 999, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>
<Avatar id={p.user_id} size={18} />
<span className="text-content" style={{ fontWeight: 700 }}>{fmt(booked(p.amount, e))}</span>
</span>
@@ -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 (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 5, marginTop: 6 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', marginRight: 2 }}>{t('costs.split')}</span>
<span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', marginInlineEnd: 2 }}>{t('costs.split')}</span>
{shown.map(m => {
const share = fmt(shareOf(e, m.user_id))
return (
<Tooltip key={m.user_id} label={t('costs.splitChipLabel', { name: personName(m.user_id), amount: share })}>
<span className={chip} data-testid="split-chip" style={{ padding: '2px 8px 2px 2px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
<span className={chip} data-testid="split-chip" style={{ paddingBlock: 2, paddingInline: '2px 8px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
<Avatar id={m.user_id} size={16} />
<span style={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{share}</span>
{m.paid ? <Check size={11} strokeWidth={2.6} className="text-success" aria-label={t('costs.paid')} /> : null}
@@ -960,7 +961,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<button type="button" onClick={() => setExpandedNoteId(open ? null : noteKey)}
aria-expanded={open}
className="bg-surface-card border border-edge-faint text-content-muted hover:text-content hover:border-content-faint transition-colors exp-note-btn"
style={{ display: 'flex', alignItems: open ? 'flex-start' : 'center', gap: 9, minWidth: 0, width: '100%', padding: open ? '10px 14px 11px 13px' : '7px 12px 7px 11px', borderRadius: open ? 14 : 999, borderLeft: '3px solid ' + color, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', lineHeight: 1.5, textAlign: 'left' }}>
style={{ display: 'flex', alignItems: open ? 'flex-start' : 'center', gap: 9, minWidth: 0, width: '100%', padding: open ? '10px 14px 11px 13px' : '7px 12px 7px 11px', borderRadius: open ? 14 : 999, borderInlineStart: '3px solid ' + color, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', lineHeight: 1.5, textAlign: 'start' }}>
<span style={open
? { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', minWidth: 0, flex: 1 }
: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0, flex: 1 }}>{note}</span>
@@ -984,7 +985,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>
{t('costs.payment')}
{line && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginLeft: 10 }}>{fmt(line.entered.amount, line.entered.currency)} → {fmt(line.into.amount, line.into.currency)}</span>}
{line && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginInlineStart: 10 }}>{fmt(line.entered.amount, line.entered.currency)} → {fmt(line.into.amount, line.into.currency)}</span>}
</div>
<Tooltip label={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
@@ -1034,7 +1035,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{neg && <span style={{ position: 'absolute', right: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#dc2626', borderRadius: 3 }} />}
</div>
</div>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, textAlign: 'right', color: pos ? '#16a34a' : neg ? '#dc2626' : 'var(--text-faint)' }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, textAlign: 'end', color: pos ? '#16a34a' : neg ? '#dc2626' : 'var(--text-faint)' }}>
{pos ? '+' + fmt(r.balance) : neg ? '−' + fmt(-r.balance) : fmt(0)}
</div>
</div>
@@ -1066,7 +1067,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div key={r.user_id}>
<button type="button" onClick={() => setExpandedFinalId(open ? null : r.user_id)} aria-expanded={open}
className="text-content"
style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto 14px', gap: 10, alignItems: 'center', width: '100%', padding: '7px 0', background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left' }}>
style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto 14px', gap: 10, alignItems: 'center', width: '100%', padding: '7px 0', background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', textAlign: 'start' }}>
<Avatar id={r.user_id} size={28} />
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{personName(r.user_id)}</span>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700 }}>{fmt(r.final)}</span>
@@ -1123,7 +1124,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return (
<div key={r.item_id} style={lineCls}>
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.name}{entered && <span className="text-content-faint" style={{ marginLeft: 8 }}>{entered}</span>}
{r.name}{entered && <span className="text-content-faint" style={{ marginInlineStart: 8 }}>{entered}</span>}
</span>
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(r.amount)}</span>
</div>
@@ -1239,7 +1240,7 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span className="text-content-faint">{lead}</span>
{uniq.map(id => (
<span key={id} className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
<span key={id} className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, paddingBlock: 3, paddingInline: '3px 10px', borderRadius: 999, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
<Avatar id={id} size={18} />{name(id)}
</span>
))}
@@ -1329,7 +1330,7 @@ function SettlementModal({ tripId, people, me, editing, currency, tripCurrency,
{/* Typed and shown the way the expense total is, in the reader's own decimal mark. */}
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', cur)} value={localizeAmountInput(amount, cur)}
onValueChange={v => setAmount(v.replace(',', '.'))}
className="w-full flex-1 border-0 bg-transparent pl-1.5 font-semibold text-content outline-none dark:bg-transparent" style={fs(14, 'body')} />
className="w-full flex-1 border-0 bg-transparent ps-1.5 font-semibold text-content outline-none dark:bg-transparent" style={fs(14, 'body')} />
</div>
</EditorField>
<EditorField label={t('costs.currency')}>
@@ -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 (
<button key={p.id} type="button" role="radio" aria-checked={on} aria-label={nameOf(p)} onClick={() => setPayerId(p.id)}
className={`inline-flex items-center gap-2 rounded-full border py-1 pl-1 pr-3 font-medium ${on ? 'border-[color:var(--text-primary)] bg-surface-card text-content shadow-sm' : 'border-edge-faint bg-surface-secondary text-content-muted hover:text-content'}`}
className={`inline-flex items-center gap-2 rounded-full border py-1 ps-1 pe-3 font-medium ${on ? 'border-[color:var(--text-primary)] bg-surface-card text-content shadow-sm' : 'border-edge-faint bg-surface-secondary text-content-muted hover:text-content'}`}
style={fs(13, 'body')}>
{personFace(p, idx, true, 22)}
{nameOf(p)}
@@ -1763,7 +1764,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
return (
<div key={p.id} className={PERSON_ROW}>
<button type="button" role="checkbox" aria-checked={on} aria-label={nameOf(p)} onClick={() => togglePayer(p.id)} data-testid="payer-toggle"
className="inline-flex min-w-0 items-center gap-2.5 text-left">
className="inline-flex min-w-0 items-center gap-2.5 text-start">
{tick(on)}
{personFace(p, idx, on)}
{personName(p, on)}
@@ -1852,7 +1853,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-1 font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>{t('costs.ticketSplitting')}</span>
<span className="me-1 font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>{t('costs.ticketSplitting')}</span>
{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 (
<div key={p.id} className={PERSON_ROW}>
<button type="button" role="checkbox" aria-checked={on} aria-label={nameOf(p)} onClick={() => toggleParticipant(p.id)}
className="inline-flex min-w-0 items-center gap-2.5 text-left">
className="inline-flex min-w-0 items-center gap-2.5 text-start">
{tick(on)}
{personFace(p, idx, on)}
{personName(p, on)}
{p.is_guest && <GuestBadge size="xs" customTooltip />}
</button>
{!on ? excludedNote : splitMode === 'equally' ? (
<span className="text-right">{badge(money(equalShares[p.id] || 0))}</span>
<span className="text-end">{badge(money(equalShares[p.id] || 0))}</span>
) : inPercent ? (
<div className="flex flex-col items-end gap-0.5">
<div className={`${AMOUNT_BOX} w-full`}>
@@ -1981,7 +1982,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{receipts.map((r, rIdx) => (
<div key={r.id} className={receiptRow}>
<button type="button" onClick={() => setModalPreviewReceipts({ receipts, initialIndex: rIdx })}
className="inline-flex min-w-0 items-center gap-2 text-left text-content hover:underline" style={fs(13, 'body')}>
className="inline-flex min-w-0 items-center gap-2 text-start text-content hover:underline" style={fs(13, 'body')}>
<Receipt size={14} className="flex-shrink-0 text-content-faint" />
<span className="truncate">{r.original_name}</span>
</button>
@@ -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')} />
</div>
</EditorField>
<EditorField label={t('costs.currency')}>
+7 -7
View File
@@ -107,7 +107,7 @@ export default function CostsTable(p: CostsTableProps) {
</colgroup>
<thead>
<tr className="border-b border-edge-faint bg-surface-tertiary" style={fs(10)}>
<th scope="col" className={`${HEAD_BASE} text-left`}>{t('budget.table.name')}</th>
<th scope="col" className={`${HEAD_BASE} text-start`}>{t('budget.table.name')}</th>
<th scope="col" className={HEAD}>{t('budget.table.date')}</th>
<th scope="col" className={HEAD}>{t('budget.table.total')}</th>
<th scope="col" className={HEAD}>{t('budget.table.persons')}</th>
@@ -124,9 +124,9 @@ export default function CostsTable(p: CostsTableProps) {
return (
<tbody key={meta.key}>
<tr className="border-t border-edge-faint" style={{ background: tintOf(meta.color, 9) }}>
<th scope="rowgroup" colSpan={2} className="px-2 py-2 text-left font-normal">
<th scope="rowgroup" colSpan={2} className="px-2 py-2 text-start font-normal">
<button type="button" onClick={() => toggle(meta.key)} aria-expanded={open}
className="inline-flex items-center gap-2.5 rounded-[10px] py-0.5 pl-1 pr-2 hover:bg-surface-card">
className="inline-flex items-center gap-2.5 rounded-[10px] py-0.5 ps-1 pe-2 hover:bg-surface-card">
<ChevronDown size={14} strokeWidth={2.2} className={`text-content-faint transition-transform ${open ? '' : '-rotate-90'}`} />
<span className="grid h-7 w-7 place-items-center rounded-[9px] bg-surface-card shadow-sm">
<meta.Icon size={14} strokeWidth={2.2} style={{ color: meta.color }} />
@@ -147,7 +147,7 @@ export default function CostsTable(p: CostsTableProps) {
<tr className="border-t border-edge-faint">
<td colSpan={9} className="px-3 py-1.5">
<button type="button" onClick={() => void add(meta.key)}
className="inline-flex items-center gap-2 rounded-full py-1 pl-1 pr-3 font-medium text-content-muted hover:bg-surface-hover hover:text-content"
className="inline-flex items-center gap-2 rounded-full py-1 ps-1 pe-3 font-medium text-content-muted hover:bg-surface-hover hover:text-content"
style={fs(12, 'body')}>
<span className="grid h-5 w-5 place-items-center rounded-full bg-surface-card shadow-sm ring-1 ring-edge-faint"><Plus size={12} strokeWidth={2.4} /></span>
{t('costs.addExpense')}
@@ -160,9 +160,9 @@ export default function CostsTable(p: CostsTableProps) {
})}
<tfoot>
<tr className="border-t border-edge" style={{ background: NEUTRAL_TINT }}>
<th scope="row" colSpan={2} className="px-3 py-3 text-left font-geist font-bold uppercase tracking-[.08em] text-content-muted" style={fs(10.5)}>
<th scope="row" colSpan={2} className="px-3 py-3 text-start font-geist font-bold uppercase tracking-[.08em] text-content-muted" style={fs(10.5)}>
{t('budget.total')}
<span className="ml-2 rounded-full bg-surface-card px-2 py-px font-normal normal-case tracking-normal text-content-muted shadow-sm" style={fs(10.5)}>{p.items.length}</span>
<span className="ms-2 rounded-full bg-surface-card px-2 py-px font-normal normal-case tracking-normal text-content-muted shadow-sm" style={fs(10.5)}>{p.items.length}</span>
</th>
<td className="px-3 py-3 text-center">
<span className="inline-flex rounded-full bg-accent px-3 py-1 font-geist font-bold tabular-nums text-accent-text" style={fs(13, 'body')}>{p.fmt(grandTotal)}</span>
@@ -201,7 +201,7 @@ function Row({ e, p, editing, setEditing, numFmt }: {
return (
<tr className="group border-t border-edge-faint hover:bg-surface-hover">
<td className="px-3 py-2 text-left">
<td className="px-3 py-2 text-start">
<EditCell label={t('budget.table.name')} text={e.name} canEdit={p.canEdit} editing={isEditing('name')} onEdit={() => setEditing({ id: e.id, field: 'name' })}
display={<span className="block truncate font-medium">{e.name}</span>}
input={<TextInput value={e.name} onSave={v => { if (v.trim()) save({ name: v.trim() }); else setEditing(null) }} onCancel={() => setEditing(null)} label={t('budget.table.name')} />} />
@@ -64,7 +64,7 @@ export default function CostsToolbar({ dateMeta, people, me, colorFor, canEdit,
const button = 'inline-flex h-9 items-center gap-1.5 rounded-[10px] border-0 px-3.5 font-medium hover:opacity-[0.88]'
return (
<div className="mb-4 flex flex-wrap items-center gap-2.5 rounded-[18px] bg-surface-tertiary py-3 pl-[22px] pr-3">
<div className="mb-4 flex flex-wrap items-center gap-2.5 rounded-[18px] bg-surface-tertiary py-3 ps-[22px] pe-3">
<h2 className="m-0 shrink-0 text-subtitle font-semibold tracking-[-0.01em] text-content">{t('trip.tabs.budget')}</h2>
<div className="mx-1.5 h-[22px] w-px shrink-0 bg-edge-faint" />
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5" style={{ fontSize: BODY_SIZE }}>
@@ -76,10 +76,10 @@ export default function CostsToolbar({ dateMeta, people, me, colorFor, canEdit,
<b className="text-content">{t('costs.daysCount', { count: dateMeta.days })}</b>
</span>
)}
<span className={`${chip} gap-2 pl-1.5`}>
<span className={`${chip} gap-2 ps-1.5`}>
<span className="inline-flex">
{people.slice(0, 4).map((p, i) => {
const ring = { width: 22, height: 22, marginLeft: i ? -8 : 0 }
const ring = { width: 22, height: 22, marginInlineStart: i ? -8 : 0 }
return p.avatar_url
? <img key={p.id} src={p.avatar_url} alt="" className="block shrink-0 rounded-full border-2 border-surface-card object-cover" style={ring} />
: (
@@ -178,11 +178,11 @@ function FilterMenu(f: CostsFilterProps) {
<Tooltip label={t('reservations.filter')}>
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open} aria-label={t('reservations.filter')} className={`${BAR_BTN} ${active ? 'text-content' : ''}`}>
<Filter size={15} strokeWidth={2} />
{active > 0 && <span className="absolute -right-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 font-geist font-bold text-accent-text" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}>{active}</span>}
{active > 0 && <span className="absolute -end-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 font-geist font-bold text-accent-text" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}>{active}</span>}
</button>
</Tooltip>
{open && (
<div role="menu" onKeyDown={e => { if (e.key === 'Escape') close() }} className="absolute right-0 top-11 z-30" style={{ ...POPOVER, width: 310 }}>
<div role="menu" onKeyDown={e => { if (e.key === 'Escape') close() }} className="absolute end-0 top-11 z-30" style={{ ...POPOVER, width: 310 }}>
<div className="mx-1.5 mb-1 mt-1.5 flex rounded-[10px] bg-surface-tertiary p-[3px]">
{owners.map(([o, label]) => (
<button key={o} type="button" onClick={() => f.onOwner(o)} aria-pressed={f.owner === o}
@@ -244,7 +244,7 @@ export function ReceiptPreviewModal({ receipts, initialIndex = 0, onClose }: Rec
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
opacity: active ? 0.8 : 0.5,
marginLeft: 2,
marginInlineStart: 2,
}}
>
#{i + 1}
+3 -3
View File
@@ -27,13 +27,13 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
<ChatMessages {...S} />
{/* Composer */}
<div onDragOver={e => e.preventDefault()} onDrop={handleDrop} style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
<div onDragOver={e => e.preventDefault()} onDrop={handleDrop} style={{ flexShrink: 0, paddingTop: 8, paddingInline: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
{/* Reply preview */}
{replyTo && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
borderLeft: '3px solid var(--accent)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)',
borderInlineStart: '3px solid var(--accent)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)',
}}>
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
@@ -48,7 +48,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
</div>
)}
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label={t('places.removeImage')} style={{ position: 'absolute', top: -6, right: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label={t('places.removeImage')} style={{ position: 'absolute', top: -6, insetInlineEnd: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
{uploadProgress > 0 && uploadProgress < 100 && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>{t('collab.chat.uploading', { percent: uploadProgress })}</div>}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
@@ -8,6 +8,6 @@ export function CollabChatAttachment({ attachment }: { attachment: { url: string
const alt = attachment.original_name || t('collab.chat.attachedImage')
return <>
<img src={attachment.url} alt={alt} onClick={() => setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} />
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label={t('collab.chat.closeImage')} onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, right: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={alt} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label={t('collab.chat.closeImage')} onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, insetInlineEnd: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={alt} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
</>
}
@@ -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', () => {
@@ -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 (
<React.Fragment key={msg.id}>
@@ -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) {
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
{/* Username for others at group start */}
{!own && isNewGroup && (
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingInlineStart: 4 }}>
{msg.username}
</span>
)}
@@ -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 }),
}}>
<Tooltip label={t('collab.chat.reply')}>
<button type="button" onClick={() => setReplyTo(msg)} aria-label={t('collab.chat.reply')} style={{
@@ -223,7 +225,7 @@ export function ChatMessages(props: any) {
<div style={{
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
justifyContent: own ? 'flex-end' : 'flex-start',
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
paddingInlineStart: own ? 0 : 8, paddingInlineEnd: own ? 8 : 0,
position: 'relative', zIndex: 1,
}}>
<div style={{
@@ -77,7 +77,7 @@ export function NoteCard({ note, canEdit, onUpdate, onDelete, onEdit, onView, on
// A pinned note keeps its category colour on the frame, the way a selected booking card does.
style={{ borderColor: note.pinned ? `color-mix(in srgb, ${color} 45%, transparent)` : 'var(--border-faint)' }}
>
<div className={`flex items-center gap-2 py-1.5 pl-3 pr-1.5 ${hasBody ? 'border-b border-edge-faint' : ''}`} style={{ background: `color-mix(in srgb, ${color} 11%, transparent)` }}>
<div className={`flex items-center gap-2 py-1.5 ps-3 pe-1.5 ${hasBody ? 'border-b border-edge-faint' : ''}`} style={{ background: `color-mix(in srgb, ${color} 11%, transparent)` }}>
{/* The category is only its colour here; the name is in the tooltip and the filter above. */}
{note.category && (
<Tooltip label={note.category}>
@@ -99,7 +99,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
{allCats.map(cat => {
const current = localColors[cat] || NOTE_COLORS[0].value
return (
<div key={cat} className="flex items-center gap-2.5 rounded-[12px] bg-surface-secondary py-1.5 pl-3 pr-1.5">
<div key={cat} className="flex items-center gap-2.5 rounded-[12px] bg-surface-secondary py-1.5 ps-3 pe-1.5">
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: current }} />
{/* Category name — editable */}
<EditableCatName name={cat} onRename={(newName) => handleRenameCategory(cat, newName)} renameLabel={t('common.rename')} />
@@ -36,7 +36,7 @@ export function EditableCatName({ name, onRename, renameLabel = 'Click to rename
return (
<Tooltip label={renameLabel}>
<button type="button" onClick={() => { setValue(name); setEditing(true) }}
className="min-w-0 flex-1 truncate py-0.5 text-left font-semibold text-content hover:underline hover:decoration-edge"
className="min-w-0 flex-1 truncate py-0.5 text-start font-semibold text-content hover:underline hover:decoration-edge"
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
{name}
</button>
@@ -23,7 +23,7 @@ export default function CollabPanelHead({ icon: Icon, title, count, actions }: {
{count != null && count > 0 && (
<span className="rounded-full bg-surface-card px-2 py-[2px] font-geist font-bold text-content-muted" style={fs(10)}>{count}</span>
)}
{actions && <div className="ml-auto flex items-center gap-1.5" style={fs(11.5)}>{actions}</div>}
{actions && <div className="ms-auto flex items-center gap-1.5" style={fs(11.5)}>{actions}</div>}
</div>
)
}
+6 -6
View File
@@ -64,10 +64,10 @@ const pollMarkdownComponents: Components = {
<h3 style={{ margin: '0 0 0.45em', fontSize: '1.1em', lineHeight: 1.3 }}>{children}</h3>
),
ul: ({ children }) => (
<ul style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'disc' }}>{children}</ul>
<ul style={{ margin: '0 0 0.55em', paddingInlineStart: 20, listStyle: 'disc' }}>{children}</ul>
),
ol: ({ children }) => (
<ol style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'decimal' }}>{children}</ol>
<ol style={{ margin: '0 0 0.55em', paddingInlineStart: 20, listStyle: 'decimal' }}>{children}</ol>
),
}
@@ -174,7 +174,7 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
<span className="text-content" style={fs(13, 'body')}>{t('collab.polls.multiChoice')}</span>
<button type="button" role="switch" aria-checked={multiChoice} onClick={() => setMultiChoice(!multiChoice)}
className={`flex h-5 w-9 flex-none items-center rounded-full p-0.5 transition-colors ${multiChoice ? 'bg-accent' : 'bg-surface-tertiary'}`}>
<span className={`block h-4 w-4 rounded-full bg-surface-card shadow-sm transition-transform ${multiChoice ? 'translate-x-4' : ''}`} />
<span className={`block h-4 w-4 rounded-full bg-surface-card shadow-sm transition-transform ${multiChoice ? 'translate-x-4 rtl:-translate-x-4' : ''}`} />
</button>
</label>
</DialogShell>
@@ -191,7 +191,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
return (
<Tooltip label={voter.username || '?'}>
<span className="grid h-[18px] w-[18px] flex-none place-items-center overflow-hidden rounded-full border-[1.5px] border-surface-card bg-surface-tertiary font-bold text-content-muted"
style={{ ...fs(7), marginLeft: offset ? -5 : 0 }}>
style={{ ...fs(7), marginInlineStart: offset ? -5 : 0 }}>
{voter.avatar_url ? <img src={voter.avatar_url} alt={voter.username || ''} className="h-full w-full object-cover" /> : (voter.username || '?')[0].toUpperCase()}
</span>
</Tooltip>
@@ -265,7 +265,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
return (
<button type="button" key={idx} onClick={() => onVote(poll.id, idx)}
disabled={isClosed}
className={`relative flex w-full items-start gap-2.5 overflow-hidden rounded-[10px] border bg-surface-secondary px-3 py-2.5 text-left transition-colors disabled:cursor-default ${myVote ? 'border-accent' : 'border-edge-faint enabled:hover:border-edge'}`}
className={`relative flex w-full items-start gap-2.5 overflow-hidden rounded-[10px] border bg-surface-secondary px-3 py-2.5 text-start transition-colors disabled:cursor-default ${myVote ? 'border-accent' : 'border-edge-faint enabled:hover:border-edge'}`}
>
{/* Progress bar background */}
<span aria-hidden="true" className="absolute inset-y-0 left-0 transition-[width] duration-500"
@@ -300,7 +300,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
{/* Percentage */}
{(hasVoted || isClosed) && (
<span className={`relative z-[1] min-w-[32px] text-right font-geist font-bold tabular-nums ${myVote ? 'text-content' : 'text-content-muted'}`} style={fs(12, 'body')}>
<span className={`relative z-[1] min-w-[32px] text-end font-geist font-bold tabular-nums ${myVote ? 'text-content' : 'text-content-muted'}`} style={fs(12, 'body')}>
{pct}%
</span>
)}
@@ -153,7 +153,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
{item.participants.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{item.participants.map(p => (
<span key={p.user_id} className="flex items-center gap-1 rounded-full border border-edge-faint bg-surface-secondary py-[2px] pl-[2px] pr-2">
<span key={p.user_id} className="flex items-center gap-1 rounded-full border border-edge-faint bg-surface-secondary py-[2px] ps-[2px] pe-2">
<span className="grid h-4 w-4 flex-none place-items-center overflow-hidden rounded-full bg-surface-tertiary font-bold text-content-muted" style={fs(7)}>
{p.avatar
? <img src={avatarSrc(p.avatar)!} alt="" className="h-full w-full object-cover" />
@@ -172,7 +172,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
// Typed into the band, as in the planner's place dialog. It takes no focus
// on opening: the dialog starts at the search, which fills the name in.
titleInput={{ value: name, onChange: setName, label: t('common.name'), placeholder: t('common.name') }}
sub={<><Bookmark size={11} strokeWidth={2.2} className="mr-1 inline-block align-[-1px]" />{collectionName}</>}
sub={<><Bookmark size={11} strokeWidth={2.2} className="me-1 inline-block align-[-1px]" />{collectionName}</>}
/>
)
@@ -198,7 +198,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
<div className="relative">
<div className="flex items-stretch gap-2">
<div className="relative min-w-0 flex-1">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<Search size={14} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
autoFocus
ref={searchRef}
@@ -208,7 +208,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void search() } }}
aria-label={t('collections.addPlaceSearch')}
placeholder={t('collections.addPlaceSearch')}
className={`${INPUT} pl-8`}
className={`${INPUT} ps-8`}
/>
</div>
<DialogButton
@@ -224,9 +224,9 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
<div
role="group"
aria-label={t('common.search')}
className="absolute left-0 right-0 z-20 mt-1.5 flex max-h-[280px] flex-col gap-0.5 overflow-y-auto rounded-[12px] border border-edge-faint bg-surface-card p-1 shadow-dropdown"
className="absolute inset-x-0 z-20 mt-1.5 flex max-h-[280px] flex-col gap-0.5 overflow-y-auto rounded-[12px] border border-edge-faint bg-surface-card p-1 shadow-dropdown"
>
<div className="flex items-center justify-between py-0.5 pl-2.5 pr-0.5">
<div className="flex items-center justify-between py-0.5 ps-2.5 pe-0.5">
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>{t('common.search')}</span>
<Tooltip label={t('common.close')}>
<button type="button" onClick={dismissResults} aria-label={t('common.close')}
@@ -238,7 +238,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
{noResults ? (
<div className="px-2.5 py-3 text-center text-content-faint" style={fs(12.5, 'body')}>{t('planner.noPlacesFound')}</div>
) : results.map((r, i) => (
<button key={i} type="button" onClick={() => pick(r)} className="flex w-full items-center gap-2.5 rounded-[8px] px-2.5 py-2 text-left hover:bg-surface-hover">
<button key={i} type="button" onClick={() => pick(r)} className="flex w-full items-center gap-2.5 rounded-[8px] px-2.5 py-2 text-start hover:bg-surface-hover">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[9px] bg-surface-secondary text-content-faint"><MapPin size={15} /></span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>{str(r.name)}</span>
@@ -88,7 +88,7 @@ export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign,
type="button"
aria-pressed={on}
onClick={() => toggle(l.id)}
className={`flex min-h-[44px] items-center gap-3 rounded-[10px] px-3 py-2 text-left ${on ? 'bg-surface-card shadow-sm' : 'hover:bg-surface-card'}`}
className={`flex min-h-[44px] items-center gap-3 rounded-[10px] px-3 py-2 text-start ${on ? 'bg-surface-card shadow-sm' : 'hover:bg-surface-card'}`}
>
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: l.color || '#6366f1' }} /* theme-lint-disable: the label's own colour, and the first swatch of the label manager for one without */ />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>{l.name}</span>
@@ -148,7 +148,7 @@ export default function CollectionExportMenu({ onExport, exporting, t }: Collect
role="menuitem"
tabIndex={-1}
onClick={() => pick(option.format)}
className="w-full flex items-start gap-3 px-3 py-2.5 rounded-lg text-left transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
className="w-full flex items-start gap-3 px-3 py-2.5 rounded-lg text-start transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
>
<option.icon size={16} className="mt-0.5 shrink-0 text-content-faint" aria-hidden />
<span className="flex-1 min-w-0">
@@ -102,14 +102,14 @@ function FilterDropdown({ label, current, options, onSelect, lead }: {
className={`flex w-full items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 transition-colors hover:bg-surface-hover ${open ? 'ring-content-faint' : 'ring-edge-faint'}`}
style={fs(12, 'body')}>
<span className="flex flex-none text-content-faint">{cur.icon ?? lead}</span>
<span id={valueId} className="min-w-0 flex-1 truncate text-left">{cur.label}</span>
<span id={valueId} className="min-w-0 flex-1 truncate text-start">{cur.label}</span>
<ChevronDown size={13} strokeWidth={2.2} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div role="listbox" aria-label={label} className="absolute left-0 right-0 top-full z-30 mt-1 flex max-h-[240px] flex-col gap-0.5 overflow-y-auto rounded-[12px] border border-edge-faint bg-surface-card p-1 shadow-dropdown">
<div role="listbox" aria-label={label} className="absolute inset-x-0 top-full z-30 mt-1 flex max-h-[240px] flex-col gap-0.5 overflow-y-auto rounded-[12px] border border-edge-faint bg-surface-card p-1 shadow-dropdown">
{options.map(o => (
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
className={`flex items-center gap-2 rounded-[8px] px-2 py-1.5 text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'bg-surface-tertiary font-semibold text-content' : 'text-content-secondary'}`}
className={`flex items-center gap-2 rounded-[8px] px-2 py-1.5 text-start transition-colors hover:bg-surface-hover ${o.key === current ? 'bg-surface-tertiary font-semibold text-content' : 'text-content-secondary'}`}
style={fs(12.5, 'body')}>
<span className="flex flex-none text-content-faint">{o.icon}</span>
<span className="min-w-0 flex-1 truncate">{o.label}</span>
@@ -209,7 +209,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
<div className="flex flex-none flex-col gap-2 px-3 pb-2.5">
<EditorField label={t('common.search')} htmlFor={searchId}>
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<Search size={14} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
id={searchId}
type="text"
@@ -218,7 +218,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
// Named after what it searches: the form beside it has a search of its own.
aria-label={t('collections.picker.search')}
placeholder={t('collections.picker.search')}
className={`${INPUT} pl-8`}
className={`${INPUT} ps-8`}
/>
</div>
</EditorField>
@@ -247,7 +247,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
key={place.id}
type="button"
onClick={() => onSelect(place)}
className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-2 text-left outline-none transition-colors hover:bg-surface-card hover:shadow-sm focus-visible:bg-surface-card focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] active:bg-surface-selected"
className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-2 text-start outline-none transition-colors hover:bg-surface-card hover:shadow-sm focus-visible:bg-surface-card focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] active:bg-surface-selected"
>
<PlaceAvatar place={place} size={34} category={place.category ? { color: place.category.color ?? undefined, icon: place.category.icon ?? undefined } : null} />
<span className="flex min-w-0 flex-1 flex-col">
@@ -673,7 +673,7 @@ describe('CollectionPlaceDetail: cover controls', () => {
expect(controls.querySelectorAll('button')).toHaveLength(3);
// Chip first, controls last in one flex bar: the chip only gets what the buttons leave.
expect(Array.from(bar.children)).toEqual([chip, controls]);
expect(bar).toHaveClass('absolute', 'flex', 'left-[14px]', 'right-[12px]', 'top-[12px]');
expect(bar).toHaveClass('absolute', 'flex', 'start-[14px]', 'end-[12px]', 'top-[12px]');
expect(controls).toHaveClass('ms-auto', 'flex-none');
expect(chip).toHaveClass('min-w-0');
expect(text).toHaveClass('truncate');
@@ -183,7 +183,7 @@ export default function CollectionPlaceDetail({
{/* Chip and cover controls share the top bar. The controls keep the end and
never shrink, so a long category name ellipsizes instead of sliding under
them, however many buttons there are and whatever size phones give them. */}
<div className="absolute left-[14px] right-[12px] top-[12px] z-[2] flex items-center gap-[8px]">
<div className="absolute start-[14px] end-[12px] top-[12px] z-[2] flex items-center gap-[8px]">
{place.category?.name && (
<span className="col-detail-cover-cat min-w-0" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={12} className="flex-none" />
@@ -115,14 +115,14 @@ export default function CopyToTripModal({ isOpen, onClose, placeIds, onCopy, t }
)}
>
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<Search size={14} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.copyToTripSearch')}
aria-label={t('collections.copyToTripSearch')}
className={`${INPUT} pl-8`}
className={`${INPUT} ps-8`}
/>
</div>
{loading ? (
@@ -142,7 +142,7 @@ export default function CopyToTripModal({ isOpen, onClose, placeIds, onCopy, t }
type="button"
onClick={() => handleCopy(trip.id)}
disabled={busy}
className="flex min-h-[52px] items-center gap-3 rounded-[10px] px-2.5 py-2 text-left hover:bg-surface-card disabled:opacity-60"
className="flex min-h-[52px] items-center gap-3 rounded-[10px] px-2.5 py-2 text-start hover:bg-surface-card disabled:opacity-60"
>
<span className="grid h-9 w-9 flex-none place-items-center overflow-hidden rounded-[9px] bg-surface-tertiary text-content-faint">
{trip.cover_image ? <img src={trip.cover_image} alt="" className="h-full w-full object-cover" /> : <MapPin size={15} />}
@@ -82,7 +82,7 @@ function TargetCard({ active, icon: Icon, title, hint, onClick }: {
type="button"
onClick={onClick}
aria-pressed={active}
className={`relative flex flex-col items-start gap-1.5 rounded-[14px] border p-3 text-left transition-colors ${
className={`relative flex flex-col items-start gap-1.5 rounded-[14px] border p-3 text-start transition-colors ${
active ? 'border-accent bg-surface-card shadow-sm' : 'border-edge-faint bg-surface-secondary hover:bg-surface-card'
}`}
>
@@ -92,7 +92,7 @@ function TargetCard({ active, icon: Icon, title, hint, onClick }: {
<span className="font-semibold text-content" style={fs(13, 'body')}>{title}</span>
<span className="leading-snug text-content-faint" style={fs(11.5)}>{hint}</span>
{active && (
<span className="absolute right-2 top-2 grid h-4 w-4 place-items-center rounded-full bg-accent text-accent-text">
<span className="absolute end-2 top-2 grid h-4 w-4 place-items-center rounded-full bg-accent text-accent-text">
<Check size={11} strokeWidth={3} />
</span>
)}
@@ -117,13 +117,13 @@ function ListChoice({ lists, selectedId, onSelect, t }: {
<div className="flex flex-col gap-2">
{lists.length > 5 && (
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<Search size={14} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
aria-label={t('collections.file.searchLists')}
placeholder={t('collections.file.searchLists')}
className={`${INPUT} pl-8`}
className={`${INPUT} ps-8`}
/>
</div>
)}
@@ -136,7 +136,7 @@ function ListChoice({ lists, selectedId, onSelect, t }: {
type="button"
onClick={() => onSelect(list.id)}
aria-pressed={active}
className={`flex min-h-[46px] items-center gap-3 rounded-[10px] px-2.5 py-2 text-left ${active ? 'bg-surface-card shadow-sm' : 'hover:bg-surface-card'}`}
className={`flex min-h-[46px] items-center gap-3 rounded-[10px] px-2.5 py-2 text-start ${active ? 'bg-surface-card shadow-sm' : 'hover:bg-surface-card'}`}
>
<span className="grid h-8 w-8 flex-none place-items-center rounded-[9px]" style={swatch(list.color)}>
<Bookmark size={15} />
@@ -210,7 +210,7 @@ export default function ImportFromTripModal({ isOpen, collectionId, collectionNa
footer = (
<DialogFooter>
<DialogButton onClick={() => setStep('trip')} icon={<ArrowLeft size={14} strokeWidth={2.2} />}>{t('common.back')}</DialogButton>
<span className="ml-1 text-content-faint" style={fs(12.5, 'body')}>
<span className="ms-1 text-content-faint" style={fs(12.5, 'body')}>
{t('collections.importSelectedCount', { count: selected.size })}
</span>
<FooterSpacer />
@@ -255,7 +255,7 @@ export default function ImportFromTripModal({ isOpen, collectionId, collectionNa
key={tr.id}
type="button"
onClick={() => pickTrip(tr)}
className="group flex w-full items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary p-2.5 text-left transition-colors hover:bg-surface-card hover:shadow-sm"
className="group flex w-full items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary p-2.5 text-start transition-colors hover:bg-surface-card hover:shadow-sm"
>
<span className="grid h-[42px] w-[42px] flex-none place-items-center overflow-hidden rounded-[11px] bg-surface-tertiary text-content-faint">
{tr.cover_image
@@ -291,14 +291,14 @@ export default function ImportFromTripModal({ isOpen, collectionId, collectionNa
<>
<div className={STICKY_BAR}>
<div className="relative min-w-[150px] flex-1">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<Search size={14} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('collections.importSearchPlaces')}
aria-label={t('collections.importSearchPlaces')}
className={`${INPUT} pl-8`}
className={`${INPUT} ps-8`}
/>
</div>
{savedCount > 0 && (
@@ -349,7 +349,7 @@ export default function ImportFromTripModal({ isOpen, collectionId, collectionNa
onClick={() => !dup && toggle(p.place_id)}
disabled={dup}
aria-pressed={dup ? undefined : on}
className={`flex min-h-[48px] w-full items-center gap-3 rounded-[10px] px-2.5 py-1.5 text-left disabled:cursor-default disabled:opacity-50 ${on ? 'bg-surface-card shadow-sm' : 'enabled:hover:bg-surface-card'}`}
className={`flex min-h-[48px] w-full items-center gap-3 rounded-[10px] px-2.5 py-1.5 text-start disabled:cursor-default disabled:opacity-50 ${on ? 'bg-surface-card shadow-sm' : 'enabled:hover:bg-surface-card'}`}
>
<span className={`grid h-4 w-4 flex-none place-items-center rounded-[5px] ${dup ? 'invisible' : on ? 'bg-accent text-accent-text' : 'border-[1.5px] border-edge'}`}>
{on && <Check size={11} strokeWidth={3} />}
@@ -69,7 +69,7 @@ function LabelRow({ label, onUpdate, onDelete, t }: {
}
return (
<div className="flex items-center gap-2.5 rounded-[12px] bg-surface-secondary py-1.5 pl-3 pr-1.5">
<div className="flex items-center gap-2.5 rounded-[12px] bg-surface-secondary py-1.5 ps-3 pe-1.5">
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: color }} />
{/* Transparent on the row until pointed at or typed in; the dark: variants
outrank the global dark rule that paints every input. */}
@@ -79,7 +79,7 @@ function LabelRow({ label, onUpdate, onDelete, t }: {
onBlur={commitName}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
maxLength={60}
className="-ml-1.5 min-w-0 flex-1 rounded-[8px] border-0 bg-transparent px-1.5 py-1 font-semibold text-content outline-none hover:bg-surface-card focus:bg-surface-card focus:shadow-sm dark:bg-transparent dark:hover:bg-surface-card dark:focus:bg-surface-card"
className="-ms-1.5 min-w-0 flex-1 rounded-[8px] border-0 bg-transparent px-1.5 py-1 font-semibold text-content outline-none hover:bg-surface-card focus:bg-surface-card focus:shadow-sm dark:bg-transparent dark:hover:bg-surface-card dark:focus:bg-surface-card"
style={fs(13, 'body')}
aria-label={t('collections.labels.name')}
/>
@@ -200,7 +200,7 @@ export default function MSaveToCollectionSheet() {
type="button"
onClick={() => handleToggle(list)}
disabled={busyId != null}
className={`mt-2 flex w-full items-center gap-[11px] rounded-[14px] border px-3 py-[10px] text-left disabled:opacity-60 ${
className={`mt-2 flex w-full items-center gap-[11px] rounded-[14px] border px-3 py-[10px] text-start disabled:opacity-60 ${
saved
? 'border-[color:var(--m-act)] bg-[color:var(--m-inner)]'
: 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]'
@@ -65,14 +65,14 @@ export default function MoveToListModal({ mode, lists, count, onPick, onClose, t
>
{lists.length > 3 && (
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<Search size={14} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.copyToTripSearch')}
aria-label={t('collections.copyToTripSearch')}
className={`${INPUT} pl-8`}
className={`${INPUT} ps-8`}
/>
</div>
)}
@@ -88,7 +88,7 @@ export default function MoveToListModal({ mode, lists, count, onPick, onClose, t
type="button"
onClick={() => pick(list.id)}
disabled={busy != null}
className="flex min-h-[48px] items-center gap-3 rounded-[10px] px-3 py-2 text-left hover:bg-surface-card disabled:opacity-60"
className="flex min-h-[48px] items-center gap-3 rounded-[10px] px-3 py-2 text-start hover:bg-surface-card disabled:opacity-60"
>
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: list.color || '#6366f1' }} /* theme-lint-disable: the list's own colour, and the default a list without one is drawn in (ListsRail) */ />
<span className="min-w-0 flex-1">
@@ -205,7 +205,7 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
aria-pressed={saved}
onClick={() => handleToggle(list)}
disabled={busyId != null}
className={`flex min-h-[46px] items-center gap-3 rounded-[10px] px-3 py-2 text-left disabled:opacity-60 ${saved ? 'bg-surface-card shadow-sm' : 'hover:bg-surface-card'}`}
className={`flex min-h-[46px] items-center gap-3 rounded-[10px] px-3 py-2 text-start disabled:opacity-60 ${saved ? 'bg-surface-card shadow-sm' : 'hover:bg-surface-card'}`}
>
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: list.color || 'var(--accent)' }} />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>{list.name}</span>
@@ -94,14 +94,14 @@ export default function SaveTripPlacesToListModal({ isOpen, tripId, placeIds, on
>
{lists.length > 5 && (
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<Search size={14} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.searchLists')}
aria-label={t('collections.searchLists')}
className={`${INPUT} pl-8`}
className={`${INPUT} ps-8`}
/>
</div>
)}
@@ -119,7 +119,7 @@ export default function SaveTripPlacesToListModal({ isOpen, tripId, placeIds, on
type="button"
onClick={() => pick(list)}
disabled={busyId != null}
className="flex min-h-[48px] items-center gap-3 rounded-[10px] px-3 py-2 text-left hover:bg-surface-card disabled:opacity-60"
className="flex min-h-[48px] items-center gap-3 rounded-[10px] px-3 py-2 text-start hover:bg-surface-card disabled:opacity-60"
>
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: list.color || '#6366f1' }} /* theme-lint-disable: the list's own colour, and the default a list without one is drawn in (ListsRail) */ />
<span className="min-w-0 flex-1">
@@ -274,7 +274,7 @@ export default function ShareCollectionModal({
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>
{member.username}
{isSelf && <span className="ml-1 font-normal text-content-faint">({t('collections.share.you')})</span>}
{isSelf && <span className="ms-1 font-normal text-content-faint">({t('collections.share.you')})</span>}
</div>
{member.email && !pending && (
<div className="truncate text-content-faint" style={fs(11.5)}>{member.email}</div>
@@ -537,7 +537,7 @@ function Row({
aria-label={name}
disabled={disabled}
onClick={onToggle}
className={`flex w-full items-center gap-3 rounded-xl border p-2.5 text-left transition-colors ${
className={`flex w-full items-center gap-3 rounded-xl border p-2.5 text-start transition-colors ${
checked ? 'border-accent bg-accent-subtle' : 'border-edge hover:bg-surface-hover'
} ${disabled ? 'opacity-60' : ''}`}
>
@@ -146,7 +146,7 @@ export default function DawarichSuggestionsPanel({
type="button"
onClick={() => setCollapsed(value => !value)}
aria-expanded={!collapsed}
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 text-left ${
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 text-start ${
phone ? 'active:bg-[color:var(--m-inner)]' : 'hover:bg-surface-hover'
}`}
>
@@ -229,7 +229,7 @@ export default function DawarichSuggestionsPanel({
<button
type="button"
onClick={() => setShowHandled(value => !value)}
className={`w-full px-3.5 py-2 text-left ${
className={`w-full px-3.5 py-2 text-start ${
phone ? 'text-m-muted active:bg-[color:var(--m-inner)]' : 'text-content-muted hover:bg-surface-hover'
}`}
style={TYPE.meta}
@@ -84,7 +84,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,
@@ -93,7 +93,7 @@ export function AssignModal(S: FileManagerState) {
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
{isLinked && <Check size={14} style={{ marginInlineStart: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
}
@@ -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'}>
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
{isLinked && <Check size={14} style={{ marginInlineStart: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
}
@@ -197,10 +197,10 @@ export function AssignModal(S: FileManagerState) {
const hasBoth = placesSection && bookingsSection
return (
<div className={hasBoth ? 'md:flex' : ''}>
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingRight: hasBoth ? 6 : 0 }}>{placesSection}</div>
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingInlineEnd: hasBoth ? 6 : 0 }}>{placesSection}</div>
{hasBoth && <div className="hidden md:block" style={{ width: 1, background: 'var(--border-primary)', flexShrink: 0 }} />}
{hasBoth && <div className="block md:hidden" style={{ height: 1, background: 'var(--border-primary)', margin: '8px 0' }} />}
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingLeft: hasBoth ? 6 : 0 }}>{bookingsSection}</div>
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingInlineStart: hasBoth ? 6 : 0 }}>{bookingsSection}</div>
</div>
)
})()}
@@ -55,7 +55,7 @@ export function FilesView(S: FileManagerState) {
fontWeight: filterType === tab.id ? 600 : 400,
}}>{tab.icon ? <tab.icon size={13} fill={filterType === tab.id ? '#facc15' : 'none'} color={filterType === tab.id ? '#facc15' : 'currentColor'} /> : tab.label}</button>
))}
<span style={{ marginLeft: 'auto', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', alignSelf: 'center' }}>
<span style={{ marginInlineStart: 'auto', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', alignSelf: 'center' }}>
{t('files.count', { count: filteredFiles.length })}
</span>
</div>
@@ -95,7 +95,7 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{file.original_name}
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
<span style={{ marginInlineStart: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<Tooltip label={t('files.openTab')}>
@@ -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}
</button>
@@ -15,7 +15,7 @@ export function FileManagerToolbar(S: FileManagerState) {
const trashLabel = t('files.trash') || 'Trash'
return (
<div className="flex-none px-7 pt-6 max-md:px-4 max-md:pt-4">
<div className="flex flex-wrap items-center gap-2.5 rounded-[18px] bg-surface-tertiary py-3 pl-[22px] pr-3">
<div className="flex flex-wrap items-center gap-2.5 rounded-[18px] bg-surface-tertiary py-3 ps-[22px] pe-3">
<h2 className="m-0 shrink-0 text-subtitle font-semibold tracking-[-0.01em] text-content">
{showTrash ? trashLabel : t('files.title')}
</h2>
@@ -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"
>
<ChevronDown size={13} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
{t('docsync.binding.settings')}
@@ -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. */}
<span className={`relative min-w-0 flex-1 truncate font-medium ${reverse ? 'text-right' : 'text-left'}`}>
<span className={`relative min-w-0 flex-1 truncate font-medium ${reverse ? 'text-end' : 'text-start'}`}>
{caption}
</span>
@@ -103,7 +103,7 @@ export default function DocSyncPanel({
onAdd={p => (sync.connectionFor(p.id) ? setScopeFor(p.id) : setConnecting(p))}
/>
<div className="min-w-0 md:border-l md:border-edge-faint md:pl-6">
<div className="min-w-0 md:border-s md:border-edge-faint md:ps-6">
{active ? (
<div className="space-y-5">
<DocSyncBinding
@@ -272,7 +272,7 @@ function StoreButton({
onClick={onClick}
aria-current={active ? 'true' : undefined}
className={[
'group flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-colors',
'group flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-start transition-colors',
active
? 'border-transparent bg-accent-subtle'
: 'border-edge bg-surface hover:bg-surface-hover',
@@ -137,12 +137,12 @@ export default function DocSyncScopeModal({
<span className="text-body font-medium text-content">{t('docsync.scope.pickTitle')}</span>
{scopes && scopes.length > 6 && (
<span className="relative">
<Search size={13} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<Search size={13} className="pointer-events-none absolute start-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={query}
onChange={e => 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"
/>
</span>
)}
@@ -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"
>
<span className="min-w-0">
<span className="block truncate text-body text-content">{s.label}</span>
+2 -2
View File
@@ -46,8 +46,8 @@ export default function HelpBadge({
}): React.ReactElement {
return (
<span
className={`inline-flex items-center gap-1.5 h-7 rounded-full pl-2.5 text-caption font-semibold leading-none whitespace-nowrap ${
count === undefined ? 'pr-2.5' : 'pr-1'
className={`inline-flex items-center gap-1.5 h-7 rounded-full ps-2.5 text-caption font-semibold leading-none whitespace-nowrap ${
count === undefined ? 'pe-2.5' : 'pe-1'
} ${uppercase ? 'uppercase tracking-[0.08em]' : ''} ${TONES[tone]} ${className}`}
>
{Icon && <Icon className="w-3.5 h-3.5 flex-shrink-0" />}
+6 -6
View File
@@ -60,7 +60,7 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
{guide.media.steps && <HelpBadge tone="neutral" icon={Camera} count={guide.steps}>{t('help.center.screenshot')}</HelpBadge>}
{/* One way out of the header: into the screen. The docs link waits at the bottom. */}
{screen && screenRoute && (
<Link to={screenRoute} onClick={closeHelp} className="ml-auto">
<Link to={screenRoute} onClick={closeHelp} className="ms-auto">
<HelpBadge tone="accent" icon={ArrowUpRight} className="hover:bg-accent-hover transition-colors">
{t('help.center.goToScreen', { screen: t(ctxKey(screen.id, 'title')) })}
</HelpBadge>
@@ -76,7 +76,7 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
</div>
<ol className="relative flex flex-col gap-3">
{/* The rail: runs behind the step numbers from the first to the last. */}
<span className="absolute left-[27px] top-6 bottom-6 w-px bg-edge" aria-hidden="true" />
<span className="absolute start-[27px] top-6 bottom-6 w-px bg-edge" aria-hidden="true" />
{steps.map(n => {
const text = t(guideStepKey(guide.id, n))
const src = helpMedia.step(guide.id, n)
@@ -117,7 +117,7 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
box, and covering it would crop away the sides the
ring is drawn on. */}
<img src={src} alt="" loading="lazy" decoding="async" className="block w-full aspect-[16/10] object-contain" />
<span className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-inverse text-inverse-text flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-elevated">
<span className="absolute bottom-2 end-2 w-8 h-8 rounded-full bg-inverse text-inverse-text flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-elevated">
<Maximize2 className="w-3.5 h-3.5" />
</span>
</button>
@@ -146,10 +146,10 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
className="trek-help-shot group relative rounded-xl overflow-hidden border border-edge-secondary bg-surface-tertiary"
>
<img src={helpMedia.result(guide.id)} alt="" loading="lazy" decoding="async" className="block w-full aspect-video object-contain" />
<span className="absolute bottom-2 left-2">
<span className="absolute bottom-2 start-2">
<HelpBadge tone="success" icon={CheckCircle2}>{t('help.center.result')}</HelpBadge>
</span>
<span className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-inverse text-inverse-text flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-elevated">
<span className="absolute bottom-2 end-2 w-8 h-8 rounded-full bg-inverse text-inverse-text flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-elevated">
<Maximize2 className="w-3.5 h-3.5" />
</span>
</button>
@@ -205,7 +205,7 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
<button
type="button"
onClick={() => openGuide(g.id)}
className="group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left hover:bg-surface-hover transition-colors"
className="group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-start hover:bg-surface-hover transition-colors"
>
<span className="w-7 h-7 rounded-md bg-surface-tertiary text-content-muted flex items-center justify-center flex-shrink-0 group-hover:bg-accent group-hover:text-accent-text transition-colors">
<RelIcon className="w-3.5 h-3.5" />
+5 -5
View File
@@ -67,7 +67,7 @@ export default function HelpHome({ context }: { context: HelpContext | null }):
<HelpBadge tone="accent" icon={ListOrdered} count={guides.length}>{t('help.center.searchGuides')}</HelpBadge>
{context.docs.length > 0 && <HelpBadge tone="neutral" icon={BookOpen} count={context.docs.length}>{t('help.center.searchDocs')}</HelpBadge>}
{screenRoute && (
<Link to={screenRoute} onClick={closeHelp} className="ml-auto">
<Link to={screenRoute} onClick={closeHelp} className="ms-auto">
<HelpBadge tone="accent" icon={ArrowUpRight} className="hover:bg-accent-hover transition-colors">
{t('help.center.goToScreen', { screen: t(ctxKey(context.id, 'title')) })}
</HelpBadge>
@@ -83,7 +83,7 @@ export default function HelpHome({ context }: { context: HelpContext | null }):
type="button"
onClick={() => setSubScreensOpen(o => !o)}
aria-expanded={subScreensOpen}
className="flex items-center gap-3 p-4 text-left rounded-2xl hover:bg-surface-hover transition-colors"
className="flex items-center gap-3 p-4 text-start rounded-2xl hover:bg-surface-hover transition-colors"
>
<HelpBadge tone="neutral" icon={LayoutGrid} uppercase count={children.length}>{t('help.center.subScreensLabel')}</HelpBadge>
<span className="min-w-0 flex-1 text-caption text-content-muted truncate">
@@ -113,7 +113,7 @@ export default function HelpHome({ context }: { context: HelpContext | null }):
decoding="async"
className="block w-full aspect-video object-contain"
/>
<span className="absolute bottom-3 left-3">
<span className="absolute bottom-3 start-3">
<HelpBadge tone="accent" icon={Camera}>{t(ctxKey(context.id, 'title'))}</HelpBadge>
</span>
</figure>
@@ -178,7 +178,7 @@ function SubScreenCard({ screen }: { screen: HelpContext }): React.ReactElement
<button
type="button"
onClick={() => browse(current ? null : screen.id)}
className="group w-full h-full flex items-center gap-3 rounded-xl border border-edge bg-surface-secondary px-3 py-2.5 text-left hover:border-content-faint hover:bg-surface-card transition-colors"
className="group w-full h-full flex items-center gap-3 rounded-xl border border-edge bg-surface-secondary px-3 py-2.5 text-start hover:border-content-faint hover:bg-surface-card transition-colors"
>
<span className="w-9 h-9 rounded-lg bg-surface-card text-content-secondary flex items-center justify-center flex-shrink-0 group-hover:bg-accent group-hover:text-accent-text transition-colors">
<Icon className="w-4 h-4" />
@@ -214,7 +214,7 @@ export function GuideCard({ guide }: { guide: HelpGuide }): React.ReactElement {
<button
type="button"
onClick={() => openGuide(guide.id)}
className="group w-full h-full flex flex-col gap-3 rounded-2xl border border-edge bg-surface-card p-4 text-left hover:border-content-faint hover:shadow-elevated transition-[border-color,box-shadow,transform] duration-200 ease-[var(--ease-out-quint)] hover:-translate-y-px"
className="group w-full h-full flex flex-col gap-3 rounded-2xl border border-edge bg-surface-card p-4 text-start hover:border-content-faint hover:shadow-elevated transition-[border-color,box-shadow,transform] duration-200 ease-[var(--ease-out-quint)] hover:-translate-y-px"
>
<span className="flex items-center justify-between">
<span className="w-10 h-10 rounded-xl bg-surface-tertiary text-content-secondary flex items-center justify-center group-hover:bg-accent group-hover:text-accent-text transition-colors">
+1 -1
View File
@@ -22,7 +22,7 @@ export default function HelpLightbox(): React.ReactElement | null {
type="button"
onClick={() => close(null)}
aria-label={t('help.center.close')}
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-inverse text-inverse-text flex items-center justify-center shadow-elevated hover:scale-105 transition-transform"
className="absolute top-4 end-4 w-10 h-10 rounded-full bg-inverse text-inverse-text flex items-center justify-center shadow-elevated hover:scale-105 transition-transform"
>
<X className="w-5 h-5" />
</button>
+12 -12
View File
@@ -41,7 +41,7 @@ export default function HelpNav({ context, currentId, activeGuideId }: {
const [familyOpen, setFamilyOpen] = useState(false)
return (
<nav className="flex flex-col min-h-0 border-b md:border-b-0 md:border-r border-edge bg-surface-secondary" aria-label={t('help.center.title')}>
<nav className="flex flex-col min-h-0 border-b md:border-b-0 md:border-e border-edge bg-surface-secondary" aria-label={t('help.center.title')}>
<div className="px-5 pt-5 pb-3 flex items-center gap-2.5">
<span className="w-8 h-8 rounded-lg flex items-center justify-center bg-accent text-accent-text">
<LifeBuoy className="w-4 h-4" />
@@ -51,7 +51,7 @@ export default function HelpNav({ context, currentId, activeGuideId }: {
<div className="px-5 pb-4">
<label className="relative block">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-content-faint pointer-events-none" />
<Search className="w-4 h-4 absolute start-3 top-1/2 -translate-y-1/2 text-content-faint pointer-events-none" />
<input
ref={searchRef}
type="search"
@@ -59,7 +59,7 @@ export default function HelpNav({ context, currentId, activeGuideId }: {
onChange={e => setQuery(e.target.value)}
placeholder={t('help.center.searchPlaceholder')}
aria-label={t('help.center.searchPlaceholder')}
className="w-full rounded-xl bg-surface-card text-content text-body pl-9 pr-3 py-2.5 outline-none border border-edge focus:border-content-faint transition-colors placeholder:text-content-faint"
className="w-full rounded-xl bg-surface-card text-content text-body ps-9 pe-3 py-2.5 outline-none border border-edge focus:border-content-faint transition-colors placeholder:text-content-faint"
/>
</label>
</div>
@@ -77,7 +77,7 @@ export default function HelpNav({ context, currentId, activeGuideId }: {
type="button"
onClick={() => setFamilyOpen(o => !o)}
aria-expanded={familyOpen}
className="flex items-center gap-1.5 px-2 pb-1 text-left text-caption font-semibold uppercase tracking-[0.1em] text-content-faint hover:text-content transition-colors"
className="flex items-center gap-1.5 px-2 pb-1 text-start text-caption font-semibold uppercase tracking-[0.1em] text-content-faint hover:text-content transition-colors"
>
<span className="flex-1">{t('help.center.subScreensLabel')}</span>
<span className="text-caption font-bold tabular-nums normal-case tracking-normal">{family.length}</span>
@@ -101,7 +101,7 @@ export default function HelpNav({ context, currentId, activeGuideId }: {
type="button"
onClick={() => { closeGuide(); setQuery('') }}
aria-current={overviewActive ? 'page' : undefined}
className={`flex items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors ${
className={`flex items-center gap-2.5 rounded-lg px-2 py-2 text-start transition-colors ${
overviewActive ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
}`}
>
@@ -219,7 +219,7 @@ function ScreenSwitcher({ shown, currentId }: { shown: HelpContext | null; curre
onClick={toggleList}
aria-haspopup="listbox"
aria-expanded={open}
className="w-full flex items-center gap-2.5 rounded-xl border border-edge bg-surface-card px-3 py-2.5 text-left hover:border-content-faint transition-colors"
className="w-full flex items-center gap-2.5 rounded-xl border border-edge bg-surface-card px-3 py-2.5 text-start hover:border-content-faint transition-colors"
>
<span className="w-7 h-7 rounded-md bg-accent text-accent-text flex items-center justify-center flex-shrink-0">
<ShownIcon className="w-3.5 h-3.5" />
@@ -236,7 +236,7 @@ function ScreenSwitcher({ shown, currentId }: { shown: HelpContext | null; curre
<ul
role="listbox"
aria-label={t('help.center.screens')}
className="trek-help-menu absolute left-2 right-2 top-full mt-1.5 z-10 flex flex-col gap-0.5 rounded-xl border border-edge bg-surface-card p-1.5 shadow-elevated"
className="trek-help-menu absolute inset-x-2 top-full mt-1.5 z-10 flex flex-col gap-0.5 rounded-xl border border-edge bg-surface-card p-1.5 shadow-elevated"
>
{screens.map(screen => {
const children = childHelpContexts(screen.id)
@@ -251,7 +251,7 @@ function ScreenSwitcher({ shown, currentId }: { shown: HelpContext | null; curre
group={children.length > 0 ? { expanded, count: children.length, toggle: () => setOpenGroup(expanded ? null : screen.id) } : undefined}
/>
{children.length > 0 && expanded && (
<ul role="group" className="flex flex-col gap-0.5 pl-3">
<ul role="group" className="flex flex-col gap-0.5 ps-3">
{children.map(child => (
<li key={child.id}>
<ScreenRow
@@ -296,7 +296,7 @@ function ScreenRow({
selected ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
}`}
>
<button type="button" onClick={onPick} className="min-w-0 flex-1 flex items-center gap-2.5 px-2 py-2 text-left">
<button type="button" onClick={onPick} className="min-w-0 flex-1 flex items-center gap-2.5 px-2 py-2 text-start">
{nested ? (
<CornerDownRight className="w-3.5 h-3.5 flex-shrink-0 text-content-faint" />
) : (
@@ -312,7 +312,7 @@ function ScreenRow({
onClick={e => { e.stopPropagation(); group.toggle() }}
aria-expanded={group.expanded}
aria-label={t('help.center.subScreens', { count: group.count })}
className="flex items-center gap-1 rounded-md mr-1.5 px-1.5 py-1 text-caption font-semibold text-content-faint hover:bg-surface-tertiary hover:text-content transition-colors"
className="flex items-center gap-1 rounded-md me-1.5 px-1.5 py-1 text-caption font-semibold text-content-faint hover:bg-surface-tertiary hover:text-content transition-colors"
>
{group.count}
<Fold className="w-3.5 h-3.5" />
@@ -332,7 +332,7 @@ function NavScreen({ screen, shown, current }: { screen: HelpContext; shown: boo
type="button"
onClick={() => browse(current ? null : screen.id)}
aria-current={shown ? 'page' : undefined}
className={`group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors ${
className={`group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-start transition-colors ${
shown ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
}`}
>
@@ -360,7 +360,7 @@ function NavGuide({ guide, active }: { guide: HelpGuide; active: boolean }): Rea
type="button"
onClick={() => openGuide(guide.id)}
aria-current={active ? 'page' : undefined}
className={`group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors ${
className={`group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-start transition-colors ${
active ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
}`}
>
+1 -1
View File
@@ -106,7 +106,7 @@ export default function HelpPanel(): React.ReactElement | null {
type="button"
onClick={closeHelp}
aria-label={t('help.center.close')}
className="absolute top-4 right-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
className="absolute top-4 end-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
>
<X className="w-4 h-4" />
</button>
@@ -105,7 +105,7 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
<button type="button"
key={u.id}
onClick={() => setSelectedUserId(u.id)}
className={`w-full text-left flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
className={`w-full text-start flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
selectedUserId === u.id
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-white'
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent'
@@ -204,14 +204,14 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
)}
>
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<Search size={14} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('journey.contributors.searchPlaceholder')}
aria-label={t('journey.contributors.searchUser')}
className={`${INPUT} pl-8`}
className={`${INPUT} ps-8`}
/>
</div>
@@ -227,7 +227,7 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
key={u.id}
onClick={() => setSelectedUserId(u.id)}
aria-pressed={on}
className={`flex min-h-[48px] w-full items-center gap-3 rounded-[10px] px-2.5 py-1.5 text-left ${on ? 'bg-surface-card shadow-sm' : 'hover:bg-surface-card'}`}
className={`flex min-h-[48px] w-full items-center gap-3 rounded-[10px] px-2.5 py-1.5 text-start ${on ? 'bg-surface-card shadow-sm' : 'hover:bg-surface-card'}`}
>
<TripMemberAvatar username={u.username} avatarUrl={avatarSrc(u.avatar)} />
<span className="min-w-0 flex-1">
@@ -24,8 +24,8 @@ export default function JournalBody({ text, dark }: Props) {
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
blockquote: ({ children }) => (
<blockquote style={{
borderLeft: `3px solid var(--journal-accent)`,
paddingLeft: 16, margin: '12px 0',
borderInlineStart: `3px solid var(--journal-accent)`,
paddingInlineStart: 16, margin: '12px 0',
fontStyle: 'italic', color: 'var(--journal-muted)',
}}>{children}</blockquote>
),
@@ -35,8 +35,8 @@ export default function JournalBody({ text, dark }: Props) {
{children}
</a>
),
ul: ({ children }) => <ul style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ul>,
ol: ({ children }) => <ol style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ol>,
ul: ({ children }) => <ul style={{ paddingInlineStart: 20, margin: '8px 0' }}>{children}</ul>,
ol: ({ children }) => <ol style={{ paddingInlineStart: 20, margin: '8px 0' }}>{children}</ol>,
li: ({ children }) => <li style={{ margin: '4px 0' }}>{children}</li>,
strong: ({ children }) => <strong style={{ fontWeight: 600 }}>{children}</strong>,
em: ({ children }) => <em>{children}</em>,
@@ -53,7 +53,7 @@ export default function JourneyDayDawarich({
type="button"
onClick={() => setOpen(v => !v)}
aria-expanded={open}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover"
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-start transition-colors hover:bg-surface-hover"
>
{/* The brand mark in its own colours, which is what tells this row from an entry
at a glance. No second label saying "Dawarich": the mark is the label. */}
@@ -60,7 +60,7 @@ export default function JourneyDayJump({ days, feedRef }: { days: JumpDay[]; fee
<div key={day.date}>
<div className="flex items-center gap-1">
<button type="button" role="menuitem" onClick={() => goDay(day.date)}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-xl px-2 py-1.5 text-left hover:bg-[color:var(--vg-surf2)]">
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-xl px-2 py-1.5 text-start hover:bg-[color:var(--vg-surf2)]">
<span className="grid h-6 w-6 flex-none place-items-center rounded-lg text-[11px] font-bold text-white" style={{ background: day.color }}>{i + 1}</span>
<span className="min-w-0 flex-1 truncate text-[12.5px] font-semibold" style={{ color: 'var(--vg-ink)' }}>{dateLabel(day.date)}</span>
<span className="flex-none rounded-full px-1.5 text-[10px] font-bold tabular-nums" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}>{day.entries.length}</span>
@@ -75,7 +75,7 @@ export default function JourneyDayJump({ days, feedRef }: { days: JumpDay[]; fee
</div>
{isOpen && day.entries.map(entry => (
<button key={entry.id} type="button" role="menuitem" onClick={() => goEntry(entry.id)}
className="flex w-full items-center gap-2 rounded-lg py-1 pl-11 pr-2 text-left text-[12px] hover:bg-[color:var(--vg-surf2)]" style={{ color: 'var(--vg-ink2)' }}>
className="flex w-full items-center gap-2 rounded-lg py-1 ps-11 pe-2 text-start text-[12px] hover:bg-[color:var(--vg-surf2)]" style={{ color: 'var(--vg-ink2)' }}>
<span className="h-1.5 w-1.5 flex-none rounded-full" style={{ background: day.color }} />
<span className="min-w-0 truncate">{entry.title}</span>
</button>
@@ -68,14 +68,14 @@ export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }:
)}
>
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<Search size={14} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('journey.trips.searchPlaceholder')}
aria-label={t('journey.trips.searchTrip')}
className={`${INPUT} pl-8`}
className={`${INPUT} ps-8`}
/>
</div>
@@ -109,7 +109,7 @@ export function DatePicker({ value, onChange, tripDates }: {
<button
type="button"
onClick={toggleOpen}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-start flex items-center justify-between"
>
{formatted ? (
<span>{formatted}</span>
@@ -127,7 +127,7 @@ export function DatePicker({ value, onChange, tripDates }: {
{/* Click-away catcher — no semantics of its own; the trigger button
above closes the popover again from the keyboard. */}
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
<div className="absolute top-full start-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
{/* Header: arrows step the current view, the label climbs to the next one up */}
<div className="flex items-center justify-between mb-2">
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
@@ -58,7 +58,7 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
<div className="absolute inset-x-0 bottom-0 pointer-events-none" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.2) 50%, transparent 100%)', height: '60%' }} />
{/* Badges top-left */}
<div className="absolute top-3 left-4 right-14 flex items-center gap-1.5 z-[2]">
<div className="absolute top-3 start-4 end-14 flex items-center gap-1.5 z-[2]">
{entry.location_name && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" />
@@ -90,14 +90,14 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
{/* Menu top-right, raised on the card colour so it reads on any photo */}
{!readOnly && (
<div className="absolute top-2.5 right-3 z-[2]">
<div className="absolute top-2.5 end-3 z-[2]">
<MoreButton label={t('files.menu')} items={menuItems} size={32} alwaysVisible className="bg-surface-card shadow-sm" />
</div>
)}
{/* Title on photo */}
{entry.title && (
<div className="absolute bottom-4 left-5 right-5 z-[2] pointer-events-none">
<div className="absolute bottom-4 inset-x-5 z-[2] pointer-events-none">
<h3 className="text-[22px] font-bold text-white tracking-[-0.02em] leading-tight drop-shadow-sm">{entry.title}</h3>
</div>
)}
@@ -105,7 +105,7 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
) : (
/* No photos: simple header */
<div className="flex items-center justify-between px-4 pt-3">
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
<div className="flex items-center gap-2 min-w-0 flex-1 me-2">
{entry.location_name && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{formatLocationName(entry.location_name)}</span>
@@ -172,8 +172,8 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
<div key={`${p.pluginId}-${i}`} className="flex items-baseline justify-between gap-2 text-[12px]">
<span className="font-medium text-zinc-500 dark:text-zinc-400 flex-shrink-0">{it.label}</span>
{it.url
? <a href={it.url} target="_blank" rel="noreferrer noopener" className="text-indigo-600 dark:text-indigo-400 truncate text-right">{it.value ?? it.url}</a>
: <span className="text-zinc-600 dark:text-zinc-300 truncate text-right">{it.value}</span>}
? <a href={it.url} target="_blank" rel="noreferrer noopener" className="text-indigo-600 dark:text-indigo-400 truncate text-end">{it.value ?? it.url}</a>
: <span className="text-zinc-600 dark:text-zinc-300 truncate text-end">{it.value}</span>}
</div>
)))}
</div>
@@ -39,9 +39,9 @@ const SOURCE_ON = 'border-content bg-surface-secondary text-content'
const PHOTO_PANEL = 'mt-2 rounded-[12px] border border-edge-faint bg-surface-secondary'
/** A control laid over a photo tile, raised on the card colour so it reads on any picture. */
const ON_PHOTO = 'bg-surface-card text-content shadow-sm'
const PHOTO_REMOVE = `absolute right-1 top-1 grid h-5 w-5 place-items-center rounded-full opacity-0 transition-opacity group-hover:opacity-100 ${ON_PHOTO}`
const PHOTO_REMOVE = `absolute end-1 top-1 grid h-5 w-5 place-items-center rounded-full opacity-0 transition-opacity group-hover:opacity-100 ${ON_PHOTO}`
/** What hangs under the location field: the search results, or the note that a search is running. */
const DROPDOWN = 'absolute left-0 right-0 top-full z-[100] mt-1 rounded-[12px] border border-edge-faint bg-surface-card shadow-dropdown'
const DROPDOWN = 'absolute inset-x-0 top-full z-[100] mt-1 rounded-[12px] border border-edge-faint bg-surface-card shadow-dropdown'
const VERDICT_TONES = {
pros: { Icon: Check, text: 'text-success', soft: 'bg-success-soft', dot: 'bg-success' },
@@ -659,12 +659,12 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<img src={photoUrl(p)} className="h-full w-full object-cover" alt="" onError={thumbnailFallback(p)} />
)}
{idx === 0 && photos.length > 1 && (
<span className={`absolute bottom-0.5 left-0.5 rounded px-1 py-px font-bold ${ON_PHOTO}`} style={fs(8)}>{t('journey.editor.photoFirst')}</span>
<span className={`absolute bottom-0.5 start-0.5 rounded px-1 py-px font-bold ${ON_PHOTO}`} style={fs(8)}>{t('journey.editor.photoFirst')}</span>
)}
{idx > 0 && photos.length > 1 && (
<button type="button"
onClick={e => { e.stopPropagation(); photoOrder.makeFirst(idx) }}
className={`absolute bottom-0.5 left-0.5 rounded px-1.5 py-0.5 font-semibold opacity-0 transition-opacity group-hover:opacity-100 ${ON_PHOTO}`}
className={`absolute bottom-0.5 start-0.5 rounded px-1.5 py-0.5 font-semibold opacity-0 transition-opacity group-hover:opacity-100 ${ON_PHOTO}`}
style={fs(8)}
>
{t('journey.editor.makeFirst')}
@@ -825,7 +825,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
}}
onFocus={() => { if (locationResults.length > 0) setShowLocationResults(true) }}
placeholder={t('journey.editor.searchLocation')}
className={`${INPUT} pr-9`}
className={`${INPUT} pe-9`}
/>
<Tooltip label={t('journey.editor.useCurrentLocation')}>
<button
@@ -833,7 +833,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
onClick={handleUseCurrentLocation}
disabled={locating}
aria-label={t('journey.editor.useCurrentLocation')}
className="absolute right-1 top-1/2 grid h-7 w-7 -translate-y-1/2 place-items-center rounded-[8px] text-content-faint hover:bg-surface-hover hover:text-content disabled:cursor-default disabled:opacity-50"
className="absolute end-1 top-1/2 grid h-7 w-7 -translate-y-1/2 place-items-center rounded-[8px] text-content-faint hover:bg-surface-hover hover:text-content disabled:cursor-default disabled:opacity-50"
>
{locating ? <Loader2 size={14} className="animate-spin" /> : <Locate size={14} />}
</button>
@@ -854,7 +854,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
setShowLocationResults(false)
setLocationResults([])
}}
className="flex w-full items-start gap-2.5 rounded-[8px] px-2.5 py-2 text-left hover:bg-surface-hover"
className="flex w-full items-start gap-2.5 rounded-[8px] px-2.5 py-2 text-start hover:bg-surface-hover"
>
<MapPin size={13} className="mt-0.5 flex-none text-content-faint" />
<div className="min-w-0">
@@ -163,12 +163,12 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
{/* Delete button */}
<button type="button"
onClick={(e) => { e.stopPropagation(); void handleDeletePhoto(photo.id) }}
className="absolute top-1.5 right-1.5 w-6 h-6 rounded-full bg-black/60 backdrop-blur text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
className="absolute top-1.5 end-1.5 w-6 h-6 rounded-full bg-black/60 backdrop-blur text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
<X size={12} />
</button>
{photo.provider && photo.provider !== 'local' && (
<div className="absolute top-1.5 left-1.5">
<div className="absolute top-1.5 start-1.5">
<span className="text-[8px] font-medium px-1.5 py-0.5 rounded-full bg-black/70 backdrop-blur text-white flex items-center gap-1">
<RefreshCw size={7} />
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synologyphotos' ? 'Synology Photos' : photo.provider}
@@ -176,7 +176,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
</div>
)}
{photo.caption && (
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute bottom-0 inset-x-0 p-2 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">{photo.caption}</p>
</div>
)}
@@ -88,7 +88,7 @@ export function MapView({ entries, mapEntries, sortedDates, activeLocationId, fu
<button
type="button"
onClick={() => onLocationClick(String(e.id))}
className={`w-full text-left flex items-center gap-3 p-3 rounded-[14px] cursor-pointer transition-all ${
className={`w-full text-start flex items-center gap-3 p-3 rounded-[14px] cursor-pointer transition-all ${
isActive
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-zinc-100 translate-x-0.5'
: 'bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:translate-x-0.5'
@@ -119,7 +119,7 @@ export function MapView({ entries, mapEntries, sortedDates, activeLocationId, fu
{/* Connector line */}
{showConnector && (
<div className="w-0.5 h-2 bg-zinc-200 dark:bg-zinc-700 ml-[18px] rounded-full" />
<div className="w-0.5 h-2 bg-zinc-200 dark:bg-zinc-700 ms-[18px] rounded-full" />
)}
</div>
)
@@ -69,7 +69,7 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick
<button type="button" className="flex-1 min-h-0 relative cursor-pointer" onClick={() => onClick(2)}>
<PhotoImg photo={photos[2]} className="w-full h-full object-cover" />
{count > 3 && (
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
<div className="absolute bottom-2 end-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
<Image size={10} />
+{count - 3}
</div>
@@ -421,7 +421,7 @@ export function ProviderPicker({
year: 'numeric',
})}
</span>
<span className="ml-1 text-zinc-400">
<span className="ms-1 text-zinc-400">
(
{Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) +
1}{' '}
@@ -501,14 +501,14 @@ export function ProviderPicker({
{addToOpen && (
<>
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
<div className="absolute top-full left-12 z-10 mt-1 max-h-[240px] min-w-[200px] overflow-y-auto rounded-xl border border-zinc-200 bg-white py-1.5 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
<div className="absolute top-full start-12 z-10 mt-1 max-h-[240px] min-w-[200px] overflow-y-auto rounded-xl border border-zinc-200 bg-white py-1.5 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
<button
type="button"
onClick={() => {
setTargetEntryId(null);
setAddToOpen(false);
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] ${
className={`flex w-full items-center gap-2 px-3 py-2 text-start text-[12px] ${
!targetEntryId
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
@@ -530,7 +530,7 @@ export function ProviderPicker({
setTargetEntryId(e.id);
setAddToOpen(false);
}}
className={`w-full truncate px-3 py-2 text-left text-[12px] ${
className={`w-full truncate px-3 py-2 text-start text-[12px] ${
targetEntryId === e.id
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
@@ -656,22 +656,22 @@ export function ProviderPicker({
}}
/>
{alreadyAdded && (
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-500 text-white">
<div className="absolute top-1.5 end-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-500 text-white">
<Check size={12} />
</div>
)}
{isSelected && !alreadyAdded && (
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
<div className="absolute top-1.5 end-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
<Check size={12} />
</div>
)}
{asset.mediaType === 'video' && (
<div className="pointer-events-none absolute bottom-1.5 left-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-black/60">
<div className="pointer-events-none absolute bottom-1.5 start-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-black/60">
<Play size={8} className="ml-px text-white" fill="currentColor" />
</div>
)}
{asset.city && (
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/50 to-transparent p-1">
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/50 to-transparent p-1">
<p className="truncate text-[8px] text-white">{asset.city}</p>
</div>
)}
@@ -360,7 +360,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
<button
type="button"
onClick={() => { void onRestoreSuggestions() }}
className="flex w-full items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary px-3.5 py-2.5 text-left transition-colors hover:bg-surface-card"
className="flex w-full items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary px-3.5 py-2.5 text-start transition-colors hover:bg-surface-card"
>
<Undo2 size={15} className="flex-none text-content-faint" />
<span className="min-w-0 flex-1">
@@ -59,7 +59,7 @@ export function VerdictSection({ pros, cons }: { pros: string[]; cons: string[]
<Check size={14} className="text-white" strokeWidth={3} />
</div>
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-green-700 dark:text-green-400">{t('journey.verdict.lovedIt')}</span>
<span className="ml-auto text-[11px] font-semibold text-green-600">{pros.length}</span>
<span className="ms-auto text-[11px] font-semibold text-green-600">{pros.length}</span>
</div>
<div className="flex flex-col gap-2">
{pros.map((p, i) => (
@@ -78,7 +78,7 @@ export function VerdictSection({ pros, cons }: { pros: string[]; cons: string[]
<Minus size={14} className="text-white" strokeWidth={3} />
</div>
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-red-700 dark:text-red-400">{t('journey.verdict.couldBeBetter')}</span>
<span className="ml-auto text-[11px] font-semibold text-red-600">{cons.length}</span>
<span className="ms-auto text-[11px] font-semibold text-red-600">{cons.length}</span>
</div>
<div className="flex flex-col gap-2">
{cons.map((c, i) => (
@@ -103,7 +103,7 @@ export default function JourneyEntryCover({
// The whole card is the photo, so its own ring is what separates it from the
// map behind. A suggestion is drawn as an outline rather than a solid thing:
// it is somewhere you planned to be, not somewhere you have written about.
className={`relative flex-none overflow-hidden rounded-[18px] text-left transition-[width,height] duration-150 ${
className={`relative flex-none overflow-hidden rounded-[18px] text-start transition-[width,height] duration-150 ${
isActive ? 'h-[180px] w-[164px] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'h-[152px] w-[136px] shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
} ${src ? '' : emptyGround} ${isSuggestion ? 'opacity-90' : ''}`}
// backgroundImage, not background: the shorthand would drop the opaque colour
@@ -140,7 +140,7 @@ export default function JourneyEntryCover({
was doing, against a bright sky. Sized and placed to sit on the same line
as the date opposite it. */}
{entry.country_code && (
<span className="absolute left-2 top-2 flex h-[18px] items-center drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]">
<span className="absolute start-2 top-2 flex h-[18px] items-center drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]">
<CountryFlag code={entry.country_code} size={14} />
</span>
)}
@@ -150,7 +150,7 @@ export default function JourneyEntryCover({
italic, dimmed title carries the rest. */}
{isSuggestion && (
<span
className={`absolute top-2 ${entry.country_code ? 'left-[30px]' : 'left-2'} ${badgeDot} text-white`}
className={`absolute top-2 ${entry.country_code ? 'start-[30px]' : 'start-2'} ${badgeDot} text-white`}
title={t('journey.entry.suggestion')}
aria-label={t('journey.entry.suggestion')}
>
@@ -160,7 +160,7 @@ export default function JourneyEntryCover({
{/* Top right: when, and how it was. They fit beside each other now that the
flag on the left is a bare mark rather than a chip of its own. */}
<span className="absolute right-2 top-2 flex items-center gap-1">
<span className="absolute end-2 top-2 flex items-center gap-1">
{mood && (
<span className={badgeDot} style={{ color: mood.color }}>
<mood.icon size={11} strokeWidth={2.4} />
@@ -77,7 +77,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
<div className="flex flex-col gap-3">
{/* URL + Copy */}
<div className="flex items-center gap-2 p-2 rounded-2xl bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700">
<Link size={13} className="text-zinc-400 flex-shrink-0 ml-1.5" />
<Link size={13} className="text-zinc-400 flex-shrink-0 ms-1.5" />
<span className="flex-1 text-[11px] text-zinc-600 dark:text-zinc-400 truncate">{shareUrl}</span>
<button type="button"
onClick={copyLink}
@@ -111,7 +111,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
>
<Icon size={13} />
{label}
{link[key] && <Check size={12} className="ml-auto" />}
{link[key] && <Check size={12} className="ms-auto" />}
</button>
))}
</div>
@@ -129,7 +129,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
</button>
{photos.length > 1 && (
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
<div className="absolute bottom-3 end-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
<Camera size={12} />
{t('mobileJourney.photosCount', { count: photos.length })}
</div>
@@ -180,7 +180,7 @@ export default function MobileMapTimeline({
if (entries.length === 0) {
return (
<div
className="fixed left-0 right-0 z-10"
className="fixed inset-x-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<JourneyMap
@@ -196,7 +196,7 @@ export default function MobileMapTimeline({
cartoApiKey={cartoApiKey}
/>
{!readOnly && onAddEntry && (
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
<div className="fixed end-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
<button type="button"
onClick={onAddEntry}
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
@@ -211,7 +211,7 @@ export default function MobileMapTimeline({
return (
<div
className="fixed left-0 right-0 z-10"
className="fixed inset-x-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
{/* Full-screen map */}
@@ -232,7 +232,7 @@ export default function MobileMapTimeline({
{/* Day bar + card carousel, as one block at the bottom of the map */}
<div
className="fixed left-0 right-0 z-40"
className="fixed inset-x-0 z-40"
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
>
<JourneyDayScrubber
@@ -274,7 +274,7 @@ export default function MobileMapTimeline({
{/* FAB: add entry — bottom right, above the timeline carousel */}
{!readOnly && onAddEntry && (
<div
className="fixed right-4 z-30"
className="fixed end-4 z-30"
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 226px)' }}
>
<button type="button"
@@ -105,7 +105,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }}
>
{/* Top bar */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}>
<div style={{ position: 'absolute', top: 0, insetInline: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}>
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>
{idx + 1} / {photos.length}
</span>
+1 -1
View File
@@ -100,7 +100,7 @@ export default function BottomNav() {
<nav
className="md:hidden fixed z-[60] flex items-center"
style={{
left: 12, right: 12,
insetInline: 12,
bottom: 'calc(12px + env(safe-area-inset-bottom, 0px))',
padding: '8px 8px',
borderRadius: 24,
+2 -3
View File
@@ -332,8 +332,7 @@ export default function DemoBanner(): React.ReactElement | null {
justifyContent: 'center',
paddingTop: 'max(16px, env(safe-area-inset-top))',
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
paddingLeft: 16,
paddingRight: 16,
paddingInline: 16,
overflow: 'auto',
fontFamily: 'var(--font-system)',
}}
@@ -508,7 +507,7 @@ export default function DemoBanner(): React.ReactElement | null {
color: '#94a3b8',
margin: 0,
lineHeight: 1.3,
paddingLeft: 18,
paddingInlineStart: 18,
}}
>
{desc}
@@ -52,7 +52,7 @@ export default function InAppNotificationBell(): React.ReactElement {
<Bell className="w-4 h-4" />
{unreadCount > 0 && (
<span
className="absolute -top-0.5 -right-0.5 flex items-center justify-center rounded-full text-white font-bold"
className="absolute -top-0.5 -end-0.5 flex items-center justify-center rounded-full text-white font-bold"
style={{
background: '#ef4444',
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
@@ -76,7 +76,7 @@ export default function InAppNotificationBell(): React.ReactElement {
style={{
position: 'fixed',
top: 'var(--nav-h)',
right: 8,
insetInlineEnd: 8,
width: 360,
maxWidth: 'calc(100vw - 16px)',
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
@@ -92,7 +92,7 @@ export default function InAppNotificationBell(): React.ReactElement {
<span className="text-sm font-semibold text-content">
{t('notifications.title')}
{unreadCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium bg-content text-surface">
<span className="ms-2 px-1.5 py-0.5 rounded-full text-xs font-medium bg-content text-surface">
{unreadCount}
</span>
)}
@@ -32,7 +32,7 @@ export default function MobileTopBar() {
>
<Bell size={20} strokeWidth={1.9} />
{unread > 0 && (
<span style={{ position: 'absolute', top: 7, right: 7, width: 8, height: 8, borderRadius: '50%', background: 'oklch(0.7 0.17 38)', boxShadow: '0 0 0 2px var(--bg, #fff)' }} />
<span style={{ position: 'absolute', top: 7, insetInlineEnd: 7, width: 8, height: 8, borderRadius: '50%', background: 'oklch(0.7 0.17 38)', boxShadow: '0 0 0 2px var(--bg, #fff)' }} />
)}
</button>
<button type="button"
@@ -62,7 +62,7 @@ function ProfileSheet({ onClose }: { onClose: () => void }) {
<div className="fixed inset-0 z-[300] md:hidden" role="presentation" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
className="absolute bottom-0 inset-x-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
role="presentation"
onClick={e => e.stopPropagation()}
@@ -93,7 +93,7 @@ function ProfileSheet({ onClose }: { onClose: () => void }) {
<div className="py-2 px-2">
<button type="button"
onClick={() => handleNav('/settings')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-start hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
>
<Settings size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t('nav.bottomSettings')}</span>
@@ -102,7 +102,7 @@ function ProfileSheet({ onClose }: { onClose: () => void }) {
{user?.role === 'admin' && (
<button type="button"
onClick={() => handleNav('/admin')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-start hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
>
<Shield size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t('nav.bottomAdmin')}</span>
@@ -115,7 +115,7 @@ function ProfileSheet({ onClose }: { onClose: () => void }) {
<div className="py-2 px-2">
<button type="button"
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-red-50 dark:hover:bg-red-900/20 active:bg-red-100 transition-colors"
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-start hover:bg-red-50 dark:hover:bg-red-900/20 active:bg-red-100 transition-colors"
>
<LogOut size={18} className="text-red-500" />
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t('nav.bottomLogout')}</span>
+2 -2
View File
@@ -110,7 +110,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)',
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
the tab pill between them sits in the middle of the bar rather than
being centred on top of both (#1983). */}
@@ -277,7 +277,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{userMenuOpen && createPortal(
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} role="presentation" onClick={() => setUserMenuOpen(false)} />
<div className="trek-menu-enter w-52 rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999 }}>
<div className="trek-menu-enter w-52 rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge" style={{ position: 'fixed', top: 'var(--nav-h)', insetInlineEnd: 8, zIndex: 9999 }}>
<div className="px-4 py-3 border-b border-edge-secondary">
<p className="text-sm font-medium text-content">{user.username}</p>
<p className="text-xs truncate text-content-muted">{user.email}</p>
+2 -2
View File
@@ -93,7 +93,7 @@ export default function PageSidebar({
/>
<aside
ref={drawerRef}
className="lg:hidden fixed top-0 left-0 bottom-0 z-50 flex flex-col shadow-2xl bg-surface-secondary"
className="lg:hidden fixed top-0 start-0 bottom-0 z-50 flex flex-col shadow-2xl bg-surface-secondary"
style={{
width: 280,
padding: '18px 14px',
@@ -174,7 +174,7 @@ function SidebarInner({
<button type="button"
onClick={() => onTabChange(tab.id)}
aria-current={active ? 'page' : undefined}
className={`flex items-center gap-2.5 rounded-[11px] px-2 py-1.5 text-left transition-colors ${active ? 'bg-surface-card font-semibold text-content shadow-sm ring-1 ring-edge-faint' : 'font-medium text-content-secondary hover:bg-surface-hover hover:text-content'}`}
className={`flex items-center gap-2.5 rounded-[11px] px-2 py-1.5 text-start transition-colors ${active ? 'bg-surface-card font-semibold text-content shadow-sm ring-1 ring-edge-faint' : 'font-medium text-content-secondary hover:bg-surface-hover hover:text-content'}`}
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
>
<span className={`grid h-7 w-7 shrink-0 place-items-center rounded-[8px] ${active ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}`}>
+1 -1
View File
@@ -36,7 +36,7 @@ export default function PlaceHoverCard({ x, y, name, categoryName, categoryIcon,
const tone = categoryName ? categoryColor || 'var(--text-muted)' : null
return (
<div data-testid="tooltip"
className="flex max-w-[300px] items-center gap-3 rounded-[18px] border border-edge-faint bg-surface-card p-1.5 pr-4 font-system shadow-popover"
className="flex max-w-[300px] items-center gap-3 rounded-[18px] border border-edge-faint bg-surface-card p-1.5 pe-4 font-system shadow-popover"
style={{ position: 'fixed', left: x + 14, top: y - 10, zIndex: 9999, pointerEvents: 'none' }}>
{/* Twelve inside eighteen with six of padding: the picture's corners follow the card's. */}
{photo ? (
@@ -118,7 +118,7 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
)
const style = {
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 14px', textAlign: 'left' as const,
padding: '7px 14px', textAlign: 'start' as const,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
background: day.dayId === selectedDayId ? 'var(--bg-hover)' : 'transparent',
border: 'none',
@@ -88,7 +88,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
{hasUnknownTitle ? notification.title_key : titleText}
</p>
<div className="flex items-center gap-0.5 flex-shrink-0">
<span className="text-xs mr-1 text-content-faint">
<span className="text-xs me-1 text-content-faint">
{relativeTime(notification.created_at, locale)}
</span>
{!notification.is_read && (
@@ -64,7 +64,7 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
type="button"
onClick={() => setOpen(prev => ({ ...prev, [group]: !prev[group] }))}
aria-expanded={!!open[group]}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left text-content-secondary hover:text-content"
className="flex min-w-0 flex-1 items-center gap-1.5 text-start text-content-secondary hover:text-content"
>
{open[group]
? <ChevronDown size={13} className="flex-none" />
@@ -102,7 +102,7 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
role="checkbox"
aria-checked={on}
onClick={() => onChange(on ? selected.filter(s => s !== scope) : [...selected, scope])}
className="flex w-full items-start gap-2.5 px-3.5 py-2.5 text-left transition-colors hover:bg-surface-tertiary"
className="flex w-full items-start gap-2.5 px-3.5 py-2.5 text-start transition-colors hover:bg-surface-tertiary"
>
<span className="mt-px"><CheckBox checked={on} /></span>
<span className="min-w-0 flex-1">
@@ -72,7 +72,7 @@ export default function ApplyTemplateButton({ tripId, visibility, style, classNa
<div
className="trek-menu-enter"
style={{
position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50,
position: 'absolute', insetInlineEnd: 0, top: '100%', marginTop: 6, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220,
transformOrigin: 'top right',
@@ -89,7 +89,7 @@ export default function ApplyTemplateButton({ tripId, visibility, style, classNa
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Package size={13} className="text-content-faint" />
<div style={{ flex: 1, textAlign: 'left' }}>
<div style={{ flex: 1, textAlign: 'start' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>
{tmpl.item_count} {t('admin.packingTemplates.items')}
@@ -18,7 +18,7 @@ function MenuItem({ icon: Icon, label, onClick }: { icon: LucideIcon; label: str
type="button"
role="menuitem"
onClick={onClick}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-caption font-medium text-content hover:bg-surface-tertiary"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-start text-caption font-medium text-content hover:bg-surface-tertiary"
>
<Icon size={13} className="text-content-faint" />
{label}
@@ -76,7 +76,7 @@ export default function PackingExportMenu({ tripId, view, style, className }: Pa
{open && (
<div
role="menu"
className="trek-menu-enter absolute right-0 top-full z-50 mt-1.5 min-w-[220px] origin-top-right rounded-[10px] border border-edge bg-surface-card p-1 shadow-dropdown"
className="trek-menu-enter absolute end-0 top-full z-50 mt-1.5 min-w-[220px] origin-top-right rounded-[10px] border border-edge bg-surface-card p-1 shadow-dropdown"
>
<MenuItem icon={Printer} label={t('packing.exportPrint')} onClick={choose(packingExport.openPrint)} />
<MenuItem icon={FileText} label={t('packing.exportMarkdown')} onClick={choose(packingExport.exportMarkdown)} />

Some files were not shown because too many files have changed in this diff Show More