@bytebrand/fe-ui-core-autobahn 1.0.113 → 1.0.114

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.
package/cards/CarCard.tsx CHANGED
@@ -6,8 +6,10 @@
6
6
  // page, landing pages and "similar cars" grids.
7
7
  //
8
8
  // Pure / presentational: all data comes via the `AutobahnCar` prop, all behaviour
9
- // via injected callbacks. No store / API / `window` access. Renders correctly
10
- // only inside a `<div className="md3">` subtree.
9
+ // via injected callbacks. No store/API access — the one guarded `window`-adjacent
10
+ // read is IntersectionObserver (see `eagerMedia` below), feature-checked so a
11
+ // non-browser render (tests without a polyfill) degrades to a no-op, not a crash.
12
+ // Renders correctly only inside a `<div className="md3">` subtree.
11
13
  //
12
14
  // Differs from the design kit on purpose:
13
15
  // - favorite state is INJECTED (isFavorite + onToggleFavorite) instead of the
@@ -15,7 +17,7 @@
15
17
  // - the needs-data deal signals (DropChip / SaveChip) only appear when real
16
18
  // `drop` / `savings` data is present.
17
19
 
18
- import React, { useState } from 'react';
20
+ import React, { useState, useEffect, useRef } from 'react';
19
21
  import { TranslationFunction } from '../types';
20
22
 
21
23
  import { CardCarousel, IconButton, SaveChip, Button } from '../ui';
@@ -53,6 +55,20 @@ export interface CarCardProps {
53
55
  onOpen: (car: AutobahnCar) => void;
54
56
  /** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
55
57
  onMediaEnter?: () => void;
58
+ /**
59
+ * Also fire `onMediaEnter` as soon as the card scrolls into view, instead of waiting for a
60
+ * hover/touch. Off by default — the SRP's grid is effectively unbounded (pagination/infinite
61
+ * scroll), and firing one getImages() request per card the instant it enters the viewport
62
+ * reintroduced exactly the failure mode `loadCarImages` was deliberately scoped away from
63
+ * (SearchNextPage.tsx: "eager per-card getImages() ... raised an error toast PER failed
64
+ * request, stacked toasts on mobile scroll" whenever the image service is degraded).
65
+ * Opt into this only for small, BOUNDED grids (e.g. the homepage's ≤6-card "Top-Angebote"),
66
+ * where without it the gallery's prev/next arrows and image-count dots stay hidden until the
67
+ * user's first hover/touch on THAT card resolves (async) — on touch devices this reads as
68
+ * "controls need a preliminary tap", because the very same tap that reveals them is usually
69
+ * also the tap the user meant as their real interaction with the card.
70
+ */
71
+ eagerMedia?: boolean;
56
72
  /** i18n translate fn (drilled from the page). */
57
73
  t: TranslationFunction;
58
74
  /**
@@ -90,6 +106,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
90
106
  onToggleFavorite,
91
107
  onOpen,
92
108
  onMediaEnter,
109
+ eagerMedia,
93
110
  t,
94
111
  adminAction,
95
112
  onRequestOffer,
@@ -100,6 +117,35 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
100
117
  const [mode, setMode] = useState<PaymentMode>('financing');
101
118
  const [photoIndex, setPhotoIndex] = useState(0);
102
119
  const [viewerOpen, setViewerOpen] = useState(false);
120
+ const mediaRef = useRef<HTMLDivElement | null>(null);
121
+ // always call the LATEST onMediaEnter without re-arming the observer on every render (the
122
+ // page callers below construct a fresh closure each render) — the effect itself only needs
123
+ // to run once per mount.
124
+ const onMediaEnterRef = useRef(onMediaEnter);
125
+ onMediaEnterRef.current = onMediaEnter;
126
+ useEffect(() => {
127
+ if (!eagerMedia || typeof IntersectionObserver === 'undefined') return undefined;
128
+ const el = mediaRef.current;
129
+ if (!el) return undefined;
130
+ let fired = false;
131
+ // rootMargin starts the fetch a bit before the card is actually on-screen, so by the time
132
+ // the user's eye (or finger, on touch) reaches it the gallery has usually already resolved
133
+ // — the whole point being no tap is required to make prev/next + the counter "real".
134
+ const observer = new IntersectionObserver(
135
+ (entries) => {
136
+ entries.forEach((entry) => {
137
+ if (entry.isIntersecting && !fired) {
138
+ fired = true;
139
+ onMediaEnterRef.current && onMediaEnterRef.current();
140
+ observer.disconnect();
141
+ }
142
+ });
143
+ },
144
+ { rootMargin: '200px 0px', threshold: 0 },
145
+ );
146
+ observer.observe(el);
147
+ return () => observer.disconnect();
148
+ }, [eagerMedia]);
103
149
  // as-good-as-new gate (mileage < 1,000 km AND first registration unknown or < 12 months) —
104
150
  // undefined (older AutobahnCar producer not yet updated) fails OPEN, keeping prior behaviour.
105
151
  const asGoodAsNew = car.asGoodAsNew !== false;
@@ -119,6 +165,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
119
165
  >
120
166
  <div
121
167
  className="cc-media"
168
+ ref={mediaRef}
122
169
  style={{ position: 'relative' }}
123
170
  onMouseEnter={onMediaEnter}
124
171
  onTouchStart={onMediaEnter}
package/cards/CarRow.tsx CHANGED
@@ -6,7 +6,9 @@
6
6
  // CTA stop propagation so they don't also trigger the card open.
7
7
  //
8
8
  // Pure / presentational: all data via the `AutobahnCar` prop, all behaviour via
9
- // injected callbacks. No store / API / `window` access. Renders correctly only
9
+ // injected callbacks. No store/API access — the one guarded `window`-adjacent read is
10
+ // IntersectionObserver (see `eagerMedia` below), feature-checked so a non-browser render
11
+ // (tests without a polyfill) degrades to a no-op, not a crash. Renders correctly only
10
12
  // inside a `<div className="md3">` subtree.
11
13
  //
12
14
  // Differs from the design kit on purpose:
@@ -18,7 +20,7 @@
18
20
  // no className prop) so the scoped `.pp-cta` margin / responsive rules and the
19
21
  // `.pp-cta + .pp-activity` adjacency in md3.css still apply.
20
22
 
21
- import React, { useState } from 'react';
23
+ import React, { useState, useEffect, useRef } from 'react';
22
24
  import { TranslationFunction } from '../types';
23
25
 
24
26
  import { CardCarousel, IconButton, SaveChip, Button, Icon, StatChip } from '../ui';
@@ -53,6 +55,16 @@ export interface CarRowProps {
53
55
  onOpen: (car: AutobahnCar) => void;
54
56
  /** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
55
57
  onMediaEnter?: () => void;
58
+ /**
59
+ * Also fire `onMediaEnter` as soon as the card scrolls into view, instead of waiting for a
60
+ * hover/touch. Off by default. See CarCard's identically-named prop for the full rationale —
61
+ * same trade-off applies here (SRP's list view is the other consumer of this same concern).
62
+ * Safe to enable even on an effectively-unbounded list AS LONG AS the caller's `onMediaEnter`
63
+ * itself caps concurrent fetches (e.g. SearchNextPage.tsx's loadCarImages queues through
64
+ * runQueuedImageLoad) — this component has no opinion on that, it only decides WHEN to call
65
+ * onMediaEnter, never how many may run at once.
66
+ */
67
+ eagerMedia?: boolean;
56
68
  /** i18n translate fn (drilled from the page). */
57
69
  t: TranslationFunction;
58
70
  /**
@@ -75,12 +87,39 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
75
87
  onToggleFavorite,
76
88
  onOpen,
77
89
  onMediaEnter,
90
+ eagerMedia,
78
91
  t,
79
92
  adminAction,
80
93
  onShowFinancingDetails,
81
94
  }) => {
82
95
  const [mode, setMode] = useState<PaymentMode>('financing');
83
96
  const [photoIndex, setPhotoIndex] = useState(0);
97
+ const mediaRef = useRef<HTMLDivElement | null>(null);
98
+ // always call the LATEST onMediaEnter without re-arming the observer on every render (the
99
+ // page callers construct a fresh closure each render) — the effect itself only needs to run
100
+ // once per mount. Identical pattern to CarCard.tsx — see its own comment for the rationale.
101
+ const onMediaEnterRef = useRef(onMediaEnter);
102
+ onMediaEnterRef.current = onMediaEnter;
103
+ useEffect(() => {
104
+ if (!eagerMedia || typeof IntersectionObserver === 'undefined') return undefined;
105
+ const el = mediaRef.current;
106
+ if (!el) return undefined;
107
+ let fired = false;
108
+ const observer = new IntersectionObserver(
109
+ (entries) => {
110
+ entries.forEach((entry) => {
111
+ if (entry.isIntersecting && !fired) {
112
+ fired = true;
113
+ onMediaEnterRef.current && onMediaEnterRef.current();
114
+ observer.disconnect();
115
+ }
116
+ });
117
+ },
118
+ { rootMargin: '200px 0px', threshold: 0 },
119
+ );
120
+ observer.observe(el);
121
+ return () => observer.disconnect();
122
+ }, [eagerMedia]);
84
123
  // as-good-as-new gate (mileage < 1,000 km AND first registration unknown or < 12 months) —
85
124
  // undefined (older AutobahnCar producer not yet updated) fails OPEN, keeping prior behaviour.
86
125
  const asGoodAsNew = car.asGoodAsNew !== false;
@@ -109,7 +148,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
109
148
  onClick={() => onOpen(car)}
110
149
  style={{ background: 'var(--md-sc-lowest)' }}
111
150
  >
112
- <div className="listcard-media" style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
151
+ <div className="listcard-media" ref={mediaRef} style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
113
152
  {/* 'auto': desktop picks cover/contain per photo (crops only when it wouldn't lose too
114
153
  much of the shot); below 601px this is identical to 'contain', so mobile is unchanged. */}
115
154
  <CardCarousel images={car.gallery} height="100%" fit="auto" onChange={setPhotoIndex} />
@@ -173,7 +212,17 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
173
212
  <GalleryBadges car={car} t={t} />
174
213
  </div>
175
214
  {/* desktop: single row — light "1|N" photo counter + a combined eye/heart chip, no
176
- availability/Geprüft pill (not present in the Figma "mcard" export). */}
215
+ availability/Geprüft pill (not present in the Figma "mcard" export).
216
+ Same 3-state policy as the mobile pills above (GalleryCounter/GalleryBadges,
217
+ cardParts.tsx) and CarCard's GalleryCounter/GalleryActivityPill, just inlined here
218
+ instead of reusing those components (different chip markup — one shared "N|M" +
219
+ eye+heart pill instead of three separate ones):
220
+ - single-image: car.photos is the real, eagerly-known count (not the lazily-loaded
221
+ gallery array length) — accurate even before the extra photos have loaded.
222
+ - loading: viewsCount/favoritesCount are undefined until stats resolve for this car
223
+ — hidden, no skeleton, same as everywhere else.
224
+ - zero-value: a real 0 is intentionally treated the same as "not loaded" — hidden,
225
+ not shown as "eye 0" / "heart 0" (noise on a dense results list otherwise). */}
177
226
  <div
178
227
  className="hide-mobile"
179
228
  style={{ position: 'absolute', bottom: 20, left: 12, zIndex: 3, display: 'flex', alignItems: 'center', gap: 6 }}
@@ -203,6 +203,7 @@ export const DropChip: React.FunctionComponent<DropChipProps> = ({ amount, t })
203
203
  color: 'var(--md-good)',
204
204
  fontSize: 10.5,
205
205
  fontWeight: 700,
206
+ lineHeight: '14px',
206
207
  boxShadow: 'var(--md-e1)',
207
208
  whiteSpace: 'nowrap',
208
209
  }}
@@ -230,7 +231,7 @@ const dealPill: React.CSSProperties = {
230
231
  export const PercentChip: React.FunctionComponent<{ pct?: number }> = ({ pct }) => {
231
232
  if (pct == null || pct <= 0) return null;
232
233
  return (
233
- <span className="deal-chip deal-chip--percent" style={{ ...dealPill, background: 'var(--md-error)', color: '#fff' }}>
234
+ <span className="deal-chip deal-chip--percent" style={{ ...dealPill, background: 'var(--md-error)', color: '#fff', lineHeight: '14px' }}>
234
235
  −{pct}%
235
236
  </span>
236
237
  );
@@ -835,7 +836,15 @@ export interface GalleryCounterProps {
835
836
  * Bottom-left gallery overlay: expand icon + "current|total" (Figma). Reuses the existing
836
837
  * `.imglabel` pill style (already used for the list-card's photo-count badge) rather than a new
837
838
  * class — same dark translucent pill, just with the maximize icon instead of the image icon.
838
- * Hidden with 0/1 photos (nothing to expand into).
839
+ *
840
+ * States (shared policy across GalleryCounter, the carousel's own arrows/dots in CardCarousel,
841
+ * and GalleryActivityPill below — see its own doc comment for the other two):
842
+ * - single-image: `total` is the car's REAL, eagerly-known photo count (car.photos, from the
843
+ * search payload — not the lazily-loaded gallery array length), so this is accurate even
844
+ * before any image beyond the first has actually loaded. total<=1 → hidden: nothing to expand
845
+ * into. Never a "1|1" placeholder.
846
+ * - loading: while `total` itself is still unknown (car not yet resolved) the caller simply
847
+ * hasn't rendered this component yet — there is no separate in-between skeleton state here.
839
848
  */
840
849
  export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ current, total, light, onClick }) => {
841
850
  if (total <= 1) return null;
@@ -864,8 +873,18 @@ export interface GalleryActivityPillProps {
864
873
 
865
874
  /**
866
875
  * Bottom-right gallery overlay: eye + views, heart + favorites, in ONE pill (Figma "Push Chips /
867
- * Likes", node 297:1037). Each half renders independently and the whole pill is omitted when
868
- * neither figure has loaded yet (same async-stats/NEEDS-DATA discipline as the rest of this file).
876
+ * Likes", node 297:1037). Each half renders independently.
877
+ *
878
+ * States (chosen ONE policy, applied identically to both halves):
879
+ * - loading: `viewsCount`/`favoritesCount` are `undefined` until the async stats call
880
+ * (UserActionsAPI 'stats/viewers') resolves for this car. No skeleton — the half (or the
881
+ * whole pill, if both are still pending) simply doesn't render yet, same "hidden, not a
882
+ * placeholder" discipline as GalleryCounter/CardCarousel's arrows above.
883
+ * - zero-value: a REAL `0` is treated the same as "not there" — hidden, not shown as "👁 0".
884
+ * Deliberate choice over spelling out "0" (the other option the ticket allows): on a dense
885
+ * grid of fresh listings a "👁 0 ❤ 0" pill on every card is noise, not signal. `!viewsCount`/
886
+ * `!favoritesCount` below intentionally don't distinguish real-0 from not-yet-loaded — both
887
+ * read as "nothing worth showing (yet)", which is the same outcome either way.
869
888
  */
870
889
  export const GalleryActivityPill: React.FunctionComponent<GalleryActivityPillProps> = ({ viewsCount, favoritesCount }) => {
871
890
  if (!viewsCount && !favoritesCount) return null;
package/chrome/TopBar.tsx CHANGED
@@ -110,9 +110,14 @@ export interface TopBarProps {
110
110
  * number/tel: link was confirmed for this, so it defaults to the contact page. Pass a `tel:...`
111
111
  * href here once one is confirmed. */
112
112
  phoneHref?: string;
113
- /** mobile-only: slide the header out of view on scroll-down, back in on scroll-up (coordinated
114
- * with the VDP sticky offer bottom bar's own show/hide). Off by default — only the car-detail
115
- * page (topBarVariant 'conversion') opts in; every other page keeps the header always docked. */
113
+ /** mobile-only (the CSS that actually moves the header, `.topbar--hidden`, only exists inside
114
+ * the `@media (max-width: 600px)` block — desktop keeps this prop's `hidden` state but never
115
+ * renders it): slide the header out of view on scroll-down, back in on scroll-up. On the
116
+ * car-detail page this is additionally coordinated with the VDP sticky offer bottom bar (hides
117
+ * well before that bar would ever dock — see STICKY_SHOW_AT in VehicleDetailedNextPage.tsx).
118
+ * Off by default — the car-detail page (topBarVariant 'conversion'), the homepage ('/') and
119
+ * the SRP list page ('/search') opt in (see App.tsx); other pages keep the header always
120
+ * docked. */
116
121
  autoHideOnScroll?: boolean;
117
122
  }
118
123
 
@@ -382,6 +382,7 @@
382
382
  },
383
383
  "rate": {
384
384
  "upTo": "Rate bis",
385
+ "upToPlaceholder": "Rate bis, z. B. 350 €/mtl.",
385
386
  "perMonth": "/mtl.",
386
387
  "downPayment": "Anzahlung",
387
388
  "none": "keine",
@@ -389,6 +390,10 @@
389
390
  "months": "{{months}} Monate",
390
391
  "months_plural": "{{months}} Monate"
391
392
  },
393
+ "mileage": {
394
+ "upTo": "km bis",
395
+ "upToPlaceholder": "km bis, z. B. 50000 km"
396
+ },
392
397
  "consumption": {
393
398
  "unitLabel": "l / 100 km"
394
399
  },
@@ -723,7 +728,7 @@
723
728
  "leaseVehiclePrice": "Anschaffungspreis¹",
724
729
  "leaseDownPayment": "Einmalige Sonderzahlung",
725
730
  "finalPaymentDue": "Schlusszahlung (fällig am Vertragsende)",
726
- "residualValue": "Restwert (verpflichtend)",
731
+ "residualValue": "Verpflichtender Kaufpreis am Vertragsende",
727
732
  "rateSumLabel": "Summe der Leasingraten ({{term}} × {{amount}} €)",
728
733
  "addServicesRowLease": "Rundum-Sorglos-Paket (Einmalzahlung)",
729
734
  "subtotalWithPackage": "Gesamt inkl. Sonderzahl. & Paket",
@@ -382,6 +382,7 @@
382
382
  },
383
383
  "rate": {
384
384
  "upTo": "Rate up to",
385
+ "upToPlaceholder": "Rate up to, e.g. 350 €/month",
385
386
  "perMonth": "/month",
386
387
  "downPayment": "Down payment",
387
388
  "none": "none",
@@ -389,6 +390,10 @@
389
390
  "months": "{{months}} months",
390
391
  "months_plural": "{{months}} months"
391
392
  },
393
+ "mileage": {
394
+ "upTo": "Mileage up to",
395
+ "upToPlaceholder": "Mileage up to, e.g. 50000 km"
396
+ },
392
397
  "consumption": {
393
398
  "unitLabel": "l / 100 km"
394
399
  },
@@ -723,7 +728,7 @@
723
728
  "leaseVehiclePrice": "Acquisition price¹",
724
729
  "leaseDownPayment": "One-time special payment",
725
730
  "finalPaymentDue": "Final payment (due at contract end)",
726
- "residualValue": "Residual value (mandatory)",
731
+ "residualValue": "Mandatory purchase price at contract end",
727
732
  "rateSumLabel": "Sum of leasing installments ({{term}} × {{amount}} €)",
728
733
  "addServicesRowLease": "Complete care package (one-time payment)",
729
734
  "subtotalWithPackage": "Total incl. special payment & package",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/fe-ui-core-autobahn",
3
- "version": "1.0.113",
3
+ "version": "1.0.114",
4
4
  "description": "auto.de md3 (Autobahn) design system — UI primitives, cards, chrome, VDP modules, theme CSS and locales. Ships raw TypeScript source (like fe-ui-core before it); each consumer's bundler compiles it.",
5
5
  "main": "index.ts",
6
6
  "license": "UNLICENSED",
package/theme/md3.css CHANGED
@@ -453,6 +453,44 @@ line-height: 31px;
453
453
  width: 14px;
454
454
  height: 14px;
455
455
  }
456
+ /* Figma spec for the rating chip in its two "Guter Preis" locations — the VDP header
457
+ (VdpHeaderSummary, .vdp-headrow) and the SRP list-card's price column (CarRow.tsx,
458
+ .listcard-price, both its mobile Finanzierung row and desktop Kaufen row share this one class).
459
+ Scoped to THESE locations only, not every .statchip site-wide (e.g. the price-history "drop"
460
+ chip a few sections down reuses tone="good" for a completely different purpose — a €-amount,
461
+ not a price rating — and keeps the theme-aware --md-good-container tokens untouched). min-width
462
+ (not a hard width) + white-space:nowrap: 108px is tight for icon+"Guter Preis"+⁴ on one line,
463
+ so this grows past it instead of wrapping/clipping rather than clamping to a width that doesn't fit. */
464
+ .md3 .vdp-headrow .statchip,
465
+ .md3 .listcard-price .statchip {
466
+ min-width: 108px;
467
+ height: 27px;
468
+ border-radius: 11px;
469
+ font-family: 'Arial';
470
+ font-weight: 700;
471
+ font-size: 12px;
472
+ line-height: 14px;
473
+ text-align: center;
474
+ white-space: nowrap;
475
+ justify-content: center;
476
+ }
477
+ .md3 .vdp-headrow .statchip sup,
478
+ .md3 .listcard-price .statchip sup {
479
+ position: relative;
480
+ top: -3px;
481
+ left: -4px;
482
+ font-size: 9px;
483
+ line-height: 9px;
484
+ }
485
+ /* 'good' tone here is specifically the "Guter Preis" rating (same canonical green as the
486
+ fin-eval-pill "good" tier elsewhere on this page) — literal color, not the --md-good-container
487
+ token, matching that other pill's own literal-color treatment. --warn (Fairer Preis) is
488
+ untouched, still tokenized. */
489
+ .md3 .vdp-headrow .statchip--good,
490
+ .md3 .listcard-price .statchip--good {
491
+ color: rgb(22, 138, 69);
492
+ background: color-mix(in srgb, rgb(22, 138, 69) 16%, transparent);
493
+ }
456
494
 
457
495
  /* small image label (views, photo count) */
458
496
  .md3 .imglabel {
@@ -3747,7 +3785,7 @@ line-height: 31px;
3747
3785
  min-width: 0;
3748
3786
  }
3749
3787
  /* "NEU" badge on car cards (legacy new-car label). */
3750
- .md3 .neu-badge { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: var(--md-r-full); background: var(--md-primary); color: #fff; font-size: 10.5px; font-weight: 700; letter-spacing: .3px; white-space: nowrap; box-shadow: var(--md-e1); }
3788
+ .md3 .neu-badge { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: var(--md-r-full); background: var(--md-primary); color: #fff; font-size: 10.5px; font-weight: 700; line-height: 14px; letter-spacing: .3px; white-space: nowrap; box-shadow: var(--md-e1); }
3751
3789
  /* "⚡ Angebot" badge — dark neutral pill above the gallery (card mockup), distinct from NEU. */
3752
3790
  .md3 .offer-badge { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 9px; border-radius: var(--md-r-full); background: var(--md-on-surface); color: #fff; font-size: 11px; font-weight: 600; white-space: nowrap; }
3753
3791
  .md3 .offer-badge .lucide { width: 13px; height: 13px; }
@@ -5164,37 +5202,50 @@ line-height: 31px;
5164
5202
  width: 112px;
5165
5203
  flex: none;
5166
5204
  }
5167
- .md3 .cc-price .fin-eval-pill {
5168
- font-family: Arial, Helvetica, sans-serif;
5169
- font-style: normal;
5205
+ /* Figma spec, now identical across all three "Guter Preis" pill locations (CarCard's own
5206
+ .cc-price, incl. the VDP "Ähnliche Fahrzeuge" rail; .fin-calc-card; .fin-modal) — one shared
5207
+ rule instead of three copies. `background`/`color` are deliberately NOT set here: they stay
5208
+ driven by the tier-color inline style each render site sets (see PriceEvalCol in
5209
+ cardParts.tsx / FinanceCalculator.tsx / VdpModules.tsx, all of which already resolve to the
5210
+ canonical rgb(22,138,69) for the 'good' tier) — a `!important` used to hardcode this pill's
5211
+ background to a fixed #dff3e5 here, silently overriding that inline value regardless of tier. */
5212
+ .md3 .cc-price .fin-eval-pill,
5213
+ .md3 .fin-calc-card .fin-eval-pill,
5214
+ .md3 .fin-modal .fin-eval-pill {
5215
+ font-family: 'Arial';
5170
5216
  font-weight: 700;
5171
5217
  font-size: 12px;
5172
5218
  line-height: 14px;
5173
5219
  text-align: center;
5174
5220
  }
5175
- .md3 .cc-price .fin-eval-pill--good {
5176
- background: #dff3e5 !important;
5177
- border-radius: 11px !important;
5178
- width: 100%;
5221
+ .md3 .cc-price .fin-eval-pill--good,
5222
+ .md3 .fin-calc-card .fin-eval-pill--good,
5223
+ .md3 .fin-modal .fin-eval-pill--good {
5224
+ width: 108px;
5179
5225
  height: 27px;
5226
+ border-radius: 11px;
5180
5227
  box-sizing: border-box;
5228
+ display: flex;
5229
+ align-items: center;
5181
5230
  justify-content: center;
5182
5231
  }
5183
5232
  /* footnote 4 ("Top-Preis" claim) — .fin-eval-pill is a flex container, so vertical-align has
5184
5233
  no effect on the sup (it's a flex item, not inline content); position:relative/top raises
5185
- it instead, same technique as the hero price's own 1/2/3 footnote markers. Shared by the
5186
- CarCard hero (.cc-price), the VDP FinanceCalculator's own pill (.fin-calc-card), and the
5187
- FinanceDetailsModal's embedded mobile rate sheet (.fin-modal). */
5234
+ it instead, same technique as the hero price's own 1/2/3 footnote markers. Same -3px lift now
5235
+ shared by all three locations above. */
5188
5236
  .md3 .cc-price .fin-eval-pill sup,
5189
5237
  .md3 .fin-calc-card .fin-eval-pill sup,
5190
5238
  .md3 .fin-modal .fin-eval-pill sup {
5191
5239
  position: relative;
5192
- top: 0px;
5240
+ top: -3px;
5193
5241
  left: -4px;
5194
5242
  font-size: 9px;
5195
5243
  line-height: 9px;
5196
5244
  }
5197
5245
  .md3 .cc-price .fin-eval-bar {
5246
+ /* matches the pill's own fixed 108px right above (both sit in the same 112px .fin-eval-col),
5247
+ so the two stay visually aligned instead of the bar stretching to the full column width. */
5248
+ width: 108px;
5198
5249
  height: 7px;
5199
5250
  }
5200
5251
  .md3 .cc-price .fin-eval-seg:first-child {
@@ -5424,14 +5475,8 @@ line-height: 31px;
5424
5475
  width: 112px;
5425
5476
  justify-self: end;
5426
5477
  }
5427
- /* pill now sits above the bar in a real flex column (see above) — stretch it to the bar's own
5428
- width instead of the base .fin-eval-pill's inline-flex/content-hugging size. */
5429
- .md3 .fin-calc-card .fin-eval-pill--good {
5430
- width: 100%;
5431
- display: flex;
5432
- align-items: center;
5433
- justify-content: center;
5434
- }
5478
+ /* .fin-calc-card/.fin-modal's own "Guter Preis" pill text/box/sup sizing now lives in the one
5479
+ shared rule (with .cc-price) further up this file, near .cc-price's own .fin-eval-* rules. */
5435
5480
  /* .fin-calc-card's own "Rate anpassen" panel is compact — the mobile-sheet/modal padding
5436
5481
  (22/28px top, 20px bottom) is too roomy here. */
5437
5482
  .md3 .fin-calc-card .fin-toggle-final,
@@ -6662,13 +6707,36 @@ line-height: 31px;
6662
6707
  }
6663
6708
  .md3 .srp-bar {
6664
6709
  position: sticky;
6665
- top: 64px;
6710
+ /* was 64px (docked under the always-visible header) — now 0: once the header slides away
6711
+ (autoHideOnScroll, TopBar.tsx) this bar takes its place at the very top instead of leaving
6712
+ a gap, and sits flush under it again the moment the header slides back in. */
6713
+ top: 0px;
6666
6714
  z-index: 20;
6667
- background: var(--md-surface);
6715
+ background: #fff;
6668
6716
  padding: 8px 0;
6669
6717
  margin: 0 0 8px;
6670
6718
  box-shadow: 0 6px 8px -8px rgba(0, 0, 0, 0.3);
6671
6719
  }
6720
+ /* full-bleed white to the true viewport edges, WITHOUT moving .srp-bar's own text — it stays
6721
+ inside .srp-main's own side padding, this pseudo-element just paints white past it on both
6722
+ sides so no gap of page background shows while the bar is sticky. NOT a box-shadow spread
6723
+ (that bleeds in all 4 directions equally, including a huge slab below the bar covering the
6724
+ cards underneath it — that was tried and broke the page) — an absolutely-positioned layer
6725
+ confined to top:0/bottom:0 of .srp-bar's own box (position:sticky is a containing block for
6726
+ it, same as position:relative) stays exactly as tall as the bar, only wider. z-index:-1 is
6727
+ scoped to .srp-bar's own stacking context (it has z-index:20), so it paints behind the bar's
6728
+ text/buttons but still above the page content scrolling underneath. */
6729
+ .md3 .srp-bar::before {
6730
+ content: '';
6731
+ position: absolute;
6732
+ top: 0;
6733
+ bottom: 0;
6734
+ left: 50%;
6735
+ width: 100vw;
6736
+ transform: translateX(-50%);
6737
+ background: #fff;
6738
+ z-index: -1;
6739
+ }
6672
6740
  .md3 .field-grid-2 {
6673
6741
  grid-template-columns: 1fr;
6674
6742
  gap: 10px;
@@ -8703,14 +8771,10 @@ color: #005CFF;
8703
8771
  gap: 16px;
8704
8772
  grid-area: evalcol;
8705
8773
  }
8706
- .md3 .fin-eval-pill--good {
8707
- background: #dff3e5 !important;
8708
- border-radius: 11px !important;
8709
- width: 100%;
8710
- height: 27px;
8711
- box-sizing: border-box;
8712
- justify-content: center;
8713
- }
8774
+ /* box sizing for this pill (all three of its locations) lives in the shared, higher-specificity
8775
+ .cc-price/.fin-calc-card/.fin-modal rule near the top of this file — this generic, unscoped
8776
+ copy used to ALSO force `background: #dff3e5 !important`, silently overriding every tier's
8777
+ real inline color (the 'good' tier's canonical rgb(22,138,69) included) at this breakpoint. */
8714
8778
  .md3 .fin-eval-bar {
8715
8779
  height: 7px;
8716
8780
  }
@@ -10334,10 +10398,14 @@ color: #005CFF;
10334
10398
  }
10335
10399
  .md3 .vdp-photosheet {
10336
10400
  position: absolute;
10337
- left: 0;
10338
- right: 0;
10339
- bottom: 0;
10340
- height: 100vh;
10401
+ /* was `left/right/bottom: 0` + `height: 100vh` — over-constrained (top was the free value, so
10402
+ it was computed as containerHeight - 100vh) and, on the affected mobile browsers, 100vh can
10403
+ exceed the CURRENT visible height (e.g. address bar still shown), which solved to a NEGATIVE
10404
+ top: the sheet's top edge — its own header, with a close button — rendered above y=0 of this
10405
+ already-safely-sized fixed overlay (see VdpViewer.tsx's own top-of-file portal comment), i.e.
10406
+ behind the browser's UI. `inset: 0` instead just fills the (correctly-sized) positioned
10407
+ ancestor exactly, no viewport unit involved. */
10408
+ inset: 0;
10341
10409
  z-index: 211;
10342
10410
  display: flex;
10343
10411
  flex-direction: column;
@@ -10356,7 +10424,9 @@ color: #005CFF;
10356
10424
  align-items: center;
10357
10425
  justify-content: center;
10358
10426
  min-height: 88px;
10359
- padding-top: 10px;
10427
+ /* safe-area-aware: with the `inset: 0` fix above this sits flush against the true top edge of
10428
+ the fullscreen overlay — same notch/status-bar territory as the main viewer's own header. */
10429
+ padding-top: max(10px, env(safe-area-inset-top));
10360
10430
  touch-action: none;
10361
10431
  }
10362
10432
  .md3 .vdp-photosheet__handle {
@@ -10371,7 +10441,8 @@ color: #005CFF;
10371
10441
  }
10372
10442
  .md3 .vdp-photosheet__close {
10373
10443
  position: absolute;
10374
- left: 12px;
10444
+ /* safe-area-aware: in the landscape layout the notch can rotate onto this side. */
10445
+ left: max(12px, env(safe-area-inset-left));
10375
10446
  top: 50%;
10376
10447
  transform: translateY(-50%);
10377
10448
  }
@@ -10385,7 +10456,8 @@ color: #005CFF;
10385
10456
  }
10386
10457
  .md3 .vdp-photosheet__titles {
10387
10458
  flex: none;
10388
- padding: 0 16px 12px;
10459
+ /* safe-area-aware left/right: landscape can rotate the notch onto either side. */
10460
+ padding: 0 max(16px, env(safe-area-inset-right)) 12px max(16px, env(safe-area-inset-left));
10389
10461
  text-align: left;
10390
10462
  }
10391
10463
  .md3 .vdp-photosheet__title {
@@ -10408,7 +10480,9 @@ color: #005CFF;
10408
10480
  flex: 1;
10409
10481
  min-height: 0;
10410
10482
  overflow-y: auto;
10411
- padding: 0 12px 12px;
10483
+ /* safe-area-aware left/right — otherwise the outer grid tiles can sit partly under a
10484
+ landscape-rotated notch, tappable in theory but visually cut off. */
10485
+ padding: 0 max(12px, env(safe-area-inset-right)) 12px max(12px, env(safe-area-inset-left));
10412
10486
  }
10413
10487
  .md3 .vdp-photosheet__grid {
10414
10488
  display: grid;
@@ -10454,8 +10528,10 @@ color: #005CFF;
10454
10528
  align-items: center;
10455
10529
  justify-content: flex-start;
10456
10530
  gap: 6px;
10457
- height: 44px;
10458
- padding: 0 16px;
10531
+ /* was a fixed `height: 44px` — swapped for `min-height` so the safe-area bottom padding below
10532
+ (home indicator) grows the bar instead of pushing its text outside a clipped fixed box. */
10533
+ min-height: 44px;
10534
+ padding: 0 max(16px, env(safe-area-inset-right)) max(0px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
10459
10535
  /* rgba(0,0,0,0.5) was invisible here — it darkens whatever's behind it, and the sheet's own
10460
10536
  panel (rgb(6,11,18)) is already almost black, so a 50%-black overlay on top of near-black
10461
10537
  reads as no change at all. #1c222c is lighter/bluer and already used elsewhere in this same
@@ -132,7 +132,18 @@ const CardCarousel: React.FunctionComponent<CardCarouselProps> = ({
132
132
  ))}
133
133
  </div>
134
134
  {/* arrows + dots only when there is actually something to switch to — with a single
135
- image (e.g. SRP cards before their gallery is lazy-loaded) they are dead controls. */}
135
+ image (e.g. SRP cards before their gallery is lazy-loaded) they are dead controls.
136
+ States: single-image → n<=1 → hidden here (same "nothing to expand into" case as
137
+ GalleryCounter, cardParts.tsx). Loading → `images` starts as a 1-element array (the
138
+ card's main thumbnail) until the caller's lazy-load resolves, so this rule ALSO covers
139
+ "still loading" — arrows/dots simply aren't rendered yet, no skeleton, same discipline
140
+ as the other overlay controls. On touch devices this used to mean "hidden until the
141
+ user's first hover/touch on this exact card" (mouseenter/touchstart was the ONLY lazy-
142
+ load trigger) — CarCard's `eagerMedia` (see its own doc comment) starts that same load
143
+ on scroll-into-view instead, so this loading window is normally already closed by the
144
+ time the user reaches the card, without changing anything in this component. Desktop
145
+ hover-to-reveal (`.cardcar:hover .car-arrow`) vs. always-visible on touch is a CSS-only
146
+ concern — see `@media (hover: none)` in md3.css, not component state. */}
136
147
  {n > 1 && (
137
148
  <>
138
149
  <button className="car-arrow car-arrow--l" aria-label="zurück" onClick={(e) => go(-1, e)}>