Compare commits

...

8 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
26 changed files with 332 additions and 161 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")"
}
@@ -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,4 +1,4 @@
import { ChevronLeft, ChevronRight, FolderSync, List, Plus, Rows3 } from 'lucide-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';
@@ -284,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>
))}
@@ -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 -4
View File
@@ -373,10 +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.
// Measured only where there is chrome to clear: the desktop atlas opens exactly as before.
const insets = chromeInsetsRef.current?.()
const pan = insets ? chromePan(insets, map.latLngToContainerPoint([NORTH_EDGE_LAT, 0]).y) : null
// 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.
+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 />);
@@ -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/);
});
});