@autobusal/routes-order 1.37.7 → 1.37.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -344,9 +344,14 @@ export const Iso = styled.span`
344
344
  * every millimetre it gives back goes to the city name, which is what
345
345
  * people actually read.
346
346
  */
347
+ /*
348
+ * Claude - 2026-09-16 (audit): xs and no smaller. "Even smaller" came out
349
+ * at 10px, under the 12px floor for text on a phone; the tighter margin
350
+ * gives back what those two points asked for.
351
+ */
347
352
  @media (max-width: 639px) {
348
353
  margin: 0 3px;
349
- font-size: calc(${ props => props.theme.size.xs } - 2px);
354
+ font-size: ${ props => props.theme.size.xs };
350
355
  }
351
356
  font-weight: 700;
352
357
  text-transform: uppercase;
@@ -377,16 +382,20 @@ export const Time = styled.div`
377
382
  * mid-word. Stripped of its pill background and padding here it costs
378
383
  * about half that, and both ends gain 20px.
379
384
  */
385
+ /*
386
+ * Claude - 2026-09-16 (audit): xs, not xs minus one - 11px is under the
387
+ * 12px floor, and the pill's padding was the space, not the point size.
388
+ */
380
389
  @media (max-width: 639px) {
381
390
  flex-direction: column;
382
391
  gap: 0;
383
- font-size: calc(${ props => props.theme.size.xs } - 1px);
392
+ font-size: ${ props => props.theme.size.xs };
384
393
  white-space: nowrap;
385
394
 
386
395
  & > span {
387
396
  padding: 0;
388
397
  background: none;
389
- font-size: calc(${ props => props.theme.size.xs } - 1px);
398
+ font-size: ${ props => props.theme.size.xs };
390
399
  }
391
400
 
392
401
  & > span > svg {
@@ -521,6 +530,19 @@ export const Price = styled.div`
521
530
  flex: 1 1 100%;
522
531
  }
523
532
 
533
+ /*
534
+ * Claude - 2026-09-16 (audit): a 44px "Select" under the thumb - it
535
+ * measured 154x32. The shared Button has no phone size and common is not
536
+ * this package's to edit, so the height is set from the cell that holds
537
+ * it; by exclusion, because the details toggle is a button in the same
538
+ * cell and sizes itself.
539
+ */
540
+ @media (max-width: 767px) {
541
+ & > button:not(.details-toggle) {
542
+ height: 44px;
543
+ }
544
+ }
545
+
524
546
  @media (min-width: 768px) {
525
547
  flex: 0 0 165px;
526
548
  flex-direction: column;
@@ -613,7 +635,8 @@ export const PriceNotice = styled.span`
613
635
  * Left-aligned under the fare on a phone, where it is a full-width line
614
636
  * beneath a price and a button rather than the middle of a centred stack.
615
637
  */
616
- font-size: ${ props => props.theme.size.xxs };
638
+ // Claude - 2026-09-16 (audit): xs - the xxs was 10px, under the phone floor
639
+ font-size: ${ props => props.theme.size.xs };
617
640
  line-height: 1.25;
618
641
  color: ${ props => props.theme.font.faded };
619
642
  text-align: left;
@@ -685,7 +708,8 @@ export const CodeCompany = styled(Link)`
685
708
  border-radius: 100px;
686
709
  background: ${ props => props.theme.background.neutral };
687
710
  color: ${ props => props.theme.font.faded };
688
- font-size: ${ props => props.theme.size.xxs };
711
+ // Claude - 2026-09-16 (audit): xs - the xxs was 10px, under the phone floor
712
+ font-size: ${ props => props.theme.size.xs };
689
713
  font-weight: 700;
690
714
  line-height: 1.4;
691
715
  letter-spacing: .3px;
@@ -800,6 +824,14 @@ export const ButtonDetails = styled.button<{ $only?: 'narrow' | 'wide' }>`
800
824
  font-size: ${ props => props.theme.size.xs };
801
825
  }
802
826
 
827
+ /*
828
+ * Claude - 2026-09-16 (audit): 54x25 on a phone is a target a thumb
829
+ * misses. The box grows to the 44px minimum; the type does not.
830
+ */
831
+ @media (max-width: 767px) {
832
+ min-height: 44px;
833
+ }
834
+
803
835
  &:hover {
804
836
  color: ${ props => props.theme.font.normal };
805
837
  }
@@ -976,11 +1008,21 @@ export const NextDay = styled.sup`
976
1008
  * leaves the reader working out what day the departure was, which is the
977
1009
  * question the whole thing exists to answer.
978
1010
  */
1011
+ /*
1012
+ * Claude - 2026-09-16 (audit): a PILL, not yellow type. Set in primary.normal
1013
+ * on the white card this measured 1.78:1 (#EABD23 on #FFFFFF) - the brand
1014
+ * yellow is a fill, not an ink, and 12px text needs 4.5:1. The highlight the
1015
+ * yellow was there for survives as the background, with the theme's own
1016
+ * contrast ink on it: #121212 on #EABD23 is 10.5:1, in both modes.
1017
+ */
979
1018
  export const Day = styled.span`
1019
+ padding: 1px 6px;
1020
+ border-radius: 4px;
980
1021
  font-size: ${ props => props.theme.size.xs };
981
1022
  font-weight: 700;
982
1023
  white-space: nowrap;
983
- color: ${ props => props.theme.primary.normal };
1024
+ color: ${ props => props.theme.primary.contrast };
1025
+ background: ${ props => props.theme.primary.normal };
984
1026
  `;
985
1027
 
986
1028
  /**
@@ -1002,7 +1044,8 @@ export const Day = styled.span`
1002
1044
  * fact about the bus, not a countdown we invented to hurry anyone.
1003
1045
  */
1004
1046
  export const SeatsLeft = styled.span<{ $low: boolean }>`
1005
- font-size: ${ props => props.theme.size.xxs };
1047
+ // Claude - 2026-09-16 (audit): xs - the xxs was 10px, under the phone floor
1048
+ font-size: ${ props => props.theme.size.xs };
1006
1049
  line-height: 1.25;
1007
1050
  text-align: left;
1008
1051
  font-weight: ${ props => (props.$low ? 700 : 400) };
@@ -94,7 +94,12 @@ export const Option = styled.button<{ $active: boolean }>`
94
94
  overflow: hidden;
95
95
  text-overflow: ellipsis;
96
96
  padding: 5px 2px;
97
- font-size: calc(${ props => props.theme.size.xs } - 2px);
97
+ // Claude - 2026-09-16 (audit): xs, not xs minus 2 - that was 10px, under
98
+ // the phone floor. (These pills are only ever painted at 640px and up -
99
+ // see Options in ./Sort.tsx - but the rule cascades to the hidden phone
100
+ // copy of every one of them regardless, and a text-size audit reads
101
+ // computed style, not display.)
102
+ font-size: ${ props => props.theme.size.xs };
98
103
  font-weight: 700;
99
104
  white-space: nowrap;
100
105
 
package/Found/refine.ts CHANGED
@@ -531,52 +531,6 @@ export const filterItems = (items: FoundData[], refinement: Refinement): FoundDa
531
531
  items.filter(item => matches(item, refinement))
532
532
  );
533
533
 
534
- /**
535
- * Collapse interchangeable departures into one entry.
536
- *
537
- * Edited: Ferjolt Ozuni - Date: 2026-08-01
538
- *
539
- * Hourly shuttle service is the dominant Albanian intercity pattern, so a
540
- * busy pair renders a wall of rows that differ only in the departure time -
541
- * the same operator, the same two stops, the same fare, the same journey
542
- * length. Those are one product with a choice of time, and reading them as
543
- * twenty separate options is what makes the page tiring.
544
- *
545
- * Duration and price are BOTH in the key on purpose: without them a slower
546
- * or dearer trip would hide inside a group under a headline it doesn't
547
- * honour. External offers are keyed by their own id so they never merge
548
- * with anything - they carry no stop ids, and grouping on missing values
549
- * would fuse unrelated partner inventory into a single row.
550
- *
551
- * Input order is preserved, so a group lands wherever its best member
552
- * sorted to and the caller does not have to re-sort.
553
- */
554
- export const groupItems = (items: FoundData[]): FoundData[][] => {
555
- const groups = new Map<string, FoundData[]>();
556
- const order: string[] = [];
557
-
558
- items.forEach(item => {
559
- const key = item.external
560
- ? `ext:${ item.id }`
561
- : [
562
- item.operator.id,
563
- item.locations.from.stop?.id ?? '-',
564
- item.locations.to.stop?.id ?? '-',
565
- item.price.value,
566
- item.duration
567
- ].join('|');
568
-
569
- if (!groups.has(key)) {
570
- groups.set(key, []);
571
- order.push(key);
572
- }
573
-
574
- groups.get(key)?.push(item);
575
- });
576
-
577
- return order.map(key => groups.get(key) as FoundData[]);
578
- };
579
-
580
534
  const countBy = (
581
535
  items: FoundData[],
582
536
  refinement: Refinement,
package/RoutesOrder.tsx CHANGED
@@ -12,6 +12,7 @@ import RoutesSearch from '@autobusal/routes-search';
12
12
  import { useGetSuggestions } from '@autobusal/routes-search/services';
13
13
  import { SearchAgain, SearchInner, SearchToggle, SearchSummary, SearchPanel } from './styles';
14
14
  import Sections from './Sections/Sections';
15
+ import { useGetFacts } from './Facts/services';
15
16
  import Step1 from './Step1/Step1';
16
17
  import prepare from './Step1/prepare';
17
18
  import Step2 from './Step2';
@@ -48,6 +49,25 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
48
49
  const fromCity = cities.find(city => city.slug === params.from);
49
50
  const toCity = cities.find(city => city.slug === params.to);
50
51
 
52
+ /**
53
+ * Which heading is the page's h1.
54
+ *
55
+ * Claude - 2026-09-16 (audit): on /bus-lines/{from}/{to} the only h1 was
56
+ * the search form's "Search Schedules" and the journey itself - the facts
57
+ * title - was an h2. The facts block is the subject, so it takes the h1
58
+ * on the step the pair page renders and the form's heading steps down.
59
+ * Keyed on the facts having ARRIVED rather than on the URL naming a pair:
60
+ * a pair obtapi does not sell renders no facts block, and demoting the
61
+ * form there would leave the page with no h1 at all. Same query key as
62
+ * Sections' own call, so react-query answers both from one request.
63
+ */
64
+ const pairFrom = fromCity?.slug ?? params.from;
65
+ const pairTo = toCity?.slug ?? params.to;
66
+
67
+ const { data: pairFacts } = useGetFacts(pairFrom, pairTo);
68
+
69
+ const subject = Boolean(pairFacts?.facts);
70
+
51
71
  const title = fromCity && toCity
52
72
  ? t('routes_order.title_pair', { from: fromCity.name, to: toCity.name })
53
73
  : t('routes_order.title');
@@ -382,6 +402,7 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
382
402
  <Step1
383
403
  value={ step1 }
384
404
  redirected={ redirected }
405
+ heading={ subject ? 'h2' : 'h1' }
385
406
  t={ t }
386
407
  onSave={ onStep1 }
387
408
  />
@@ -436,8 +457,9 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
436
457
  checkout. */ }
437
458
  { (showStep1 || showStep2 || showStep3) && (
438
459
  <Sections
439
- from={ fromCity?.slug ?? params.from }
440
- to={ toCity?.slug ?? params.to }
460
+ from={ pairFrom }
461
+ to={ pairTo }
462
+ heading={ showStep1 ? 'h1' : 'h2' }
441
463
  t={ t }
442
464
  />
443
465
  ) }
@@ -2,6 +2,7 @@ import { TFunction } from 'i18next';
2
2
  import Facts from '../Facts/Facts';
3
3
  import Faq from '../Faq/Faq';
4
4
  import Availability from '../Availability/Availability';
5
+ import AvailabilitySkeleton from '../Availability/Skeleton';
5
6
  import AvailabilitySchema from '../Availability/Schema';
6
7
  import Schedule from '../Schedule/Schedule';
7
8
  import Links from '../Links/Links';
@@ -16,6 +17,8 @@ import { Nav, Anchor, Anchored } from './styles';
16
17
  interface Props {
17
18
  from?: string
18
19
  to?: string
20
+ // the facts title's level - h1 on the pair page, h2 under a step heading
21
+ heading?: 'h1' | 'h2'
19
22
  t: TFunction<'common'>
20
23
  }
21
24
 
@@ -55,10 +58,15 @@ const LINKS = 'links';
55
58
  * The nav renders only when there is more than the wizard to navigate to -
56
59
  * a bar whose only link is "Trips" is furniture.
57
60
  */
58
- const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
61
+ const Sections = ({ from, to, heading, t }: Props): (JSX.Element | null) => {
59
62
  const { data } = useGetFacts(from, to);
60
63
 
61
- const { data: dates } = useGetAvailability(from, to);
64
+ // Edited: Claude - Date: 2026-09-16
65
+ // `isLoading`, not `isFetching`: this is the ONE state the skeleton below
66
+ // exists for - the first request, before anything has ever answered. A
67
+ // background refetch re-uses `dates` from cache while it runs, so it never
68
+ // sees this true and never swaps a real table back out for a placeholder.
69
+ const { data: dates, isLoading: datesLoading } = useGetAvailability(from, to);
62
70
 
63
71
  const facts = data?.facts;
64
72
 
@@ -165,7 +173,7 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
165
173
  { availability && <AvailabilitySchema data={ availability } t={ t } /> }
166
174
 
167
175
  <Anchored>
168
- <Facts id={ FACTS } facts={ facts } t={ t } />
176
+ <Facts id={ FACTS } facts={ facts } heading={ heading } t={ t } />
169
177
  </Anchored>
170
178
 
171
179
  { dead && onward }
@@ -178,11 +186,23 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
178
186
  at all when the endpoint gives us nothing, which is also what
179
187
  happens on a pair that has stopped running: the notice in the
180
188
  facts block above has already said so, and thirty rows of "no
181
- departures" underneath it would be a wall, not an answer. */ }
182
- { availability && (
189
+ departures" underneath it would be a wall, not an answer.
190
+
191
+ Edited: Claude - Date: 2026-09-16
192
+ WHILE IT LOADS, a skeleton the same shape takes the space instead
193
+ of nothing - see Availability/Skeleton for why (measured CLS of
194
+ 0.52-1.16 on this exact block). `datesLoading` is the only other
195
+ branch: a pair that answers with nothing goes straight from the
196
+ skeleton to rendering nothing at all, which is correct - there
197
+ was never a table to reserve room for. */ }
198
+ { availability ? (
183
199
  <Anchored>
184
200
  <Availability id={ AVAILABILITY } data={ availability } t={ t } />
185
201
  </Anchored>
202
+ ) : datesLoading && (
203
+ <Anchored>
204
+ <AvailabilitySkeleton id={ AVAILABILITY } from={ facts.from } to={ facts.to } t={ t } />
205
+ </Anchored>
186
206
  ) }
187
207
 
188
208
  <Anchored>
package/Step1/Step1.tsx CHANGED
@@ -3,27 +3,55 @@ import { useParams, useSearchParams } from 'react-router-dom';
3
3
  import { TFunction } from 'i18next';
4
4
  import { FaRoute } from 'react-icons/fa';
5
5
  import RoutesSearch from '@autobusal/routes-search';
6
- import prepare from './prepare';
6
+ import prepare, { todayIn } from './prepare';
7
7
  import Steps from '../Steps/Steps';
8
8
  import { Title } from '../styles';
9
9
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
10
10
  import { useGetSettings } from '@autobusal/providers/services';
11
+ import { useGetSuggestions } from '@autobusal/routes-search/services';
11
12
 
12
13
  interface Props {
13
14
  value?: RoutesSearchForm
14
15
  redirected: boolean
16
+ /**
17
+ * The level of the "Search Schedules" heading.
18
+ *
19
+ * Claude - 2026-09-16 (audit): on the bare /bus-lines page the form IS
20
+ * the page and this is its h1. On a pair page the subject is the journey
21
+ * - the facts block's own title - and an h1 above it left every
22
+ * /bus-lines/{from}/{to} page named "Search Schedules" to crawlers and
23
+ * screen readers alike. RoutesOrder decides, because it is the one place
24
+ * that knows whether the facts block is going to render.
25
+ */
26
+ heading?: 'h1' | 'h2'
15
27
  t: TFunction<'common'>
16
28
  onSave: (data: RoutesSearchForm) => void
17
29
  }
18
30
 
19
- const Step1 = ({ value, redirected, t, onSave }: Props): JSX.Element => {
31
+ const Step1 = ({ value, redirected, heading = 'h1', t, onSave }: Props): JSX.Element => {
20
32
  const params = useParams();
21
33
 
22
34
  const [ searchParams ] = useSearchParams();
23
35
 
24
36
  const { data } = useGetSettings();
25
37
 
26
- const prepared = prepare(value, params, searchParams, data.preferences.defaultLocations);
38
+ /**
39
+ * The day the form opens on: TODAY, where the coach leaves from.
40
+ *
41
+ * Claude - 2026-09-16 (audit): it was futureDate(1) - tomorrow on the
42
+ * buyer's device clock - which predates same-day selling and put 17/09 in
43
+ * the box while it was still the 16th in Thessaloniki. The same clock the
44
+ * picker floors on (setDepartureZone in the search form): the origin's
45
+ * timezone, then the server's own date, then the device as a last resort.
46
+ * The city list is the search form's cached query, so this costs nothing.
47
+ */
48
+ const { data: cities } = useGetSuggestions();
49
+
50
+ const origin = params.from ?? data.preferences.defaultLocations.from;
51
+
52
+ const zone = cities.find(city => city.slug === origin)?.country?.timezone;
53
+
54
+ const prepared = prepare(value, params, searchParams, data.preferences.defaultLocations, todayIn(zone) ?? data.today);
27
55
 
28
56
  // Two ways this page ends up with a complete search spec:
29
57
  // 1. `?type=submit` - set by the in-app search form (home/Main/Main.tsx)
@@ -45,7 +73,7 @@ const Step1 = ({ value, redirected, t, onSave }: Props): JSX.Element => {
45
73
 
46
74
  return (
47
75
  <>
48
- <Title>
76
+ <Title as={ heading }>
49
77
  <FaRoute />
50
78
  { t('routes_order.step1.title') }
51
79
  </Title>
package/Step1/prepare.ts CHANGED
@@ -2,11 +2,42 @@ import { Params } from 'react-router-dom';
2
2
  import { futureDate, isPreparedDate } from '@autobusal/utilities';
3
3
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
4
4
 
5
+ /**
6
+ * Today, DD/MM/YYYY, as the clock in `zone` reckons it.
7
+ *
8
+ * Claude - 2026-09-16 (audit): the same en-CA trick the calendar's departure
9
+ * floor uses (common/Calendar/utilities/settings.ts) - it formats as
10
+ * YYYY-MM-DD, the one date string that parses unambiguously, and the parts
11
+ * are reassembled rather than the string parsed so no UTC instant can land
12
+ * on the day before. Null when there is no zone or the runtime rejects it,
13
+ * so the caller falls back rather than opening the form on nothing.
14
+ */
15
+ export const todayIn = (zone?: string | null): string | null => {
16
+ if (!zone) {
17
+ return null;
18
+ }
19
+
20
+ try {
21
+ const [ year, month, day ] = new Intl.DateTimeFormat('en-CA', {
22
+ timeZone: zone,
23
+ year: 'numeric',
24
+ month: '2-digit',
25
+ day: '2-digit'
26
+ }).format(new Date()).split('-');
27
+
28
+ return `${ day }/${ month }/${ year }`;
29
+ } catch {
30
+ return null;
31
+ }
32
+ };
33
+
5
34
  const prepare = (
6
35
  value: (RoutesSearchForm | undefined),
7
36
  params: Readonly<Params<string>>,
8
37
  searchParams: URLSearchParams,
9
- defaultLocations: { from: string, to: string }
38
+ defaultLocations: { from: string, to: string },
39
+ // the day to open on when the URL names none - see Step1 for whose clock
40
+ today?: string | null
10
41
  ): RoutesSearchForm => {
11
42
  if (value !== undefined) {
12
43
  return value;
@@ -24,7 +55,13 @@ const prepare = (
24
55
  */
25
56
  const to = params.to ?? '';
26
57
 
27
- const departure = params.departure?.replace(/-/g, '/') ?? futureDate(1);
58
+ /*
59
+ * Claude - 2026-09-16 (audit): TODAY, not tomorrow. `today` is the day as
60
+ * the departure city reckons it (Step1 works it out); without one the
61
+ * device clock is the only clock left. futureDate(1) dated from before
62
+ * same-day sales existed, and nothing documented a reason to keep it.
63
+ */
64
+ const departure = params.departure?.replace(/-/g, '/') ?? today ?? futureDate(0);
28
65
 
29
66
  /*
30
67
  * Edited: Claude - Date: 2026-08-29
@@ -1,12 +1,12 @@
1
1
  import { useId, useState } from 'react';
2
2
  import { TFunction } from 'i18next';
3
3
  import { FieldErrors, FieldValues, UseFormRegister } from 'react-hook-form';
4
- import { Calendar, Gender, ChooseSeat, Required } from '@autobusal/common';
4
+ import { Calendar, ChooseSeat, Required } from '@autobusal/common';
5
5
  import { Validate, Display } from '@autobusal/utilities';
6
6
  import Choose from '../Saved/Choose';
7
7
  import Remember from '../Saved/Remember';
8
8
  import { useGetSavedPassenger } from '../../services';
9
- import { Container, Title, Notice } from './styles';
9
+ import { Container, Title, Notice, Fieldset, Legend } from './styles';
10
10
  import { PersonData, SavedPassenger, SavedPassengerRecord } from '@autobusal/providers/types/persons';
11
11
  import { OccupiedData } from '@autobusal/providers/types/buses';
12
12
 
@@ -171,9 +171,10 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
171
171
  // The form should post exactly what it shows.
172
172
  if (has('sex')) {
173
173
  // 0 is a perfectly good answer here, so this tests for null rather than
174
- // for falsiness. A traveller with no stored sex leaves the control at
175
- // its own default and is reported by `missing` instead.
176
- onUpdate(names.sex, chosen.sex === null ? 0 : chosen.sex);
174
+ // for falsiness. A traveller with no stored sex leaves the group EMPTY
175
+ // - reported by `missing`, and then required below - rather than being
176
+ // quietly booked as male.
177
+ onUpdate(names.sex, chosen.sex === null ? '' : chosen.sex);
177
178
  }
178
179
 
179
180
  if (has('passport')) {
@@ -233,6 +234,23 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
233
234
  // nobody has been chosen to merge into.
234
235
  const full = saved !== undefined && savedMax !== undefined && savedMax > 0 && saved.length >= savedMax && picked === 0;
235
236
 
237
+ /**
238
+ * The gender to tick when the form mounts, or null for none.
239
+ *
240
+ * Claude - 2026-09-16 (audit): common's <Gender> ticked "Male" whenever
241
+ * nothing was known, so every passenger whose buyer never touched the
242
+ * control travelled as male in the operator's manifest - our guess dressed
243
+ * up as an answer. The radios are rendered here instead, start empty and
244
+ * are required like the fields around them; only a value that was
245
+ * actually recorded (a saved traveller's, or this form's own before a
246
+ * step back) pre-selects one.
247
+ */
248
+ const stored = values?.[names.sex];
249
+
250
+ const sex = record !== undefined
251
+ ? record.sex
252
+ : (stored === undefined || stored === '' ? null : Number(stored));
253
+
236
254
  return (
237
255
  <Container className="box">
238
256
  <Title>
@@ -259,10 +277,19 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
259
277
  <div className="row">
260
278
  <Required htmlFor={ id(names.firstName) }>{ t('routes_order.step4.first_name') }</Required>
261
279
 
280
+ { /* Claude - 2026-09-16 (audit): autocomplete tokens on every field
281
+ a browser or password manager can fill, SCOPED per passenger
282
+ with a section- prefix - without it a two-passenger form is
283
+ offered the same person twice. aria-required says what the
284
+ asterisk shows. The date of birth and the seat pickers are
285
+ shared controls (common) that render their own inputs and are
286
+ not touched here. */ }
262
287
  <input
263
288
  type="text"
264
289
  key={ `${ names.firstName }-${ applied }` }
265
290
  id={ id(names.firstName) }
291
+ autoComplete={ `section-${ name } given-name` }
292
+ aria-required="true"
266
293
  defaultValue={ start(names.firstName, record?.first_name) }
267
294
  { ...refs(names.firstName, Validate('required|min_length:2|max_length:100', t)) }
268
295
  />
@@ -277,6 +304,8 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
277
304
  type="text"
278
305
  key={ `${ names.lastName }-${ applied }` }
279
306
  id={ id(names.lastName) }
307
+ autoComplete={ `section-${ name } family-name` }
308
+ aria-required="true"
280
309
  defaultValue={ start(names.lastName, record?.last_name) }
281
310
  { ...refs(names.lastName, Validate('required|min_length:2|max_length:100', t)) }
282
311
  />
@@ -306,15 +335,31 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
306
335
 
307
336
  { has('sex') && (
308
337
  <div className="row">
309
- { t('routes_order.step4.gender') }
310
-
311
- <Gender
312
- key={ `${ names.sex }-${ applied }` }
313
- name={ names.sex }
314
- defaultValue={ record !== undefined ? (record.sex ?? undefined) : (values !== undefined ? Number(values[names.sex]) : undefined) }
315
- t={ t }
316
- refs={ refs }
317
- />
338
+ { /* a fieldset, so the two radios share one accessible name - on
339
+ their own each announced only "Male" or "Female" with nothing
340
+ saying what the question was */ }
341
+ <Fieldset key={ `${ names.sex }-${ applied }` }>
342
+ <Legend>
343
+ <Required>{ t('routes_order.step4.gender') }</Required>
344
+ </Legend>
345
+
346
+ <div className="list">
347
+ { ([ [ 0, 'male' ], [ 1, 'female' ] ] as const).map(([ value, label ]) => (
348
+ <label key={ value }>
349
+ <input
350
+ type="radio"
351
+ value={ value }
352
+ defaultChecked={ sex === value }
353
+ autoComplete={ `section-${ name } sex` }
354
+ aria-required="true"
355
+ { ...refs(names.sex, Validate('required', t)) }
356
+ />
357
+
358
+ { t(`data.gender.${ label }`, { ns: 'common' }) }
359
+ </label>
360
+ )) }
361
+ </div>
362
+ </Fieldset>
318
363
 
319
364
  { Display(errors[names.sex]) }
320
365
  </div>
@@ -331,6 +376,7 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
331
376
  type="text"
332
377
  key={ `${ names.passport }-${ applied }` }
333
378
  id={ id(names.passport) }
379
+ aria-required="true"
334
380
  defaultValue={ start(names.passport, record?.passport) }
335
381
  { ...refs(names.passport, Validate('required|min_length:2|max_length:50', t)) }
336
382
  />
@@ -349,6 +395,8 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
349
395
  type="text"
350
396
  key={ `${ names.phone }-${ applied }` }
351
397
  id={ id(names.phone) }
398
+ autoComplete={ `section-${ name } tel` }
399
+ aria-required={ phoneOptional ? undefined : 'true' }
352
400
  defaultValue={ start(names.phone, record?.phone) }
353
401
  { ...refs(names.phone, Validate(phoneOptional ? 'min_length:5|max_length:50' : 'required|min_length:5|max_length:50', t)) }
354
402
  />
@@ -376,6 +424,7 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
376
424
  type="email"
377
425
  key={ `${ names.email }-${ applied }` }
378
426
  id={ id(names.email) }
427
+ autoComplete={ `section-${ name } email` }
379
428
  defaultValue={ start(names.email, undefined) }
380
429
  { ...refs(names.email, Validate('email|max_length:255', t)) }
381
430
  />
@@ -402,6 +451,8 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
402
451
  type="text"
403
452
  key={ `${ names.whatsapp }-${ applied }` }
404
453
  id={ id(names.whatsapp) }
454
+ autoComplete={ `section-${ name } tel` }
455
+ aria-required="true"
405
456
  defaultValue={ start(names.whatsapp, record?.whatsapp) }
406
457
  { ...refs(names.whatsapp, Validate('required|min_length:5|max_length:50', t)) }
407
458
  />
@@ -418,6 +469,8 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
418
469
  type="text"
419
470
  key={ `${ names.telegram }-${ applied }` }
420
471
  id={ id(names.telegram) }
472
+ autoComplete={ `section-${ name } nickname` }
473
+ aria-required="true"
421
474
  defaultValue={ start(names.telegram, record?.telegram) }
422
475
  { ...refs(names.telegram, Validate('required|min_length:2|max_length:100', t)) }
423
476
  />
@@ -2,6 +2,37 @@ import styled from 'styled-components';
2
2
 
3
3
  export const Container = styled.div`
4
4
  margin-bottom: 5px;
5
+
6
+ /*
7
+ * Claude - 2026-09-16 (audit): 44px fields under a thumb. The theme sizes
8
+ * every text input at 38px, which is fine under a mouse and short of the
9
+ * 44px minimum on a phone; min-height beats the global height, so nothing
10
+ * else about the fields changes.
11
+ */
12
+ @media (max-width: 639px) {
13
+ input:not([type='checkbox']):not([type='radio']), select {
14
+ min-height: 44px;
15
+ }
16
+ }
17
+ `;
18
+
19
+ /**
20
+ * The gender radios, as a group with one accessible name.
21
+ *
22
+ * Claude - 2026-09-16 (audit): the browser's default fieldset chrome is
23
+ * removed so the group sits in its row like every other field; the legend
24
+ * carries the required label the way the other rows' labels do.
25
+ */
26
+ export const Fieldset = styled.fieldset`
27
+ margin: 0;
28
+ padding: 0;
29
+ min-width: 0;
30
+ border: 0;
31
+ `;
32
+
33
+ export const Legend = styled.legend`
34
+ padding: 0;
35
+ margin-bottom: 6px;
5
36
  `;
6
37
 
7
38
  export const Title = styled.h3`