Compare commits

..

13 Commits

Author SHA1 Message Date
Maurice 796f39fe1f fix(rtl): write the isolate marks of flowText as escapes, not as raw characters
A raw FSI or PDI in the source reads as nothing and is what Sonar's
bidirectional character rule (S6389) flags. The strings are the same.
2026-11-08 01:40:30 +00:00
Maurice ee9cbce23d fix(journey): the feed's entries are gone under the header pills, not half seen between them
Scrolled up, a day's head showed at a third of its strength between the
back button and the segment, because the feed's mask let content through
from 22px below the safe area while the pills reach 38px. It is now
transparent down to 40px and whole from 60px, where the first day's head
starts.
2026-11-08 01:11:01 +00:00
Maurice 82fcdcd0fc test(rtl): read rtl.css selectors with their white space folded
Prettier breaks the arrow rule's long selector over lines, so the literal
look-up found no rule and FE-RTL-CSS-003 failed after the file was
formatted.
2026-11-08 00:38:51 +00:00
Maurice 3b7cdf516a fix(mobile): the rail fades where its entries scroll on a short window; the watch capture says why it retries
On a wide window too short for every entry (a folded Duo on its side) the
rail's list scrolls with its scrollbar hidden, and settings and
administration at its foot were cut off without a sign. The list now fades
at the edge with more behind it, as other scrolling lists do.

watch-screens.sh names the step that failed (launch, not ready,
screenshot) and tries once more after 20 seconds before it gives up.
2026-11-08 00:33:07 +00:00
Maurice 41752887dc fix(journey): a folded Duo on its side gets the compact cards in the wide size class too
The folded Duo on its side is over 500px high and so in the wide size
class, whose large cards won over the short window's compact ones. Short
is now asked first.
2026-11-08 00:10:57 +00:00
Maurice b9d0d3397e style: format the six files the formatting check names
They are outside scripts/format-baseline.json, so new code there must be
formatted; Prettier only, no change in behaviour.
2026-11-08 00:10:57 +00:00
Maurice 2a2dfec247 fix(mobile): large screen polish for the dashboard, the trip dock, transports, the stay chip and the journey strip
- Dashboard: crossing the split size keeps every widget in the same parent
  (the columns dissolve into one ordered stack below it), so the currency
  converter is not remounted and keeps its amount and rates.
- Trip dock: More is marked with the main dock's ring while one of its
  sections (chat, notes, links, polls, files) is on show or it is open,
  instead of the filled circle of a tab.
- Transport cards: the status button is a flex box, so its dot shows when
  the card may be edited.
- Day header: the stay chip's name keeps its own direction, so a Latin name
  in an Arabic header is cut at its end.
- Journey: a folded Duo on its side (about 540px high) gets the compact
  cards, so the map keeps its room.
- Rail: a test pins settings and administration marking their own entry.
2026-11-07 23:31:54 +00:00
Maurice 21e07e20a1 fix(ci): keep index.css and useAtlas.ts within their size baselines
The opening pan now measures the north edge through a function chromePan
calls only where there is chrome to clear, so useAtlas needs no branch of
its own. index.css loses the long note on the dark field rule to one line,
and a .dark .bg-slate-100 rule that two later rules always overrode.
2026-11-07 23:31:54 +00:00
Maurice 322d5d5209 test(map): the phone hook mock keeps the number of hooks stable
The mock called the real useIsPhone only while a test left the answer open,
so a map still mounted when the answer was reset rendered with one hook more
and React threw. It now calls the real hook every time and overrides only its
answer.
2026-11-08 00:05:54 +01:00
Maurice 0346f9c13d refactor(mobile): the file preview's heading as its own part
The type tile beside the name took the preview pane past the complexity
limit; the heading (tile, name, type, wallet, size, date, uploader) is now a
part of its own and the pane reads as before.
2026-11-08 00:04:47 +01:00
Maurice dfd87d413e fix(atlas): measure the opening pan only where there is chrome to clear
The opening view asked Leaflet for the north edge's point on every atlas,
though only the touch shell's rail and header need a pan. The desktop atlas
now opens exactly as before, without the call, which its map mocks also
never had.
2026-11-08 00:02:20 +01:00
Maurice 27aab5c783 test(journey): wait for the dropped create parameter to reach the URL
The draft opens and the parameter goes in the same effect, but the URL shows
it a render later. Under CI load the check ran in between and saw
?create=entry; it now waits for the empty search like it waits for the draft.
2026-11-07 23:59:38 +01:00
Maurice a7cc1b9e57 fix(mobile): the checks the large-screen merge left red
- The trip's segmented control writes its fit through a ref, not into the
  element held in state (react-hooks/immutability).
- The amount field of a payment uses the body type tier, which follows the
  text size setting, instead of a raw size.
- The form sheet tests wait the frame the edge fade now measures on, and read
  the wide variant from the size class the shell writes.
- The native dashes spec lists its locales through node:fs, so shared needs no
  vite types for it (knip).
- Baselines lowered where the fixes removed warnings: no-misused-promises,
  no-non-null-assertion in tests, the MSheet rtl entry.
2026-11-07 23:56:33 +01:00
34 changed files with 431 additions and 209 deletions
+7 -6
View File
@@ -66,12 +66,12 @@ wait_ready() {
TOKEN=0
# shot <udid> <file> <screen> <scenario> <time or -> <language>: one launch, retried
# after 2, 5 and 10 seconds when the simulator refuses it.
# after 2, 5, 10 and 20 seconds when the simulator refuses it, saying which step failed.
shot() {
local udid=$1 file=$2 screen=$3 scenario=$4 clock=$5 lang=$6
local locale=${lang//-/_} attempt
for attempt in 0 2 5 10; do
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s"; sleep "$attempt"; }
local locale=${lang//-/_} attempt failed=
for attempt in 0 2 5 10 20; do
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s ($failed)"; sleep "$attempt"; }
TOKEN=$((TOKEN + 1))
xcrun simctl terminate "$udid" "$BUNDLE" >/dev/null 2>&1 || true
sleep 0.5
@@ -79,11 +79,12 @@ shot() {
[ "$clock" = - ] || extra=(-TrekNow "$clock")
xcrun simctl launch "$udid" "$BUNDLE" \
-AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" -TrekScenario "$scenario" \
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || continue
wait_ready "$udid" "$TOKEN" || continue
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || { failed=launch; continue; }
wait_ready "$udid" "$TOKEN" || { failed='not ready'; continue; }
# The map's tiles come over the network after the screen is up.
[ "$screen" = place ] && sleep 6
xcrun simctl io "$udid" screenshot --type=png "$file" >/dev/null 2>&1 && return 0
failed=screenshot
done
echo "gave up on $(basename "$file")"
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 375,
"@typescript-eslint/no-misused-promises": 847,
"@typescript-eslint/no-misused-promises": 845,
"no-console": 88,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 144,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1912,
"@typescript-eslint/no-non-null-assertion": 1911,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
-1
View File
@@ -79,7 +79,6 @@
"hooks/useAnchoredPosition.ts": 3,
"hooks/useElementRect.ts": 3,
"mobile/components/MSegmented.tsx": 1,
"mobile/components/MSheet.tsx": 1,
"mobile/screens/journey/MJourneyDetail.tsx": 3,
"mobile/screens/journey/MJourneyEntrySheet.tsx": 1,
"mobile/screens/trip/map/MMapArea.tsx": 3,
+9 -1
View File
@@ -211,7 +211,15 @@ vi.mock('../../services/photoService', () => ({
const shell = vi.hoisted(() => ({ phone: undefined as boolean | undefined }))
vi.mock('../../mobile/useIsPhone', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../mobile/useIsPhone')>()
return { ...actual, useIsPhone: () => shell.phone ?? actual.useIsPhone() }
// The real hook runs every time, so a test that changes the answer never changes the
// number of hooks a mounted map has (React throws on that).
return {
...actual,
useIsPhone: () => {
const real = actual.useIsPhone()
return shell.phone ?? real
},
}
})
vi.mock('./gcj02Crs', () => ({
@@ -103,7 +103,10 @@ describe('localizedParams', () => {
});
it('FE-NOTIF-MODEL-009: anything that is not a bare date passes through, and no params stay none', () => {
expect(localizedParams({ trip: '2026-11-05 at noon', n: '3' }, 'de', NOW)).toEqual({ trip: '2026-11-05 at noon', n: '3' });
expect(localizedParams({ trip: '2026-11-05 at noon', n: '3' }, 'de', NOW)).toEqual({
trip: '2026-11-05 at noon',
n: '3',
});
expect(localizedParams(undefined, 'de', NOW)).toEqual({});
});
});
+1 -5
View File
@@ -1290,7 +1290,6 @@ img[alt="TREK"] {
/* ── Dark-Mode: Tailwind-Klassen ─────────────── */
.dark .bg-slate-50 { background-color: #131316 !important; }
.dark .bg-slate-100 { background-color: #1c1c21 !important; }
.dark .bg-white { background-color: #09090b !important; }
.dark .bg-gray-50 { background-color: #131316 !important; }
.dark .bg-gray-100 { background-color: #1c1c21 !important; }
@@ -1307,10 +1306,7 @@ img[alt="TREK"] {
.dark .bg-slate-100 { background-color: #27272a !important; }
.dark .bg-slate-900 { background-color: #e4e4e7 !important; color: #09090b !important; }
.dark .hover\:bg-slate-700:hover { background-color: #d4d4d8 !important; color: #09090b !important; }
/* Not inside the touch shell (.m-root): its fields state their own fill, a
transparent input inside a rounded wrapper above all, and this rule outweighs
every utility class, so it painted a dark box inside each of them. The
:where() keeps the desktop's weight exactly as it was. */
/* Not in the touch shell (.m-root), whose fields set their own fill; :where() keeps the desktop's weight. */
.dark input:not(:where(.m-root *)), .dark textarea:not(:where(.m-root *)), .dark select:not(:where(.m-root *)) {
background-color: #1c1c21;
color: #f4f4f5;
+10 -3
View File
@@ -1,4 +1,5 @@
import { useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react';
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
import { edgeFadeClass, useEdgeFade } from './useEdgeFade';
/**
* The rail on the leading edge that takes the dock's place on a wide window
@@ -27,6 +28,9 @@ export function MRail({
}) {
const listRef = useRef<HTMLDivElement>(null);
useEvenLines(listRef);
// On a short window the entries scroll; the cut edge fades, so the foot's entries read as more below.
const [list, setList] = useState<HTMLDivElement | null>(null);
const fade = useEdgeFade(list, 'y');
return (
<nav
aria-label={label}
@@ -34,8 +38,11 @@ export function MRail({
>
{head && <div className="mb-2 flex flex-none justify-center">{head}</div>}
<div
ref={listRef}
className="flex min-h-0 w-full flex-1 flex-col items-center gap-1 overflow-y-auto px-1 [scrollbar-width:none]"
ref={(node) => {
listRef.current = node;
setList(node);
}}
className={`flex min-h-0 w-full flex-1 flex-col items-center gap-1 overflow-y-auto px-1 [scrollbar-width:none] ${edgeFadeClass(fade, 'y')}`}
>
{children}
</div>
@@ -1,5 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'
import { useIsSplit } from '../../../hooks/sizeClass'
import {
Archive, ArchiveRestore, ArrowRight, Bell, CalendarDays, CalendarPlus, Copy,
LayoutGrid, List, MapPin, Pencil, Plus, RefreshCw, Sparkles, Trash2, Users,
@@ -60,7 +59,6 @@ export default function MDashboard(): React.ReactElement {
const unread = useInAppNotificationStore(s => s.unreadCount)
const fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount)
const [menuOpen, setMenuOpen] = useState(false)
const split = useIsSplit()
const { available: assistantAvailable, open: openAssistant } = useAssistantHomeLauncher()
const [subOpen, setSubOpen] = useState(false)
// The filter chips scroll when a language's words do not fit beside the two
@@ -281,9 +279,12 @@ export default function MDashboard(): React.ReactElement {
{/* Beside each other from the split size class on, the way the desktop
dashboard holds them: the trips in the main column, the widgets in a
column of their own, each in the order the user set. */}
<div className="split:grid split:grid-cols-[minmax(0,1fr)_minmax(300px,360px)] split:items-start split:gap-6">
<div className="min-w-0">
column of their own, each in the order the user set. Narrower, both
columns dissolve (display: contents) into one stack ordered the same
way, so crossing the split size never moves a widget to another
parent and its state (the converter's amount, its rates) stays. */}
<div className="flex flex-col split:grid split:grid-cols-[minmax(0,1fr)_minmax(300px,360px)] split:items-start split:gap-6">
<div className="contents min-w-0 split:block">
{spotlight && (
<MSpotlightCard
trip={spotlight}
@@ -292,22 +293,18 @@ export default function MDashboard(): React.ReactElement {
actions={actionsFor(spotlight, 'list')}
/>
)}
{(split ? dashOrder.filter(id => id === 'trips') : dashOrder).map(id => (
<React.Fragment key={id}>{renderBlock(id)}</React.Fragment>
))}
<div style={{ order: dashOrder.indexOf('trips') }}>{renderBlock('trips')}</div>
</div>
<div className="contents min-w-0 split:sticky split:top-[calc(var(--m-safe-top,12px)+82px)] split:-mt-[14px] split:block">
{dashOrder.filter(id => id !== 'trips').map(id => (
<div key={id} style={{ order: dashOrder.indexOf(id) }}>{renderBlock(id)}</div>
))}
{widgetPlugins.length > 0 && (
<div className="mt-3 flex flex-col gap-3" style={{ order: dashOrder.length }}>
<PluginWidgets plugins={widgetPlugins} tripId={spotlight ? String(spotlight.id) : null} />
</div>
)}
</div>
{(split || widgetPlugins.length > 0) && (
<div className="min-w-0 split:sticky split:top-[calc(var(--m-safe-top,12px)+82px)] split:-mt-[14px]">
{split && dashOrder.filter(id => id !== 'trips').map(id => (
<React.Fragment key={id}>{renderBlock(id)}</React.Fragment>
))}
{widgetPlugins.length > 0 && (
<div className="mt-3 flex flex-col gap-3">
<PluginWidgets plugins={widgetPlugins} tripId={spotlight ? String(spotlight.id) : null} />
</div>
)}
</div>
)}
</div>
</div>
@@ -42,8 +42,12 @@ import MJourneyRecordingSheet from './MJourneyRecordingSheet'
* (`embedded`): the trip's back button and controls sit on top, so the screen
* drops its own back button and leaves the trip's URL alone.
*/
/** A phone on its side: too short for the regular strip of cards and the map above it. */
const SHORT_WINDOW = '(max-height: 500px)'
/**
* A phone on its side, or a folded Duo on its side (about 540px high): too short
* for the regular strip of cards and the map above it. Higher than the shell's
* 500px for that Duo, whose regular cards left the map a band under the header.
*/
const SHORT_WINDOW = '(max-height: 600px)'
function subscribeShort(onChange: () => void) {
const mq = window.matchMedia(SHORT_WINDOW)
mq.addEventListener('change', onChange)
@@ -418,11 +422,12 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
{feed && (
<div
className="absolute inset-y-0 z-[6] overflow-y-auto overflow-x-hidden border-e border-[color:var(--m-rowbr)] bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[calc(var(--m-safe-top,12px)+60px)]"
// Entries scrolled up fade out under the header's pills instead of showing through between them.
// Entries scrolled up fade out under the header's pills instead of showing through between them:
// gone down to the pills' lower edge (38px tall), whole again before the first day's head.
style={{
insetInlineStart: 'var(--m-rail-w, 0px)',
width: 'var(--m-pane-aside-w)',
maskImage: 'linear-gradient(180deg, transparent calc(var(--m-safe-top, 12px) + 22px), #000 calc(var(--m-safe-top, 12px) + 58px))',
maskImage: 'linear-gradient(180deg, transparent calc(var(--m-safe-top, 12px) + 40px), #000 calc(var(--m-safe-top, 12px) + 60px))',
}}
>
<MJourneyFeed
@@ -589,7 +594,8 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
showMood={current.show_mood !== 0}
showWeather={current.show_weather !== 0}
tone="mobile"
size={wide ? 'large' : short ? 'compact' : 'regular'}
// Short before wide: a folded Duo on its side is wide (over 500px high) and short.
size={short ? 'compact' : wide ? 'large' : 'regular'}
/>
</div>
))}
@@ -137,12 +137,19 @@ function FeedCard({ entry, color, active, onTap, showMood, showWeather, t }: Fee
) : (
<MapPin size={11} strokeWidth={2.2} className="flex-none" />
)}
<span dir="auto" className="truncate font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold">
<span
dir="auto"
className="truncate font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold"
>
{place}
</span>
</span>
)}
{story && <span dir="auto" className="line-clamp-3 text-caption leading-[1.45] text-m-ink opacity-80">{story}</span>}
{story && (
<span dir="auto" className="line-clamp-3 text-caption leading-[1.45] text-m-ink opacity-80">
{story}
</span>
)}
</span>
</button>
);
+8 -13
View File
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type ComponentType } from
import { findFocusDayId, findTodayDayId } from '../../../components/Planner/today'
import { takeResumeDay } from '../../../utils/resumeRoute'
import {
CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal, PanelLeftClose, PanelLeftOpen, Plane, Route, SlidersHorizontal, Trash2, Upload,
CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, PanelLeftClose, PanelLeftOpen, Plane, Route, SlidersHorizontal, Trash2, Upload,
} from 'lucide-react'
import { useTripPlanner } from '../../../pages/tripPlanner/useTripPlanner'
import { pickDockTabs } from './dockTabs'
@@ -29,7 +29,7 @@ import { useDayPreviewStore } from '../../dayPreviewStore'
import { useIsSplit, useIsWide } from '../../../hooks/sizeClass'
import MTripRail from './MTripRail'
import {
CollabSegment, CompactToggle, DayRailSteps, DocSyncButton, GlassSegment, HeaderActions, PrimaryPill, ROW_AFTER_BACK, ROW_CENTRED,
CollabSegment, CompactToggle, DayRailSteps, DockMoreButton, DocSyncButton, GlassSegment, HeaderActions, PrimaryPill, ROW_AFTER_BACK, ROW_CENTRED,
} from './MTripShellParts'
import { edgeFadeClass, useEdgeFade } from '../../components/useEdgeFade'
import { useShortcuts } from '../../keyboard/useShortcuts'
@@ -927,17 +927,12 @@ export default function MTripShell({
</button>
)
})}
<button
type="button"
onClick={() => openSheet('mehr')}
aria-label={t('mobileTrip.more')}
aria-expanded={sheet?.id === 'mehr'}
className={`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
sheet?.id === 'mehr' ? 'bg-m-act text-m-actfg' : 'text-m-muted'
}`}
>
<MoreHorizontal size={19} strokeWidth={2.2} />
</button>
<DockMoreButton
open={sheet?.id === 'mehr'}
standsFor={!dockTabs.some(tab => tab.id === trTab)}
onOpen={() => openSheet('mehr')}
label={t('mobileTrip.more')}
/>
</nav>}
<Sheets planner={planner} shell={shell} />
@@ -1,5 +1,5 @@
import { ChevronLeft, ChevronRight, FolderSync, List, Plus, Rows3 } from 'lucide-react';
import { useLayoutEffect, useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, FolderSync, List, MoreHorizontal, Plus, Rows3 } from 'lucide-react';
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { canManageDocSync } from '../../../components/Files/docsync/useDocSync';
import { useDocSyncOffered } from '../../../components/Files/docsync/useDocSyncOffered';
import { useIsWide } from '../../../hooks/sizeClass';
@@ -26,14 +26,21 @@ import { collabBoardFor, collabSideBySide } from './tabs/collabPanes';
export function GlassSegment({ children, activeKey }: { children: ReactNode; activeKey?: string }) {
const [region, setRegion] = useState<HTMLDivElement | null>(null);
const [track, setTrack] = useState<HTMLDivElement | null>(null);
// The element is also kept in a ref: the fit below writes to it, and state is not to be written.
const trackRef = useRef<HTMLDivElement | null>(null);
const attachTrack = useCallback((el: HTMLDivElement | null) => {
trackRef.current = el;
setTrack(el);
}, []);
const fade = useEdgeFade(track);
useLayoutEffect(() => {
if (!region || !track) return;
const el = trackRef.current;
if (!region || !el) return;
const fit = () => {
track.dataset.tight = 'false';
track.dataset.tight = track.scrollWidth > track.clientWidth + 0.5 ? 'true' : 'false';
const active = track.querySelector<HTMLElement>('[aria-pressed="true"]');
if (active && track.scrollWidth > track.clientWidth + 0.5) {
el.dataset.tight = 'false';
el.dataset.tight = el.scrollWidth > el.clientWidth + 0.5 ? 'true' : 'false';
const active = el.querySelector<HTMLElement>('[aria-pressed="true"]');
if (active && el.scrollWidth > el.clientWidth + 0.5) {
// 'nearest' on both axes: the shell root is fixed, anything else scrolls the page behind it.
active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
@@ -49,7 +56,7 @@ export function GlassSegment({ children, activeKey }: { children: ReactNode; act
className="pointer-events-none absolute inset-y-0 end-12 start-12 flex items-center justify-center"
>
<div className="pointer-events-auto flex min-w-0 max-w-full rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[3px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
<div ref={setTrack} className={`m-seg flex min-w-0 overflow-x-auto rounded-full ${edgeFadeClass(fade)}`}>
<div ref={attachTrack} className={`m-seg flex min-w-0 overflow-x-auto rounded-full ${edgeFadeClass(fade)}`}>
{children}
</div>
</div>
@@ -277,3 +284,37 @@ export function CompactToggle({ active, onToggle, label }: { active: boolean; on
</button>
);
}
/**
* The phone dock's More. It stands for its sections (chat, notes, files and the
* rest) while one is on show, and for itself while open: marked as a ring, as
* the main dock's More is, not filled like a tab it would then read as.
*/
export function DockMoreButton({
open,
standsFor,
onOpen,
label,
}: {
open: boolean;
standsFor: boolean;
onOpen: () => void;
label: string;
}) {
return (
<button
type="button"
onClick={onOpen}
aria-label={label}
aria-expanded={open}
aria-current={!open && standsFor ? 'page' : undefined}
className={`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
open || standsFor
? 'bg-[color:var(--m-ic)] text-m-ink ring-[1.5px] ring-inset ring-[color:var(--m-act)]'
: 'text-m-muted'
}`}
>
<MoreHorizontal size={19} strokeWidth={2.2} />
</button>
);
}
@@ -544,7 +544,8 @@ function TimelineHeader({ tl, dayLabel, openLabel, weatherLabel, onOpenDay, onOp
className={`${GLASS_CHIP} min-w-[4.5rem] shrink gap-[5px] px-2.5`}
>
<HotelChipIcon variant={chip.variant} />
<span className="min-w-0 truncate">{chip.name}</span>
{/* Its own direction, so a Latin name in an Arabic header is cut at its end, not its start. */}
<span dir="auto" className="min-w-0 truncate">{chip.name}</span>
{chip.time && <span className="flex-none tabular-nums text-m-muted">{chip.time.slice(0, 5)}</span>}
</button>
))}
@@ -182,7 +182,7 @@ export function AddPaymentSheet({
onChange={(e) => setAmount(e.target.value)}
placeholder={zeroAmountInput(currency)}
aria-label={t('costs.amount')}
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-[0.84375rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums] placeholder:font-normal placeholder:text-m-faint"
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-body font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums] placeholder:font-normal placeholder:text-m-faint"
/>
<span className="flex-none font-geist text-caption font-bold text-m-faint">{currency}</span>
</div>
@@ -123,7 +123,10 @@ export function FileTile({ file, selected, canStar, onPick, onStar, onMenu, t }:
)}
</span>
<span className="flex flex-1 flex-col gap-[6px] px-3 pb-[10px] pt-2">
<span dir="auto" className="line-clamp-2 break-words text-[length:calc(12.5px*var(--fs-scale-body,1))] font-bold leading-[1.3] text-m-ink">
<span
dir="auto"
className="line-clamp-2 break-words text-[length:calc(12.5px*var(--fs-scale-body,1))] font-bold leading-[1.3] text-m-ink"
>
{file.original_name}
</span>
<span className="mt-auto flex flex-wrap items-center gap-1">
@@ -166,7 +169,10 @@ export function FileTile({ file, selected, canStar, onPick, onStar, onMenu, t }:
function Badge({ children }: { children: ReactNode }) {
return (
// A size, a date or a name, each in its own direction ("46.1 KB" stays so in Arabic).
<span dir="auto" className="whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-muted">
<span
dir="auto"
className="whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-muted"
>
{children}
</span>
);
@@ -181,6 +187,43 @@ interface FilePreviewPaneProps {
onMenu: () => void;
}
/** The pane's heading: the type's tile when nothing is shown in place, the name, and what the file is. */
function FileHeading({ file, shownInPlace }: { file: TripFile; shownInPlace: boolean }) {
const { locale } = useTranslation();
const meta = getFileTypeMeta(file);
return (
<div className="flex items-start gap-3">
{!shownInPlace && (
<span className="relative block h-[92px] w-[74px] flex-none overflow-hidden rounded-xl border border-[color:var(--m-rowbr)] bg-m-sheetop">
<TypeArt file={file} />
</span>
)}
<div className={`min-w-0 flex-1 ${shownInPlace ? '' : 'pt-1'}`}>
<h3 className="break-words text-[length:calc(16px*var(--fs-scale-subtitle,1))] font-extrabold leading-[1.3] text-m-ink">
<bdi>{file.original_name}</bdi>
</h3>
<div className="mt-[6px] flex flex-wrap items-center gap-1">
<span
className="rounded-full px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-extrabold tracking-[.06em]"
style={{ background: `${meta.color}1f`, color: meta.color }}
>
{meta.label}
</span>
{isWalletPass(file.mime_type, file.original_name) && (
<span className="flex items-center gap-[3px] rounded-full bg-m-act px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-actfg">
<Wallet size={9} strokeWidth={2.4} aria-hidden="true" />
{APPLE_WALLET}
</span>
)}
{!!file.file_size && <Badge>{formatSize(file.file_size)}</Badge>}
<Badge>{formatFileDate(file.created_at, locale)}</Badge>
{file.uploaded_by_name && <Badge>{file.uploaded_by_name}</Badge>}
</div>
</div>
</div>
);
}
/**
* The file picked in the grid, shown beside it once the window has room for
* two panes, the way Files on an iPad previews a document: the picture or the
@@ -191,8 +234,6 @@ interface FilePreviewPaneProps {
*/
export function FilePreviewPane({ file, planner, onOpen, onStar, onLinks, onMenu }: FilePreviewPaneProps) {
const { t } = planner;
const { locale } = useTranslation();
const meta = getFileTypeMeta(file);
const image = isImage(file.mime_type);
const pdf = isPdf(file) && pdfInPage();
const src = useSignedUrl(file.url, image || pdf);
@@ -229,35 +270,7 @@ export function FilePreviewPane({ file, planner, onOpen, onStar, onLinks, onMenu
</div>
)}
<div className="flex items-start gap-3">
{!shownInPlace && (
<span className="relative block h-[92px] w-[74px] flex-none overflow-hidden rounded-xl border border-[color:var(--m-rowbr)] bg-m-sheetop">
<TypeArt file={file} />
</span>
)}
<div className={`min-w-0 flex-1 ${shownInPlace ? '' : 'pt-1'}`}>
<h3 className="break-words text-[length:calc(16px*var(--fs-scale-subtitle,1))] font-extrabold leading-[1.3] text-m-ink">
<bdi>{file.original_name}</bdi>
</h3>
<div className="mt-[6px] flex flex-wrap items-center gap-1">
<span
className="rounded-full px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-extrabold tracking-[.06em]"
style={{ background: `${meta.color}1f`, color: meta.color }}
>
{meta.label}
</span>
{isWalletPass(file.mime_type, file.original_name) && (
<span className="flex items-center gap-[3px] rounded-full bg-m-act px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-actfg">
<Wallet size={9} strokeWidth={2.4} aria-hidden="true" />
{APPLE_WALLET}
</span>
)}
{!!file.file_size && <Badge>{formatSize(file.file_size)}</Badge>}
<Badge>{formatFileDate(file.created_at, locale)}</Badge>
{file.uploaded_by_name && <Badge>{file.uploaded_by_name}</Badge>}
</div>
</div>
</div>
<FileHeading file={file} shownInPlace={shownInPlace} />
{(linked.places.length > 0 || linked.reservations.length > 0 || file.note_id != null) && (
<div className="flex flex-wrap gap-[6px]">
@@ -44,7 +44,10 @@ export function CardTitleButton({
onClick={onOpen}
className="flex min-w-0 flex-1 flex-wrap items-center gap-x-[7px] gap-y-1 text-start"
>
<span dir="auto" className="line-clamp-2 min-w-0 flex-[1_1_auto] break-words text-[0.78125rem] font-bold text-m-ink wide:basis-full">
<span
dir="auto"
className="line-clamp-2 min-w-0 flex-[1_1_auto] break-words text-[0.78125rem] font-bold text-m-ink wide:basis-full"
>
{res.title}
</span>
<span className="inline-flex flex-none items-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-m-card px-2 py-[2px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.06em] text-m-muted">
@@ -116,7 +119,9 @@ export function CardWhenAndCode({
<Field label={t('reservations.date')} className="flex-[1.4]">
{dayDate ? (
<span className="flex min-w-0 items-center justify-center gap-1.5">
<span dir="auto" className="min-w-0 truncate">{dayValue}</span>
<span dir="auto" className="min-w-0 truncate">
{dayValue}
</span>
<span className="flex-none font-medium text-m-faint">{dayDate}</span>
</span>
) : (
@@ -178,7 +183,9 @@ export function CardFiles({ files, card, t }: { files: ReturnType<typeof filesFo
className="flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-[7px]"
>
<FileText size={12} strokeWidth={2} className="flex-none text-m-muted" />
<span dir="auto" className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">{f.original_name}</span>
<span dir="auto" className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">
{f.original_name}
</span>
</span>
))}
</div>
@@ -144,7 +144,7 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
type="button"
onClick={toggleStatus}
aria-label={confirmed ? t('reservations.pending') : t('reservations.confirmed')}
className="-m-1 flex-none p-1"
className="-m-1 flex flex-none p-1"
>
<StatusDot color={dotColor} />
</button>
+9 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { chromeFitPadding, chromePan } from './atlasChrome';
// FE-ATLAS-CHROME-001 onwards: the atlas camera beside the touch shell's rail and header.
@@ -11,6 +11,14 @@ describe('atlas chrome insets', () => {
expect(chromePan({ top: 62, start: 0 }, 83, false)).toBeNull();
});
it('FE-ATLAS-CHROME-005: the north edge is measured only where there is chrome to clear', () => {
const measure = vi.fn(() => -77);
expect(chromePan(undefined, measure, false)).toBeNull();
expect(measure).not.toHaveBeenCalled();
expect(chromePan({ top: 62, start: 0 }, measure, false)).toEqual([0, -139]);
expect(measure).toHaveBeenCalledTimes(1);
});
it('FE-ATLAS-CHROME-002: the world moves away from the rail, in either reading direction', () => {
expect(chromePan({ top: 62, start: 100 }, 83, false)).toEqual([-50, 0]);
expect(chromePan({ top: 62, start: 100 }, 83, true)).toEqual([50, 0]);
+4 -3
View File
@@ -23,16 +23,17 @@ export const NORTH_EDGE_LAT = 75;
* inhabited top (NORTH_EDGE_LAT, at `northEdgeY` in the map's own pixels) needs
* to clear the header: a tall window shows it below the header anyway, a phone
* had the stars of the far north under it. Leaflet pans the camera, so the
* numbers point the other way from the world's move.
* numbers point the other way from the world's move. `northEdgeY` may be a
* measurement, taken only where there is chrome: the desktop atlas has none.
*/
export function chromePan(
insets: AtlasChromeInsets | undefined,
northEdgeY: number,
northEdgeY: number | (() => number),
rtl = documentRtl()
): [number, number] | null {
if (!insets) return null;
const x = insets.start ? (rtl ? 1 : -1) * (insets.start / 2) : 0;
const y = Math.min(0, northEdgeY - insets.top);
const y = Math.min(0, (typeof northEdgeY === 'function' ? northEdgeY() : northEdgeY) - insets.top);
return x || y ? [x, y] : null;
}
+2 -2
View File
@@ -373,8 +373,8 @@ export function useAtlas(options: { chromeInsets?: () => AtlasChromeInsets } = {
map.getPane('regionPane')!.style.zIndex = '401'
mapInstance.current = map
// The opening view centres the world beside the screen's chrome, not behind the rail and under the header.
const pan = chromePan(chromeInsetsRef.current?.(), map.latLngToContainerPoint([NORTH_EDGE_LAT, 0]).y)
// The opening view clears the touch shell's rail and header; the desktop atlas has none and opens as before.
const pan = chromePan(chromeInsetsRef.current?.(), () => map.latLngToContainerPoint([NORTH_EDGE_LAT, 0]).y)
if (pan) map.panBy(pan, { animate: false })
// Both renderers live as long as the map does (see the note on the refs); map.remove()
// disposes them along with every other layer it holds.
@@ -162,7 +162,8 @@ describe('useJourneyDetail', () => {
setup({ search: '?create=entry' });
await waitFor(() => expect(latest.editingEntry).not.toBeNull());
expect(latest.editingEntry).toMatchObject({ id: 0, journey_id: 7, type: 'entry', visibility: 'private' });
expect(screen.getByTestId('search')).toHaveTextContent('');
// The parameter goes in the same effect but reaches the URL a render later.
await waitFor(() => expect(screen.getByTestId('search')).toBeEmptyDOMElement());
});
it('FE-JRN-DETHOOK-004: a viewer never gets the draft editor', async () => {
+5 -4
View File
@@ -15,9 +15,9 @@
* Zero specificity, so an explicit text-center or text-end on the element wins.
*/
:where(
:root[dir='rtl'] [dir='auto']:dir(ltr):is(.truncate, [class*='line-clamp-'], input, textarea),
:root[dir='ltr'] [dir='auto']:dir(rtl):is(.truncate, [class*='line-clamp-'], input, textarea)
) {
:root[dir='rtl'] [dir='auto']:dir(ltr):is(.truncate, [class*='line-clamp-'], input, textarea),
:root[dir='ltr'] [dir='auto']:dir(rtl):is(.truncate, [class*='line-clamp-'], input, textarea)
) {
text-align: end;
}
@@ -32,7 +32,8 @@
* own property, so it composes with a rotation the icon carries (an open
* disclosure pointing down) instead of replacing it.
*/
:root[dir='rtl'] .m-root
:root[dir='rtl']
.m-root
:is(
.lucide-chevron-left,
.lucide-chevron-right,
+38 -25
View File
@@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
// FE-RTL-CSS-001 to FE-RTL-CSS-004
//
@@ -8,43 +8,56 @@ import { describe, expect, it } from 'vitest'
// text that lines up with the UI in its own direction, arrows that turn round
// in the touch shell, and Arabic's caption labels. jsdom applies no
// stylesheet, so these read the real file.
const css = readFileSync(resolve(process.cwd(), 'src/styles/rtl.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
const css = readFileSync(resolve(process.cwd(), 'src/styles/rtl.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
/** The declarations of the rule whose selector text contains every given part. */
function ruleBody(...parts: string[]): string {
for (const chunk of css.split('}')) {
const open = chunk.indexOf('{')
if (open < 0) continue
const head = chunk.slice(0, open)
if (parts.every(part => head.includes(part))) return chunk.slice(open + 1)
const open = chunk.indexOf('{');
if (open < 0) continue;
// Prettier breaks a long selector over lines; compared with its white space folded.
const head = chunk.slice(0, open).replace(/\s+/g, ' ');
if (parts.every((part) => head.includes(part))) return chunk.slice(open + 1);
}
return ''
return '';
}
describe('rtl.css', () => {
it('FE-RTL-CSS-001: the stylesheet is loaded with the app', () => {
const main = readFileSync(resolve(process.cwd(), 'src/main.tsx'), 'utf8')
expect(main).toMatch(/import '\.\/styles\/rtl\.css'/)
})
const main = readFileSync(resolve(process.cwd(), 'src/main.tsx'), 'utf8');
expect(main).toMatch(/import '\.\/styles\/rtl\.css'/);
});
it('FE-RTL-CSS-002: cut, clamped or typed user text in its own direction sits on the UI start edge', () => {
const body = ruleBody(":root[dir='rtl'] [dir='auto']:dir(ltr)", '.truncate', "[class*='line-clamp-']", 'input', 'textarea')
expect(body).toMatch(/text-align:\s*end/)
const body = ruleBody(
":root[dir='rtl'] [dir='auto']:dir(ltr)",
'.truncate',
"[class*='line-clamp-']",
'input',
'textarea'
);
expect(body).toMatch(/text-align:\s*end/);
// The left-to-right UI gets the mirror case: Arabic text in an English row.
expect(css).toContain(":root[dir='ltr'] [dir='auto']:dir(rtl)")
expect(css).toContain(":root[dir='ltr'] [dir='auto']:dir(rtl)");
// Zero specificity: an explicit alignment class on the element still wins.
expect(css).toMatch(/:where\(\s*:root\[dir='rtl'\]/)
})
expect(css).toMatch(/:where\(\s*:root\[dir='rtl'\]/);
});
it('FE-RTL-CSS-003: in the touch shell every back, forward and flow arrow turns round in a right-to-left language', () => {
const body = ruleBody(":root[dir='rtl'] .m-root", '.lucide-chevron-left', '.lucide-chevron-right', '.lucide-arrow-left', '.lucide-arrow-right')
expect(body).toMatch(/scale:\s*-1 1/)
const body = ruleBody(
":root[dir='rtl'] .m-root",
'.lucide-chevron-left',
'.lucide-chevron-right',
'.lucide-arrow-left',
'.lucide-arrow-right'
);
expect(body).toMatch(/scale:\s*-1 1/);
// An icon that names its own treatment keeps it.
expect(css).toContain(":not([class*='rtl:'])")
})
expect(css).toContain(":not([class*='rtl:'])");
});
it('FE-RTL-CSS-004: Arabic and Hebrew caption labels grow, drop the tracking and take the muted ink', () => {
expect(ruleBody(':lang(ar)', ':lang(he)', '.m-root .uppercase')).toMatch(/zoom:\s*1\.2[\s\S]*letter-spacing:\s*0/)
expect(ruleBody('.uppercase.text-m-faint')).toMatch(/color:\s*var\(--m-muted\)/)
})
})
expect(ruleBody(':lang(ar)', ':lang(he)', '.m-root .uppercase')).toMatch(/zoom:\s*1\.2[\s\S]*letter-spacing:\s*0/);
expect(ruleBody('.uppercase.text-m-faint')).toMatch(/color:\s*var\(--m-muted\)/);
});
});
+2 -2
View File
@@ -7,8 +7,8 @@ import { afterEach, describe, expect, it } from 'vitest';
import { flowText, isRtlDocument } from './flowText';
const FSI = '⁨';
const PDI = '⁩';
const FSI = '\u2068';
const PDI = '\u2069';
describe('flowText', () => {
afterEach(() => {
+2 -2
View File
@@ -9,8 +9,8 @@
* script the names are in, and the arrow points left, from the first name to
* the second. A left-to-right UI gets the plain string.
*/
const FSI = '⁨';
const PDI = '⁩';
const FSI = '\u2068';
const PDI = '\u2069';
export function isRtlDocument(): boolean {
return typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
+32 -1
View File
@@ -9,7 +9,7 @@ import { useAddonStore } from '../../../src/store/addonStore';
import { useAuthStore } from '../../../src/store/authStore';
import { usePluginStore } from '../../../src/store/pluginStore';
import { buildUser } from '../../helpers/factories';
import { fireEvent, render, screen, within } from '../../helpers/render';
import { act, fireEvent, render, screen, within } from '../../helpers/render';
import { setSizeClass } from '../../helpers/sizeClass';
// FE-MOB-RAIL-001 onwards
@@ -71,6 +71,37 @@ describe('MNavRail', () => {
expect(within(rail()).getByRole('button', { name: 'Admin' })).toBeInTheDocument();
});
it('FE-MOB-RAIL-012: settings and administration, and their sub pages, mark their own entry in the rail', () => {
useAuthStore.setState({ user: buildUser({ role: 'admin' }) });
const { unmount } = render(<MNavRail />, { initialEntries: ['/settings'] });
expect(within(rail()).getByRole('button', { name: 'Settings' })).toHaveAttribute('aria-current', 'page');
expect(within(rail()).getByRole('button', { name: 'Admin' })).not.toHaveAttribute('aria-current');
unmount();
render(<MNavRail />, { initialEntries: ['/admin/users'] });
expect(within(rail()).getByRole('button', { name: 'Admin' })).toHaveAttribute('aria-current', 'page');
expect(within(rail()).getByRole('button', { name: 'Settings' })).not.toHaveAttribute('aria-current');
expect(within(rail()).getByRole('button', { name: 'My Trips' })).not.toHaveAttribute('aria-current');
});
it('FE-MOB-RAIL-013: on a window too short for every entry the list fades at the edge that has more behind it', () => {
useAuthStore.setState({ user: buildUser({ role: 'admin' }) });
render(<MNavRail />, { initialEntries: ['/dashboard'] });
const list = rail().querySelector('.overflow-y-auto') as HTMLElement;
expect(list.className).not.toMatch(/m-fade-/);
Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 800 });
Object.defineProperty(list, 'clientHeight', { configurable: true, value: 400 });
act(() => {
list.dispatchEvent(new Event('scroll'));
});
expect(list).toHaveClass('m-fade-b');
list.scrollTop = 400;
act(() => {
list.dispatchEvent(new Event('scroll'));
});
expect(list).toHaveClass('m-fade-t');
});
it("FE-MOB-RAIL-004: the create button keeps the dock's meaning and its exceptions", () => {
const { unmount } = render(
<>
@@ -8,6 +8,7 @@ import { useInAppNotificationStore } from '../../../../src/store/inAppNotificati
import { usePluginStore } from '../../../../src/store/pluginStore';
import type { TripCardBadge } from '../../../../src/api/client';
import type { DashboardTrip } from '../../../../src/pages/dashboard/dashboardModel';
import { setSizeClass } from '../../../helpers/sizeClass';
// FE-MOB-DASH-001 onwards
@@ -416,6 +417,21 @@ describe('MDashboard', () => {
expect(screen.getByText('Upcoming reservations')).toBeInTheDocument();
});
it('FE-MOB-DASH-041: crossing the split size keeps the widgets mounted, so the converter keeps its amount', () => {
mocks.dash = buildDash();
render(<MDashboard />);
const amount = screen.getAllByRole('textbox', { name: 'From' })[0] as HTMLInputElement;
fireEvent.change(amount, { target: { value: '250' } });
setSizeClass('split');
expect(screen.getAllByRole('textbox', { name: 'From' })[0]).toBe(amount);
expect(amount.value).toBe('250');
setSizeClass('compact');
expect(screen.getAllByRole('textbox', { name: 'From' })[0]).toBe(amount);
expect(amount.value).toBe('250');
});
it('FE-MOB-DASH-027: a malformed date renders a dash instead of crashing', () => {
mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 14, start_date: 'not-a-date', end_date: iso(3) })] });
render(<MDashboard />);
+9 -2
View File
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { useRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { sizeClassOf } from '../../../src/hooks/sizeClass';
import { useAutoGrow } from '../../../src/hooks/useAutoGrow';
import MSheet from '../../../src/mobile/components/MSheet';
import { edgeFadeClass, useEdgeFade } from '../../../src/mobile/components/useEdgeFade';
@@ -115,7 +116,8 @@ describe('useEdgeFade on the vertical axis', () => {
Object.defineProperty(el, 'scrollHeight', { configurable: true, value: 800 });
await act(async () => {
el.appendChild(document.createElement('div'));
await Promise.resolve();
// Measured on the next frame, so a burst of changes is measured once.
await new Promise((resolve) => setTimeout(resolve, 40));
});
expect(result.current.end).toBe(true);
});
@@ -162,7 +164,12 @@ describe('form sheet stylesheet', () => {
it('FE-MOB-FORMSHEET-010: the short-window variant is the other side of the wide size class height bound', () => {
expect(tailwind).toContain("addVariant('short', '@media (max-height: 500px)')");
expect(tailwind).toContain("addVariant('wide', '@media (min-width: 600px) and (min-height: 501px)')");
// `wide:` follows the size class the shell writes (data-size), whose height bound starts at 501.
expect(tailwind).toContain(
`addVariant('wide', ':where(html:has(.m-shell[data-size="wide"], .m-shell[data-size="split"])) &')`
);
expect(sizeClassOf(1024, 500)).toBe('compact');
expect(sizeClassOf(1024, 501)).not.toBe('compact');
});
it('FE-MOB-FORMSHEET-011: on a short window the actions sit beside the title and the fields take the rest', () => {
@@ -1,7 +1,7 @@
// FE-MOB-JDET-001 to FE-MOB-JDET-048
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '../../../helpers/render';
// FE-MOB-JDET-001 to FE-MOB-JDET-050
import { describe, it, expect, beforeEach, afterEach, onTestFinished, vi } from 'vitest';
import { setSizeClass } from '../../../helpers/sizeClass';
import { render, screen, fireEvent, waitFor, act } from '../../../helpers/render';
import MJourneyDetail from '../../../../src/mobile/screens/journey/MJourneyDetail';
import { addonsApi, journeyApi, memoriesApi } from '../../../../src/api/client';
import { useJourneyStore } from '../../../../src/store/journeyStore';
@@ -246,6 +246,24 @@ afterEach(() => {
// ── Tests ────────────────────────────────────────────────────────────────────
describe('MJourneyDetail', () => {
it('FE-MOB-JDET-049: a folded Duo on its side (540px high) gets the compact cards, so the map keeps its room', () => {
const real = window.matchMedia;
// Height queries answered for a window 540px high; the rest as the setup answers them.
const tall = (query: string): MediaQueryList => {
const max = /\(\s*max-height:\s*(\d+)px\s*\)/.exec(query);
const list = real(query);
return max ? ({ ...list, matches: 540 <= Number(max[1]), media: query } as MediaQueryList) : list;
};
window.matchMedia = tall;
onTestFinished(() => { window.matchMedia = real; });
// At 720 by 540 it is the wide size class too, whose large cards must not win.
setSizeClass('wide');
setup();
expect(document.querySelector('.h-\\[112px\\]')).toBeInTheDocument();
expect(document.querySelector('.h-\\[152px\\]')).not.toBeInTheDocument();
expect(document.querySelector('.h-\\[196px\\]')).not.toBeInTheDocument();
});
it('FE-MOB-JDET-001: shows a spinner while the journey is loading', () => {
setup({ loading: true, current: null });
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
@@ -798,6 +816,15 @@ describe('MJourneyDetail', () => {
expect(screen.getByRole('button', { name: /Kyoto/ })).toHaveAttribute('aria-current', 'true');
});
it('FE-MOB-JDET-050: the feed hides what scrolls under the header pills down to their lower edge', () => {
setSizeClass('split');
setup();
const feed = screen.getByRole('button', { name: /Kyoto/ }).closest('.overflow-y-auto') as HTMLElement;
// Transparent through the 38px pills below the safe area, whole before the first day's head at +60px.
expect(feed.style.maskImage).toMatch(/transparent calc\(var\(--m-safe-top, 12px\) \+ 40px\)/);
expect(feed.style.maskImage).toMatch(/#000 calc\(var\(--m-safe-top, 12px\) \+ 60px\)/);
});
it('FE-MOB-JDET-048: the gallery takes the page, feed or not', () => {
setSizeClass('split');
setup({ view: 'gallery' });
@@ -221,6 +221,8 @@ describe('MPlanTimeline', () => {
// The name gives way with an ellipsis; the time after it stays, with no dot between.
const sacher = screen.getByText('Hotel Sacher')
expect(sacher).toHaveClass('truncate', 'min-w-0')
// In an Arabic header a Latin name keeps its own direction, so the ellipsis lands at its end.
expect(sacher).toHaveAttribute('dir', 'auto')
expect(sacher.closest('button')).toHaveTextContent('Hotel Sacher11:00')
expect(sacher.closest('button')).toHaveClass('shrink')
expect(screen.getByText('Ryokan Kyoto').closest('button')).toHaveTextContent('Ryokan Kyoto15:00')
@@ -243,6 +243,14 @@ describe('MTransportsTab', () => {
await waitFor(() => expect(p.tripActions.toggleReservationStatus).toHaveBeenCalledWith(7, 101))
})
it('FE-MOB-TRTAB-029: the dot inside the status button is laid out as a box, so it shows', () => {
renderTab(planner())
const toggle = within(cardOf('HND to ITM')).getByRole('button', { name: 'reservations.pending' })
// The dot is a bare span sized by width and height: only a flex (or block) parent gives it its box.
expect(toggle).toHaveClass('flex')
expect(toggle.querySelector('span')).toHaveClass('h-[7px]', 'w-[7px]')
})
it('FE-MOB-TRTAB-017: toasts when the status toggle fails', async () => {
const p = planner()
vi.mocked(p.tripActions.toggleReservationStatus).mockRejectedValueOnce(new Error('offline'))
@@ -421,10 +421,20 @@ describe('MTripShell', () => {
expect(more).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(more)
expect(more).toHaveAttribute('aria-expanded', 'true')
expect(more).toHaveClass('bg-m-act')
// A ring, as the main dock marks its More, not the fill of a tab.
expect(more).toHaveClass('ring-[1.5px]')
expect(more).not.toHaveClass('bg-m-act')
expect(plan).not.toHaveAttribute('aria-current')
act(() => { shellApi.closeSheet() })
expect(plan).toHaveAttribute('aria-current', 'page')
expect(more).not.toHaveClass('ring-[1.5px]')
})
it('FE-MOB-SHELL-098: a section reached through More marks More with the ring while it is on show', () => {
renderShell({ activeTab: 'dateien' } as Partial<TripPlanner>)
const more = screen.getByRole('button', { name: 'mobileTrip.more' })
expect(more).toHaveAttribute('aria-current', 'page')
expect(more).toHaveClass('ring-[1.5px]')
expect(more).not.toHaveClass('bg-m-act')
})
+56 -54
View File
@@ -1,8 +1,8 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { directionOf, pickLocale, translator } from '../../../native-shell/i18n'
import { directionOf, pickLocale, translator } from '../../../native-shell/i18n';
// FE-NATIVE-SHELL-001 to FE-NATIVE-SHELL-006
//
@@ -12,83 +12,85 @@ import { directionOf, pickLocale, translator } from '../../../native-shell/i18n'
/** Every rule of a stylesheet as its selector list, its declarations and the media query around it, comments dropped. */
function rules(css: string): { selectors: string[]; body: string; media: string }[] {
const out: { selectors: string[]; body: string; media: string }[] = []
const stack: string[] = []
let buf = ''
const out: { selectors: string[]; body: string; media: string }[] = [];
const stack: string[] = [];
let buf = '';
for (const ch of css.replace(/\/\*[\s\S]*?\*\//g, '')) {
if (ch === '{') {
stack.push(buf.trim())
buf = ''
stack.push(buf.trim());
buf = '';
} else if (ch === '}') {
const head = stack.pop() ?? ''
const head = stack.pop() ?? '';
if (!head.startsWith('@')) {
const media = stack.filter(h => h.startsWith('@media')).join(' ')
out.push({ selectors: head.split(',').map(sel => sel.trim()), body: buf, media })
const media = stack.filter((h) => h.startsWith('@media')).join(' ');
out.push({ selectors: head.split(',').map((sel) => sel.trim()), body: buf, media });
}
buf = ''
buf = '';
} else {
buf += ch
buf += ch;
}
}
return out
return out;
}
const css = readFileSync(resolve(process.cwd(), 'native-shell/shell.css'), 'utf8')
const all = rules(css)
const css = readFileSync(resolve(process.cwd(), 'native-shell/shell.css'), 'utf8');
const all = rules(css);
const find = (selector: string, media?: RegExp) =>
all.filter(r => r.selectors.includes(selector) && (!media || media.test(r.media)))
all.filter((r) => r.selectors.includes(selector) && (!media || media.test(r.media)));
describe('native shell', () => {
it('FE-NATIVE-SHELL-001: Arabic and Hebrew start right to left, every other language left to right', () => {
expect(pickLocale(['ar-SA'])).toBe('ar')
expect(pickLocale(['he-IL'])).toBe('he')
expect(directionOf('ar')).toBe('rtl')
expect(directionOf('he')).toBe('rtl')
expect(directionOf('ru')).toBe('ltr')
expect(directionOf('de')).toBe('ltr')
})
expect(pickLocale(['ar-SA'])).toBe('ar');
expect(pickLocale(['he-IL'])).toBe('he');
expect(directionOf('ar')).toBe('rtl');
expect(directionOf('he')).toBe('rtl');
expect(directionOf('ru')).toBe('ltr');
expect(directionOf('de')).toBe('ltr');
});
it('FE-NATIVE-SHELL-002: the first screens speak without dashes as punctuation', () => {
for (const code of ['en', 'de']) {
const t = translator(code)
const t = translator(code);
for (const key of ['native.welcome.sub', 'native.cloud.sub', 'native.cloud.f3']) {
expect(t(key)).not.toMatch(/[\u2013\u2014]| - /)
expect(t(key)).not.toMatch(/[\u2013\u2014]| - /);
}
}
})
});
it('FE-NATIVE-SHELL-003: Russian keeps TREK\'s faces through Cyrillic stand-ins under the same family names', () => {
const faces = css.match(/@font-face\s*\{[^}]*\}/g) ?? []
const cyrillic = faces.filter(f => /unicode-range:\s*U\+0301, U\+0400-045F/.test(f))
expect(cyrillic.filter(f => /font-family:\s*'Poppins'/.test(f))).toHaveLength(3)
expect(cyrillic.filter(f => /font-family:\s*'MuseoModerno'/.test(f))).toHaveLength(1)
it("FE-NATIVE-SHELL-003: Russian keeps TREK's faces through Cyrillic stand-ins under the same family names", () => {
const faces = css.match(/@font-face\s*\{[^}]*\}/g) ?? [];
const cyrillic = faces.filter((f) => /unicode-range:\s*U\+0301, U\+0400-045F/.test(f));
expect(cyrillic.filter((f) => /font-family:\s*'Poppins'/.test(f))).toHaveLength(3);
expect(cyrillic.filter((f) => /font-family:\s*'MuseoModerno'/.test(f))).toHaveLength(1);
for (const face of cyrillic) {
const file = /url\('\.\/fonts\/([^']+)'\)/.exec(face)?.[1]
expect(file).toBeTruthy()
expect(() => readFileSync(resolve(process.cwd(), 'native-shell/fonts', file as string))).not.toThrow()
const file = /url\('\.\/fonts\/([^']+)'\)/.exec(face)?.[1];
expect(file).toBeTruthy();
expect(() => readFileSync(resolve(process.cwd(), 'native-shell/fonts', file as string))).not.toThrow();
}
})
});
it('FE-NATIVE-SHELL-004: a short screen on its side puts the two halves of a screen side by side', () => {
const compact = /orientation: landscape\) and \(max-height: 500px\)/
expect(find('.screen:has(> .col)', compact)[0]?.body).toMatch(/grid-template-columns:\s*minmax\(0, 1fr\) minmax\(0, 1fr\)/)
expect(find('.col-about', compact)[0]?.body).toMatch(/position:\s*sticky/)
const compact = /orientation: landscape\) and \(max-height: 500px\)/;
expect(find('.screen:has(> .col)', compact)[0]?.body).toMatch(
/grid-template-columns:\s*minmax\(0, 1fr\) minmax\(0, 1fr\)/
);
expect(find('.col-about', compact)[0]?.body).toMatch(/position:\s*sticky/);
// Everywhere else the halves are no boxes of their own: one column, as before.
expect(find('.col')[0]?.body).toMatch(/display:\s*contents/)
})
expect(find('.col')[0]?.body).toMatch(/display:\s*contents/);
});
it('FE-NATIVE-SHELL-005: a screen taller than the window scrolls instead of overflowing both edges', () => {
expect(find('.screen')[0]?.body).toMatch(/margin-block:\s*auto/)
const shell = find('.shell')[0]?.body ?? ''
expect(shell).toMatch(/overflow-x:\s*clip/)
expect(shell).not.toMatch(/justify-content:\s*center/)
})
expect(find('.screen')[0]?.body).toMatch(/margin-block:\s*auto/);
const shell = find('.shell')[0]?.body ?? '';
expect(shell).toMatch(/overflow-x:\s*clip/);
expect(shell).not.toMatch(/justify-content:\s*center/);
});
it('FE-NATIVE-SHELL-006: in Arabic the chevrons sit at the far edge and point along the reading direction, the price stands apart', () => {
expect(find('.btn .chev')[0]?.body).toMatch(/margin-inline-start:\s*auto/)
expect(find(":root[dir='rtl'] .chev")[0]?.body).toMatch(/transform:\s*scaleX\(-1\)/)
expect(find('.plan-price small')[0]?.body).toMatch(/margin-inline-start:\s*7px/)
expect(find('.btn-row')[0]?.body).toMatch(/text-align:\s*start/)
expect(css).not.toMatch(/margin-left|margin-right|padding-left|padding-right|text-align:\s*left/)
})
})
expect(find('.btn .chev')[0]?.body).toMatch(/margin-inline-start:\s*auto/);
expect(find(":root[dir='rtl'] .chev")[0]?.body).toMatch(/transform:\s*scaleX\(-1\)/);
expect(find('.plan-price small')[0]?.body).toMatch(/margin-inline-start:\s*7px/);
expect(find('.btn-row')[0]?.body).toMatch(/text-align:\s*start/);
expect(css).not.toMatch(/margin-left|margin-right|padding-left|padding-right|text-align:\s*left/);
});
});
+16 -2
View File
@@ -1,6 +1,9 @@
/// <reference types="vite/client" />
/// <reference types="node" />
import type { TranslationStrings } from './types';
import { existsSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
/**
@@ -11,7 +14,18 @@ import { describe, expect, it } from 'vitest';
* reads, and the style is held here because a re-translation can bring a dash
* back without any other check noticing.
*/
const nativeModules = import.meta.glob<{ default: TranslationStrings }>('./*/native.ts', { eager: true });
const here = dirname(fileURLToPath(import.meta.url));
const locales = readdirSync(here, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && existsSync(join(here, entry.name, 'native.ts')))
.map((entry) => entry.name);
const nativeModules: Record<string, { default: TranslationStrings }> = Object.fromEntries(
await Promise.all(
locales.map(async (locale) => [
`./${locale}/native.ts`,
(await import(`./${locale}/native.ts`)) as { default: TranslationStrings },
]),
),
);
const DASH = /[‒–—―]| - /;