Fix horizontal truncation across the phone layer
Four defects, all of them real clipping rather than styling taste. The header's two action pills were `shrink-0` at 288px inside a 343px container, so flexbox took the difference out of the logo, which carried `min-w-0`. The brand mark rendered 37px wide at 375px and 0px at 320px, and the CTA ran past the right edge. The wordmark stays legible now and the call button goes square and icon-only below 760px; the CTA moves to a new MobileActionBar, which has room for it and puts it in the thumb zone. It waits for the hero's own buttons to scroll away and drops back down while the form is on screen, so it never covers the fields it points at. `.scroll-cards` bleeds to the viewport edge with a 16px inline padding and a matching negative margin, but had no `scroll-padding-inline`, so `scroll-snap-align: start` resolved to scroll position 16 and mandatory snapping forced that scroll on load: the first card sat on the bezel with no gutter, and one row had drifted a full card over. Matching the padding fixes the resting position on all seven rows. Card tracks move from a percentage of the row to `calc(100vw - 62px)`. A percentage peek grows with the screen, and at 82-90% it exposed ~68px of the next card -- enough legible half-words to read as broken text. The value stays in the markup as `auto-cols-*` so it keeps its sort position against `sm:grid-still`, which has to win at the breakpoint. The market stat card pinned its source line to the card bottom while the longest label wraps to four lines at phone width, running text through text by 75px. The source is in flow under `mt-auto` instead. The header CTA switches on `max-md` rather than the `narrow` variant: `narrow` is max-width 760 and the bar is min-width 760, so at exactly 760px both would have hidden and the page would have had no CTA at all. Verified at 320 / 375 / 759 / 760 / 800 and desktop: no horizontal page scroll, no in-flow overflow, desktop grids unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { EquipmentSection } from './components/EquipmentSection'
|
||||
import { Hero } from './components/Hero'
|
||||
import { LaunchSection } from './components/LaunchSection'
|
||||
import { LossEconomySection } from './components/LossEconomySection'
|
||||
import { MobileActionBar } from './components/MobileActionBar'
|
||||
import { MarketSection } from './components/MarketSection'
|
||||
import { ProgramsSection } from './components/ProgramsSection'
|
||||
import { ProjectSection } from './components/ProjectSection'
|
||||
@@ -32,6 +33,8 @@ export default function App() {
|
||||
|
||||
<ResidencySection />
|
||||
<SiteFooter />
|
||||
|
||||
<MobileActionBar />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function EquipmentSection() {
|
||||
<div
|
||||
ref={track.ref}
|
||||
aria-label="Оборудование ЭКЗО Групп"
|
||||
className={cx('scroll-cards auto-cols-[minmax(275px,84%)] gap-[14px] md:grid-still md:grid-cols-3', track.revealClass)}
|
||||
className={cx('scroll-cards auto-cols-[calc(100vw-62px)] gap-[14px] md:grid-still md:grid-cols-3', track.revealClass)}
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<article
|
||||
|
||||
@@ -19,7 +19,7 @@ export function LaunchSection() {
|
||||
|
||||
<div
|
||||
ref={grid.ref}
|
||||
className={cx('scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-3', grid.revealClass)}
|
||||
className={cx('scroll-cards auto-cols-[calc(100vw-62px)] gap-[12px] sm:grid-still sm:grid-cols-3', grid.revealClass)}
|
||||
>
|
||||
{formats.map((format) => (
|
||||
<article
|
||||
@@ -50,7 +50,7 @@ export function LaunchSection() {
|
||||
|
||||
<div
|
||||
ref={steps.ref}
|
||||
className={cx('scroll-cards auto-cols-[minmax(320px,90%)] gap-[14px] md:grid-still md:grid-cols-2', steps.revealClass)}
|
||||
className={cx('scroll-cards auto-cols-[calc(100vw-62px)] gap-[14px] md:grid-still md:grid-cols-2', steps.revealClass)}
|
||||
>
|
||||
{timeline.map((step) => (
|
||||
<article key={step.week} className="rounded-[20px] border border-teal/[0.16] bg-ice px-[20px] py-[22px]">
|
||||
|
||||
@@ -28,25 +28,28 @@ export function MarketSection() {
|
||||
<div
|
||||
ref={stats.ref}
|
||||
className={cx(
|
||||
'scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-2',
|
||||
'scroll-cards auto-cols-[calc(100vw-62px)] gap-[12px] sm:grid-still sm:grid-cols-2',
|
||||
stats.revealClass,
|
||||
)}
|
||||
>
|
||||
{marketStats.map((stat) => (
|
||||
<article
|
||||
key={stat.label}
|
||||
className="relative min-h-[185px] overflow-hidden rounded-lg border border-navy/[0.12] bg-white p-[24px] shadow-[0_16px_48px_rgb(4_29_46/0.06)]
|
||||
className="relative flex min-h-[185px] flex-col overflow-hidden rounded-lg border border-navy/[0.12] bg-white p-[24px] shadow-[0_16px_48px_rgb(4_29_46/0.06)]
|
||||
phone:min-h-[172px] phone:p-[20px] sm:min-h-[205px]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="teal-bloom-soft absolute -right-[36px] -bottom-[48px] size-[150px] rounded-full"
|
||||
/>
|
||||
<strong className="mb-[8px] block text-[clamp(34px,5vw,54px)] leading-none font-[830] tracking-[-0.06em] text-navy">
|
||||
<strong className="relative z-1 mb-[8px] block text-[clamp(34px,5vw,54px)] leading-none font-[830] tracking-[-0.06em] text-navy">
|
||||
{stat.value}
|
||||
</strong>
|
||||
<p className="max-w-[240px] text-[14px] leading-[1.45] text-muted">{stat.label}</p>
|
||||
<span className="absolute bottom-[19px] left-[24px] text-[10px] tracking-[0.09em] text-[#7E93A3] uppercase phone:bottom-[15px] phone:left-[20px]">
|
||||
<p className="relative z-1 max-w-[240px] text-[14px] leading-[1.45] text-muted">{stat.label}</p>
|
||||
{/* In flow under `mt-auto`, not pinned to the card's bottom: the
|
||||
longest label wraps to four lines at phone card width and ran
|
||||
straight through an absolutely placed source line. */}
|
||||
<span className="relative z-1 mt-auto pt-[14px] text-[10px] tracking-[0.09em] text-[#7E93A3] uppercase">
|
||||
{stat.source}
|
||||
</span>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { contacts } from '../data/content'
|
||||
import { cx } from '../lib/cx'
|
||||
import { ButtonLink, buttonClass } from './Button'
|
||||
import { PhoneIcon } from './Icons'
|
||||
|
||||
/**
|
||||
* The primary CTA's home below 760px, where the header cannot hold it without
|
||||
* crushing the logo. See `SiteHeader`.
|
||||
*
|
||||
* It waits for the hero — whose own two buttons say the same thing — to scroll
|
||||
* away, and steps back down while the form is on screen, so it never sits on
|
||||
* top of the fields it points at. That also keeps it clear of the submit
|
||||
* button, which is the one control it could plausibly be mistaken for.
|
||||
*/
|
||||
export function MobileActionBar() {
|
||||
const [shown, setShown] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const hero = document.getElementById('top')
|
||||
const form = document.getElementById('proposal')
|
||||
if (!hero || !form) return
|
||||
|
||||
let heroOnScreen = true
|
||||
let formOnScreen = false
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === hero) heroOnScreen = entry.isIntersecting
|
||||
if (entry.target === form) formOnScreen = entry.isIntersecting
|
||||
}
|
||||
setShown(!heroOnScreen && !formOnScreen)
|
||||
})
|
||||
|
||||
observer.observe(hero)
|
||||
observer.observe(form)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
/* Not `hidden` when down: it is off-screen, and `inert` already takes it
|
||||
out of the tab order, so the slide stays animatable both ways. */
|
||||
inert={!shown || undefined}
|
||||
className={cx(
|
||||
'fixed inset-x-0 bottom-0 z-[55] border-t border-white/[0.1] bg-navy-deep/[0.94] backdrop-blur-[18px] md:hidden',
|
||||
'px-[16px] pt-[12px] pb-[max(12px,env(safe-area-inset-bottom))]',
|
||||
'transition-transform duration-300 ease-[cubic-bezier(0.2,0.65,0.3,1)]',
|
||||
shown ? 'translate-y-0' : 'translate-y-full',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<a
|
||||
href={contacts.phoneHref}
|
||||
aria-label={`Позвонить в Экзо Групп по номеру ${contacts.phone}`}
|
||||
className={buttonClass('call', 'shrink-0', 'min-h-[52px] w-[52px] px-0')}
|
||||
>
|
||||
<PhoneIcon />
|
||||
</a>
|
||||
<ButtonLink href="#proposal" className="flex-1">
|
||||
Получить предложение
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function ProgramsSection() {
|
||||
lead="Действующие процедуры, ЛФК, массаж и EXO-технологии объединяются в курс под состояние пациента, этап реабилитации и утверждённую клиническую модель."
|
||||
/>
|
||||
|
||||
<div className="scroll-cards auto-cols-[minmax(280px,86%)] gap-[12px] sm:auto-cols-[minmax(300px,48%)] md:grid-still md:grid-cols-5">
|
||||
<div className="scroll-cards auto-cols-[calc(100vw-62px)] gap-[12px] sm:auto-cols-[minmax(300px,48%)] md:grid-still md:grid-cols-5">
|
||||
{programs.map((program) => (
|
||||
<ProgramCard key={program.num} {...program} />
|
||||
))}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ProjectSection() {
|
||||
<div
|
||||
ref={strip.ref}
|
||||
className={cx(
|
||||
'scroll-cards mb-[24px] auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:mb-[28px] sm:grid-cols-2 md:grid-cols-4',
|
||||
'scroll-cards mb-[24px] auto-cols-[calc(100vw-62px)] gap-[12px] sm:grid-still sm:mb-[28px] sm:grid-cols-2 md:grid-cols-4',
|
||||
strip.revealClass,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,11 @@ import { Container } from './Layout'
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="bg-footer py-[26px] text-[12px] text-white/[0.52]">
|
||||
<footer
|
||||
/* Extra bottom room on phones: `MobileActionBar` is ~76px tall plus the
|
||||
safe-area inset, and it is still up at the foot of the page. */
|
||||
className="bg-footer pt-[26px] pb-[calc(102px+env(safe-area-inset-bottom))] text-[12px] text-white/[0.52] md:pb-[26px]"
|
||||
>
|
||||
<Container className="grid gap-[14px] md:grid-cols-[220px_1fr]">
|
||||
<div className="flex max-w-[180px] shrink-0 items-center">
|
||||
<img
|
||||
|
||||
@@ -8,15 +8,22 @@ import { Container } from './Layout'
|
||||
* The original ended up with the blurred background painted unconditionally —
|
||||
* the last stylesheet override drops the scroll-dependent variant — so there is
|
||||
* no scroll state to track here.
|
||||
*
|
||||
* Below 760px the bar carries the brand mark and the call button only. The two
|
||||
* action pills used to be `shrink-0` and 288px wide inside a 343px container,
|
||||
* which left the logo — the one element that has to stay legible on a
|
||||
* credibility-led B2B page — 37px at iPhone width and 0px at 320px, with the
|
||||
* CTA itself running past the right edge. The CTA moves to `MobileActionBar`,
|
||||
* which has room for it and puts it in the thumb zone.
|
||||
*/
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="fixed inset-x-0 top-0 z-[60] bg-navy-deep/[0.92] py-[14px] shadow-[0_12px_42px_rgb(3_20_34/0.18)] backdrop-blur-[18px]">
|
||||
<Container className="flex items-center justify-between gap-[18px]">
|
||||
<a className="inline-flex min-w-0 items-center" href="#top" aria-label="ЭКЗО Групп — наверх">
|
||||
<Container className="flex items-center justify-between gap-[16px]">
|
||||
<a className="inline-flex shrink-0 items-center" href="#top" aria-label="ЭКЗО Групп — наверх">
|
||||
<img
|
||||
alt="ЭКЗО Групп — российские технологии реабилитации"
|
||||
className="block h-auto w-[clamp(172px,20vw,232px)] max-w-full object-contain object-left compact:w-[168px]"
|
||||
className="block h-auto w-[146px] object-contain object-left compact:w-[138px] sm:w-[168px] lg:w-[clamp(172px,20vw,232px)]"
|
||||
src={logo}
|
||||
width={900}
|
||||
height={204}
|
||||
@@ -35,11 +42,23 @@ export function SiteHeader() {
|
||||
</nav>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-[8px]">
|
||||
<a href={contacts.phoneHref} aria-label="Позвонить в Экзо Групп" className={buttonClass('call', undefined, headerSize)}>
|
||||
<a
|
||||
href={contacts.phoneHref}
|
||||
aria-label={`Позвонить в Экзо Групп по номеру ${contacts.phone}`}
|
||||
className={buttonClass(
|
||||
'call',
|
||||
undefined,
|
||||
/* Square and icon-only until the bar has room for the word. */
|
||||
'min-h-[44px] w-[44px] px-0 text-[13px] md:w-auto md:px-[17px]',
|
||||
)}
|
||||
>
|
||||
<PhoneIcon />
|
||||
<span>Звонок</span>
|
||||
<span className="hidden md:inline">Звонок</span>
|
||||
</a>
|
||||
<ButtonLink href="#proposal" size={headerSize}>
|
||||
{/* `max-md`, not the `narrow` variant: `narrow` is max-width 760 and the
|
||||
bar is min-width 760, so at exactly 760px both would hide and the page
|
||||
would have no CTA at all. `max-md` is the exact complement of `md`. */}
|
||||
<ButtonLink href="#proposal" size={headerSize} className="max-md:hidden">
|
||||
Получить предложение
|
||||
</ButtonLink>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function WhyExoSection() {
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
ref={list.ref}
|
||||
className={cx('scroll-cards auto-cols-[minmax(280px,84%)] gap-[10px] md:grid-still', list.revealClass)}
|
||||
className={cx('scroll-cards auto-cols-[calc(100vw-62px)] gap-[10px] md:grid-still', list.revealClass)}
|
||||
>
|
||||
{whyItems.map((item) => (
|
||||
<div
|
||||
|
||||
@@ -166,8 +166,21 @@
|
||||
scrollbar-width: none;
|
||||
padding: 2px 16px 15px;
|
||||
margin-inline: -16px;
|
||||
/* Without this the snapport starts at the scrollport edge, so `snap-align:
|
||||
start` on the first card resolves to scrollLeft 16 — mandatory snapping
|
||||
then forces that scroll on load and the first card sits on the bezel with
|
||||
no gutter. Matching the inline padding puts the resting state at 0. */
|
||||
scroll-padding-inline: 16px;
|
||||
}
|
||||
|
||||
/* Card widths in these rows are written as `calc(100vw - 62px)` rather than
|
||||
as a percentage of the track (16px gutter + 12px gap + a 34px sliver of the
|
||||
next card). A percentage peek scales with the screen, and past ~40px it
|
||||
starts exposing legible half-words, which reads as broken text rather than
|
||||
as "there is more to the right". A fixed sliver looks the same on every
|
||||
phone. The value stays in the markup as `auto-cols-*` so it keeps its sort
|
||||
position against `sm:grid-still`, which has to win at the breakpoint. */
|
||||
|
||||
.scroll-cards::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user