Redesign the phone layer of the landing

Three layout bugs and a mobile experience that had no wayfinding.

The `#proposal` section was clipped, not merely cramped: the submit
button carried `whitespace-nowrap` from the shared button base, giving
it a min-content width of 358px, which sized the section's implicit
`auto` grid track to 406px inside a 343px container. `overflow-hidden`
then sliced the copy and every form field off the right edge of a 375px
screen. Fixed at the root — `whitespace-nowrap` moves out of `base` and
into `headerSize`, where buttons are sized by their label — plus an
explicit `grid-cols-[minmax(0,1fr)]` on the section.

The market stat cards put their source line in absolute position against
a guessed min-height, so the longest label ran underneath it. It is in
the flow now, pinned by `mt-auto`.

Below 980px the header nav is not rendered, which left seventeen screens
of scroll with no way to navigate them. The header gives that width back
to the page — logo and call button only — and <ThumbBar /> takes over:
scroll progress, a live section indicator that opens a section sheet,
and one short CTA, hidden over the hero and over the form.

Card rows now say what they are. Rows of unordered peers keep scrolling
sideways and gain a measured segment indicator (<Rail />), replacing the
two prose "swipe sideways" hints. The launch timeline goes the other
way: sideways scrolling hid week 3-5 from someone reading week 1-2, so
below 900px it becomes four disclosures on a spine — which also recovers
the scroll length the taller hero spent.

Below 560px the type scale steps down half a level and the display
tracking opens back up, and the hero photo zooms past `cover` onto the
wave wall, since fitting by height left only ceiling tiles above the
copy.

Also: the route card inside the market section split into two 165px
columns at 760px, so that split now waits for 980px.

Desktop above 980px is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 11:32:28 +06:00
co-authored by Claude Opus 5
parent cd3414d8d0
commit 9b4f93bb16
22 changed files with 724 additions and 114 deletions
+3
View File
@@ -9,6 +9,7 @@ import { ResidencySection } from './components/ResidencySection'
import { ScenariosSection } from './components/ScenariosSection' import { ScenariosSection } from './components/ScenariosSection'
import { SiteFooter } from './components/SiteFooter' import { SiteFooter } from './components/SiteFooter'
import { SiteHeader } from './components/SiteHeader' import { SiteHeader } from './components/SiteHeader'
import { ThumbBar } from './components/ThumbBar'
import { WhyExoSection } from './components/WhyExoSection' import { WhyExoSection } from './components/WhyExoSection'
export default function App() { export default function App() {
@@ -30,6 +31,8 @@ export default function App() {
<ResidencySection /> <ResidencySection />
<SiteFooter /> <SiteFooter />
<ThumbBar />
</> </>
) )
} }
+12 -2
View File
@@ -4,10 +4,20 @@ import { cx } from '../lib/cx'
export type ButtonVariant = 'primary' | 'secondary' | 'call' export type ButtonVariant = 'primary' | 'secondary' | 'call'
const base = const base =
'inline-flex cursor-pointer items-center justify-center gap-[10px] rounded-full border border-transparent font-[790] leading-none whitespace-nowrap ' + 'inline-flex cursor-pointer items-center justify-center gap-[10px] rounded-full border border-transparent font-[790] leading-none ' +
'transition-[transform,box-shadow,background-color,border-color] duration-200 hover:-translate-y-[2px] active:translate-y-0 ' + 'transition-[transform,box-shadow,background-color,border-color] duration-200 hover:-translate-y-[2px] active:translate-y-0 ' +
'[&>svg]:size-[18px] [&>svg]:shrink-0' '[&>svg]:size-[18px] [&>svg]:shrink-0'
/**
* `whitespace-nowrap` belongs on buttons sized by their label, not on every
* button. A full-width button whose label cannot wrap reports a min-content
* width of its whole label — 358px for «Получить предварительный аудит» — and
* that width propagates out through any `auto` grid track around it. That is
* what pushed the whole `#proposal` section 47px off a 375px screen and let
* `overflow-hidden` slice the copy and the form fields off the right edge.
*/
export const nowrap = 'whitespace-nowrap'
/** /**
* Sizing gets its own slot rather than living in `base`. Tailwind resolves * Sizing gets its own slot rather than living in `base`. Tailwind resolves
* conflicting utilities by their order in the stylesheet, not by the order they * conflicting utilities by their order in the stylesheet, not by the order they
@@ -18,7 +28,7 @@ const base =
const defaultSize = 'min-h-[52px] px-[22px]' const defaultSize = 'min-h-[52px] px-[22px]'
/** `.btn--header` — smaller, and tighter still on the narrowest phones. */ /** `.btn--header` — smaller, and tighter still on the narrowest phones. */
export const headerSize = 'min-h-[44px] px-[17px] text-[13px] phone:px-[14px] phone:text-[12px]' export const headerSize = 'min-h-[44px] px-[17px] text-[13px] whitespace-nowrap phone:px-[14px] phone:text-[12px]'
const variants: Record<ButtonVariant, string> = { const variants: Record<ButtonVariant, string> = {
primary: primary:
@@ -2,7 +2,8 @@ import { devices, equipmentNote } from '../data/content'
import { useReveal } from '../hooks/useReveal' import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { CheckIcon } from './Icons' import { CheckIcon } from './Icons'
import { Container, ScrollHint, Section, SectionHead } from './Layout' import { Container, Section, SectionHead } from './Layout'
import { Rail } from './Rail'
/** /**
* Product shots differ in framing, so the last stylesheet pass in the original * Product shots differ in framing, so the last stylesheet pass in the original
@@ -37,10 +38,12 @@ export function EquipmentSection() {
lead="Конфигурация подбирается под профиль центра, поток пациентов и задачи врачей. Используем технологическое ядро ЭКЗО без универсального набора и лишнего дублирования." lead="Конфигурация подбирается под профиль центра, поток пациентов и задачи врачей. Используем технологическое ядро ЭКЗО без универсального набора и лишнего дублирования."
/> />
<div <Rail
ref={track.ref} outerRef={track.ref}
aria-label="Оборудование Экзо Групп" wrapperClassName={track.revealClass}
className={cx('scroll-cards auto-cols-[minmax(275px,84%)] gap-[14px] md:grid-still md:grid-cols-3', track.revealClass)} tone="dark"
label="Оборудование Экзо Групп"
className="scroll-cards auto-cols-[minmax(275px,84%)] gap-[14px] md:grid-still md:grid-cols-3"
> >
{devices.map((device) => ( {devices.map((device) => (
<article <article
@@ -76,11 +79,7 @@ export function EquipmentSection() {
</div> </div>
</article> </article>
))} ))}
</div> </Rail>
<ScrollHint tone="dark" className="md:hidden">
На мобильном листайте карточки в сторону
</ScrollHint>
<div <div
ref={note.ref} ref={note.ref}
+25 -8
View File
@@ -13,7 +13,8 @@ export function Hero() {
<section <section
id="top" id="top"
aria-labelledby="hero-title" aria-labelledby="hero-title"
className="relative isolate flex min-h-[780px] items-end overflow-hidden bg-navy-deep pt-[126px] pb-[48px] text-white lg:min-h-[810px]" className="relative isolate flex min-h-[780px] items-end overflow-hidden bg-navy-deep pt-[126px] pb-[48px] text-white
compact:min-h-[660px] compact:pt-[132px] compact:pb-[36px] lg:min-h-[810px]"
> >
<div <div
aria-hidden="true" aria-hidden="true"
@@ -32,33 +33,49 @@ export function Hero() {
<h1 id="hero-title">Физиотерапия с нуля готовое направление под поток вашего центра</h1> <h1 id="hero-title">Физиотерапия с нуля готовое направление под поток вашего центра</h1>
<p className="mb-[28px] max-w-[760px] text-[clamp(19px,2vw,24px)] leading-[1.52] text-white/[0.8]"> <p className="mb-[28px] max-w-[760px] text-[clamp(19px,2vw,24px)] leading-[1.52] text-white/[0.8] compact:mb-[22px] compact:text-[17px]">
Проводим аудит клиентской базы и помещения. Проектируем кабинеты, подбираем технологии ЭКЗО, создаём Проводим аудит клиентской базы и помещения. Проектируем кабинеты, подбираем технологии ЭКЗО, создаём
клинические маршруты, обучаем команду. клинические маршруты, обучаем команду.
</p> </p>
<div className="mb-[34px] flex flex-col items-stretch gap-[12px] sm:flex-row sm:items-center"> <div className="mb-[34px] flex flex-col items-stretch gap-[12px] compact:mb-[26px] sm:flex-row sm:items-center">
<ButtonLink href="#proposal"> <ButtonLink href="#proposal">
Получить предварительный аудит Получить предварительный аудит
<ArrowRightIcon /> <ArrowRightIcon />
</ButtonLink> </ButtonLink>
</div> </div>
{/* Two specs and a claim, not three facts. On a phone the third one
stops being an orphan on its own row and starts being the point:
it is the only number here about money. */}
<div <div
aria-label="Ключевые параметры проекта" aria-label="Ключевые параметры проекта"
className="grid max-w-[760px] grid-cols-3 gap-[10px] phone:grid-cols-2 phone:[&>*:last-child]:col-span-full" className="grid max-w-[760px] grid-cols-3 gap-[10px] phone:grid-cols-2 phone:gap-[9px] phone:[&>*:last-child]:col-span-full"
> >
{heroFacts.map((fact) => ( {heroFacts.map((fact, index) => {
const claim = index === heroFacts.length - 1
return (
<div <div
key={fact.value} key={fact.value}
className="min-h-[92px] rounded-md border border-white/[0.14] bg-white/[0.08] px-[16px] py-[17px] backdrop-blur-[13px]" className={cx(
'min-h-[92px] rounded-md border border-white/[0.14] bg-white/[0.08] px-[16px] py-[17px] backdrop-blur-[13px]',
'phone:min-h-0 phone:px-[14px] phone:py-[13px]',
claim && 'phone:border-teal/[0.42] phone:bg-teal/[0.11]',
)}
>
<strong
className={cx(
'mb-[3px] block text-[clamp(20px,3vw,30px)] leading-[1.05] tracking-[-0.03em] [overflow-wrap:anywhere]',
claim && 'phone:text-[#9FF6EC]',
)}
> >
<strong className="mb-[3px] block text-[clamp(20px,3vw,30px)] leading-[1.05] tracking-[-0.03em] [overflow-wrap:anywhere]">
{fact.value} {fact.value}
</strong> </strong>
<span className="block text-[12px] leading-[1.35] text-white/[0.65]">{fact.label}</span> <span className="block text-[12px] leading-[1.35] text-white/[0.65]">{fact.label}</span>
</div> </div>
))} )
})}
</div> </div>
<div className="mt-[18px] inline-flex items-center gap-[9px] text-[14px] font-[720] text-[#C8FFF9]"> <div className="mt-[18px] inline-flex items-center gap-[9px] text-[14px] font-[720] text-[#C8FFF9]">
+18 -2
View File
@@ -1,9 +1,9 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
/** Every icon on the page is a 24×24 outline drawn in `currentColor`. */ /** Every icon on the page is a 24×24 outline drawn in `currentColor`. */
function Icon({ children }: { children: ReactNode }) { function Icon({ className, children }: { className?: string; children: ReactNode }) {
return ( return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none"> <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" className={className}>
{children} {children}
</svg> </svg>
) )
@@ -25,6 +25,22 @@ export function CheckIcon() {
) )
} }
export function ChevronUpIcon({ className }: { className?: string }) {
return (
<Icon className={className}>
<path d="m6 15 6-6 6 6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
</Icon>
)
}
export function CloseIcon({ className }: { className?: string }) {
return (
<Icon className={className}>
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeLinecap="round" strokeWidth="2" />
</Icon>
)
}
export function PhoneIcon() { export function PhoneIcon() {
return ( return (
<Icon> <Icon>
@@ -1,11 +1,16 @@
import { useState } from 'react'
import { formatNote, formats, timeline } from '../data/content' import { formatNote, formats, timeline } from '../data/content'
import { useMediaQuery } from '../hooks/useMediaQuery'
import { useReveal } from '../hooks/useReveal' import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { ChevronUpIcon } from './Icons'
import { Container, Section, SectionHead, SmallNote } from './Layout' import { Container, Section, SectionHead, SmallNote } from './Layout'
import { Rail } from './Rail'
export function LaunchSection() { export function LaunchSection() {
const grid = useReveal<HTMLDivElement>() const grid = useReveal<HTMLDivElement>()
const steps = useReveal<HTMLDivElement>() const steps = useReveal<HTMLDivElement>()
const stacked = useMediaQuery('(max-width: 899px)')
return ( return (
<Section id="launch" tone="white"> <Section id="launch" tone="white">
@@ -17,9 +22,11 @@ export function LaunchSection() {
lead="Вместо хаотичной закупки техники мы внедряем экосистему: совмещаем технологии, настраиваем санитарную логистику, обеспечиваем приватность пациентов и внедряем четкие протоколы работы." lead="Вместо хаотичной закупки техники мы внедряем экосистему: совмещаем технологии, настраиваем санитарную логистику, обеспечиваем приватность пациентов и внедряем четкие протоколы работы."
/> />
<div <Rail
ref={grid.ref} outerRef={grid.ref}
className={cx('scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-3', grid.revealClass)} wrapperClassName={grid.revealClass}
label="Форматы кабинета"
className="scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-3"
> >
{formats.map((format) => ( {formats.map((format) => (
<article <article
@@ -37,29 +44,92 @@ export function LaunchSection() {
<p className="text-[14px] text-muted">{format.text}</p> <p className="text-[14px] text-muted">{format.text}</p>
</article> </article>
))} ))}
</div> </Rail>
<SmallNote className="mt-[16px]">{formatNote}</SmallNote> <SmallNote className="mt-[16px]">{formatNote}</SmallNote>
{/* The timeline breaks into two columns at 900px, not at the shared {/* Twelve weeks in order — deliberately not a rail. Sideways scrolling
760px step the rest of the page uses. */} would hide week 35 from someone still reading week 12, and the
order is the whole point of this block. Below 900px the four weeks
stack along a teal spine and open one at a time: the sequence stays
visible at a glance, and the eighteen bullets underneath it stop
costing 2400px of scroll to walk past. */}
<div <div
ref={steps.ref} ref={steps.ref}
className={cx( className={cx(
'scroll-cards mt-[30px] auto-cols-[minmax(330px,92%)] gap-[14px] min-[900px]:grid-still min-[900px]:grid-cols-2', 'mt-[30px] grid grid-cols-[minmax(0,1fr)] gap-[14px]',
'relative pl-[26px] before:absolute before:top-[16px] before:bottom-[16px] before:left-[5px] before:w-[2px]',
"before:rounded-full before:bg-[linear-gradient(180deg,var(--color-teal),rgb(0_196_180/0.14))] before:content-['']",
'min-[900px]:grid-cols-2 min-[900px]:gap-[14px] min-[900px]:pl-0 min-[900px]:before:hidden',
steps.revealClass, steps.revealClass,
)} )}
> >
{timeline.map((step) => ( {timeline.map((step, index) => (
<TimelineStep key={step.week} step={step} defaultOpen={index === 0} collapsible={stacked} />
))}
</div>
</Container>
</Section>
)
}
/**
* One week of the launch plan. A disclosure below 900px, a plain card above it
* — the difference is real markup, not a class, because a heading that is a
* button on a phone should not stay a button on a desktop where nothing
* collapses.
*/
function TimelineStep({
step,
defaultOpen,
collapsible,
}: {
step: (typeof timeline)[number]
defaultOpen: boolean
collapsible: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
const expanded = !collapsible || open
const week = (
<span className="block text-[12px] font-black tracking-[0.11em] text-teal-ink uppercase">{step.week}</span>
)
const title = <strong className="mt-[6px] block text-[17px] leading-[1.2] text-navy">{step.title}</strong>
return (
<article <article
key={step.week} className="relative rounded-[20px] border border-teal/[0.16] bg-ice px-[20px] py-[20px]
className="rounded-[20px] border border-teal/[0.16] bg-ice px-[20px] py-[22px]" before:absolute before:top-[26px] before:-left-[26px] before:size-[12px] before:rounded-full before:border-[3px]
before:border-white before:bg-teal before:content-[''] min-[900px]:py-[22px] min-[900px]:before:hidden"
> >
<span className="mb-[8px] block text-[12px] font-black tracking-[0.11em] text-teal-ink uppercase"> {collapsible ? (
{step.week} <button
type="button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
className="flex w-full cursor-pointer items-start gap-[12px] text-left outline-none focus-visible:rounded-[6px] focus-visible:ring-2 focus-visible:ring-teal"
>
<span className="min-w-0 flex-1">
{week}
{title}
</span> </span>
<strong className="mb-[10px] block text-[18px] leading-[1.2] text-navy">{step.title}</strong> <ChevronUpIcon
<span className="mb-[14px] block text-[13px] leading-[1.55] text-muted italic">{step.lead}</span> className={cx(
'mt-[3px] size-[18px] shrink-0 text-teal-ink transition-transform duration-250',
open ? 'rotate-0' : 'rotate-180',
)}
/>
</button>
) : (
<>
{week}
{title}
</>
)}
{expanded ? (
<>
<span className="mt-[12px] mb-[14px] block text-[13px] leading-[1.55] text-muted italic">{step.lead}</span>
<ul className="grid list-disc gap-[8px] pl-[18px] text-[13px] leading-[1.55] text-muted"> <ul className="grid list-disc gap-[8px] pl-[18px] text-[13px] leading-[1.55] text-muted">
{step.items.map((item) => ( {step.items.map((item) => (
<li key={item.title}> <li key={item.title}>
@@ -68,10 +138,8 @@ export function LaunchSection() {
</li> </li>
))} ))}
</ul> </ul>
</>
) : null}
</article> </article>
))}
</div>
</Container>
</Section>
) )
} }
@@ -151,19 +151,3 @@ export function Section({
</section> </section>
) )
} }
/** `.scroll-hint` — only meaningful while the card row still scrolls sideways. */
export function ScrollHint({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
return (
<div
aria-hidden="true"
className={cx(
"mt-[12px] flex items-center gap-[8px] text-[11px] before:h-px before:w-[24px] before:bg-current before:content-['']",
tone === 'dark' ? 'text-white/[0.48]' : 'text-muted',
className,
)}
>
{children}
</div>
)
}
@@ -73,7 +73,7 @@ export function LeadForm() {
noValidate noValidate
onSubmit={onSubmit} onSubmit={onSubmit}
className={cx( className={cx(
'rounded-xl border border-white/[0.14] bg-white/[0.1] p-[clamp(23px,4vw,34px)] shadow-[0_30px_90px_rgb(0_0_0/0.23)] backdrop-blur-[20px]', 'min-w-0 rounded-xl border border-white/[0.14] bg-white/[0.1] p-[clamp(23px,4vw,34px)] shadow-[0_30px_90px_rgb(0_0_0/0.23)] backdrop-blur-[20px]',
revealClass, revealClass,
)} )}
> >
@@ -2,6 +2,7 @@ import { marketNotes, marketStats, routeWith, routeWithout } from '../data/conte
import { useReveal } from '../hooks/useReveal' import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { Container, Eyebrow, Section, SectionHead, SmallNote } from './Layout' import { Container, Eyebrow, Section, SectionHead, SmallNote } from './Layout'
import { Rail } from './Rail'
export function MarketSection() { export function MarketSection() {
const stats = useReveal<HTMLDivElement>() const stats = useReveal<HTMLDivElement>()
@@ -17,39 +18,41 @@ export function MarketSection() {
lead="Забудьте о расходах на привлечение новых клиентов. Мы встраиваем готовое направление физиотерапии в вашу структуру, превращая лояльную базу пациентов и текущий штат врачей в источник новой прибыли." lead="Забудьте о расходах на привлечение новых клиентов. Мы встраиваем готовое направление физиотерапии в вашу структуру, превращая лояльную базу пациентов и текущий штат врачей в источник новой прибыли."
/> />
<div className="grid gap-[24px] md:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)] md:items-stretch"> <div className="grid grid-cols-[minmax(0,1fr)] gap-[24px] md:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)] md:items-stretch">
<div <Rail
ref={stats.ref} outerRef={stats.ref}
className={cx( wrapperClassName={stats.revealClass}
'scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-2', label="Рыночные показатели"
stats.revealClass, className="scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-2"
)}
> >
{marketStats.map((stat) => ( {marketStats.map((stat) => (
/* The source line used to be absolutely positioned against the
card's min-height, so the longest label ran underneath it.
It is in the flow now, pinned to the bottom by `mt-auto`. */
<article <article
key={stat.label} 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]" phone:min-h-[168px] phone:p-[20px] sm:min-h-[205px]"
> >
<span <span
aria-hidden="true" aria-hidden="true"
className="teal-bloom-soft absolute -right-[36px] -bottom-[48px] size-[150px] rounded-full" 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 mb-[8px] block text-[clamp(34px,5vw,54px)] leading-none font-[830] tracking-[-0.06em] text-navy">
{stat.value} {stat.value}
</strong> </strong>
<p className="max-w-[240px] text-[14px] leading-[1.45] text-muted">{stat.label}</p> <p className="relative 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]"> <span className="relative mt-auto pt-[14px] text-[10px] tracking-[0.09em] text-[#7E93A3] uppercase">
{stat.source} {stat.source}
</span> </span>
</article> </article>
))} ))}
</div> </Rail>
<article <article
ref={gap.ref} ref={gap.ref}
className={cx( className={cx(
'gap-card-surface relative grid gap-[16px] overflow-hidden rounded-xl p-[27px] text-white shadow-deep', 'gap-card-surface relative grid gap-[16px] overflow-hidden rounded-xl p-[27px] text-white shadow-deep compact:p-[20px]',
gap.revealClass, gap.revealClass,
)} )}
> >
@@ -60,12 +63,15 @@ export function MarketSection() {
<div className="relative z-1"> <div className="relative z-1">
<Eyebrow tone="dark">Исходная точка</Eyebrow> <Eyebrow tone="dark">Исходная точка</Eyebrow>
<h3 className="max-w-[600px] text-[clamp(27px,3.5vw,42px)]"> <h3 className="max-w-[600px] text-[clamp(27px,3.5vw,42px)] compact:text-[22px] compact:tracking-[-0.02em]">
Маршрут пациента: как перестать отдавать прибыль конкурентам. Маршрут пациента: как перестать отдавать прибыль конкурентам.
</h3> </h3>
</div> </div>
<div className="relative z-1 grid gap-[12px] md:grid-cols-2"> {/* Not `md:` — at 760 the card is already sharing the row with the
stats, so splitting it again gives two 165px columns of
four-word lines. It waits for 980, where the card is wide. */}
<div className="relative z-1 grid gap-[12px] lg:grid-cols-2">
<RouteColumn sign="—" title="Пока физиотерапии нет" items={routeWithout} /> <RouteColumn sign="—" title="Пока физиотерапии нет" items={routeWithout} />
<RouteColumn sign="+" title="После запуска направления" items={routeWith} accent /> <RouteColumn sign="+" title="После запуска направления" items={routeWith} accent />
</div> </div>
@@ -1,7 +1,8 @@
import { programs, routeSteps } from '../data/content' import { programs, routeSteps } from '../data/content'
import { useReveal } from '../hooks/useReveal' import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { Container, Eyebrow, ScrollHint, Section, SectionHead } from './Layout' import { Container, Eyebrow, Section, SectionHead } from './Layout'
import { Rail } from './Rail'
export function ProgramsSection() { export function ProgramsSection() {
const route = useReveal<HTMLElement>() const route = useReveal<HTMLElement>()
@@ -15,13 +16,14 @@ export function ProgramsSection() {
lead="Программа связывает врача, технологии, длительность и контроль динамики. Название и состав адаптируются под профиль центра и утверждённую клиническую модель." lead="Программа связывает врача, технологии, длительность и контроль динамики. Название и состав адаптируются под профиль центра и утверждённую клиническую модель."
/> />
<div className="scroll-cards auto-cols-[minmax(300px,88%)] gap-[14px] sm:auto-cols-[minmax(300px,48%)] md:grid-still md:grid-cols-5"> <Rail
label="Клинические программы"
className="scroll-cards auto-cols-[minmax(300px,88%)] gap-[14px] sm:auto-cols-[minmax(300px,48%)] md:grid-still md:grid-cols-5"
>
{programs.map((program) => ( {programs.map((program) => (
<ProgramCard key={program.num} {...program} /> <ProgramCard key={program.num} {...program} />
))} ))}
</div> </Rail>
<ScrollHint className="md:hidden">На мобильном листайте программы</ScrollHint>
<aside <aside
ref={route.ref} ref={route.ref}
@@ -3,6 +3,7 @@ import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { promiseIcons } from './Icons' import { promiseIcons } from './Icons'
import { Container, Section, SectionHead, SmallNote } from './Layout' import { Container, Section, SectionHead, SmallNote } from './Layout'
import { Rail } from './Rail'
export function ProjectSection() { export function ProjectSection() {
const strip = useReveal<HTMLDivElement>() const strip = useReveal<HTMLDivElement>()
@@ -23,12 +24,11 @@ export function ProjectSection() {
} }
/> />
<div <Rail
ref={strip.ref} outerRef={strip.ref}
className={cx( wrapperClassName={cx('mb-[24px] sm:mb-[28px]', strip.revealClass)}
'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', label="Что получает центр"
strip.revealClass, className="scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-2 md:grid-cols-4"
)}
> >
{promises.map((promise) => ( {promises.map((promise) => (
<article key={promise.title} className="rounded-[20px] border border-navy/[0.12] bg-white p-[22px]"> <article key={promise.title} className="rounded-[20px] border border-navy/[0.12] bg-white p-[22px]">
@@ -39,7 +39,7 @@ export function ProjectSection() {
<p className="text-[14px] text-muted">{promise.text}</p> <p className="text-[14px] text-muted">{promise.text}</p>
</article> </article>
))} ))}
</div> </Rail>
<div <div
ref={panel.ref} ref={panel.ref}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react'
import { cx } from '../lib/cx'
/**
* A row of peer cards: scrolls sideways on phones, becomes a plain grid at the
* breakpoint the caller sets in `className` (`sm:grid-still` / `md:grid-still`).
*
* Only rows whose cards are unordered peers belong here. Sequences — the launch
* timeline, the patient route — stay stacked, because sideways scrolling hides
* step two from someone still reading step one.
*
* The segmented indicator underneath measures the row instead of guessing from
* a breakpoint, so it appears exactly when the sideways gesture exists and
* disappears the moment the row turns back into a grid.
*/
export function Rail({
label,
tone = 'light',
className,
wrapperClassName,
outerRef,
children,
}: {
label?: string
tone?: 'light' | 'dark'
/** Classes for the scrolling row itself. */
className?: string
/** Classes for the wrapper that holds the row and the indicator. */
wrapperClassName?: string
outerRef?: RefObject<HTMLDivElement | null>
children: ReactNode
}) {
const rowRef = useRef<HTMLDivElement>(null)
const [{ index, count }, setPosition] = useState({ index: 0, count: 0 })
useEffect(() => {
const row = rowRef.current
if (!row) return
const measure = () => {
/* `count: 0` hides the indicator — the row is a grid at this width. */
if (row.scrollWidth - row.clientWidth < 4) {
setPosition((current) => (current.count === 0 ? current : { index: 0, count: 0 }))
return
}
const cards = Array.from(row.children) as HTMLElement[]
const middle = row.getBoundingClientRect().left + row.clientWidth / 2
let nearest = 0
let shortest = Infinity
cards.forEach((card, position) => {
const box = card.getBoundingClientRect()
const distance = Math.abs(box.left + box.width / 2 - middle)
if (distance < shortest) {
shortest = distance
nearest = position
}
})
setPosition((current) =>
current.index === nearest && current.count === cards.length
? current
: { index: nearest, count: cards.length },
)
}
measure()
row.addEventListener('scroll', measure, { passive: true })
const observer = new ResizeObserver(measure)
observer.observe(row)
return () => {
row.removeEventListener('scroll', measure)
observer.disconnect()
}
}, [])
const goTo = (position: number) => {
const row = rowRef.current
const card = row?.children[position] as HTMLElement | undefined
if (!row || !card) return
/* 16px is the row's own inline padding — the resting offset of card one. */
row.scrollBy({
left: card.getBoundingClientRect().left - row.getBoundingClientRect().left - 16,
behavior: 'smooth',
})
}
return (
<div ref={outerRef} className={wrapperClassName}>
<div ref={rowRef} aria-label={label} className={className}>
{children}
</div>
{count > 1 ? (
<div className="mt-[2px] flex items-center gap-[7px]">
{Array.from({ length: count }, (_, position) => (
<button
key={position}
type="button"
aria-label={`Показать карточку ${position + 1} из ${count}`}
aria-current={position === index}
onClick={() => goTo(position)}
className="flex h-[22px] cursor-pointer items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-teal"
>
<span
className={cx(
'block h-[4px] rounded-full transition-[width,background-color] duration-300',
position === index ? 'w-[28px]' : 'w-[10px]',
tone === 'dark'
? position === index
? 'bg-teal-bright'
: 'bg-white/[0.22]'
: position === index
? 'bg-teal'
: 'bg-navy/[0.16]',
)}
/>
</button>
))}
</div>
) : null}
</div>
)
}
@@ -10,10 +10,15 @@ export function RequestSection() {
return ( return (
<section id="proposal" className="cta-surface relative overflow-hidden py-[clamp(72px,9vw,120px)] text-white"> <section id="proposal" className="cta-surface relative overflow-hidden py-[clamp(72px,9vw,120px)] text-white">
<Container className="relative grid items-center gap-[30px] md:grid-cols-[minmax(0,1fr)_minmax(430px,0.83fr)]"> {/* `grid-cols-[minmax(0,1fr)]` is load-bearing: an implicit `auto` track
<div ref={copy.ref} className={cx(copy.revealClass)}> takes its minimum from the widest thing inside it, so anything in the
form that cannot wrap sets the width of the whole section. */}
<Container className="relative grid grid-cols-[minmax(0,1fr)] items-center gap-[30px] md:grid-cols-[minmax(0,1fr)_minmax(430px,0.83fr)]">
<div ref={copy.ref} className={cx('min-w-0', copy.revealClass)}>
<Eyebrow tone="dark">Следующий шаг</Eyebrow> <Eyebrow tone="dark">Следующий шаг</Eyebrow>
<h2 className="text-[clamp(39px,6vw,68px)]">Запустим физиотерапию с нуля под ваш поток</h2> <h2 className="text-[clamp(39px,6vw,68px)] compact:text-[32px] compact:tracking-[-0.035em]">
Запустим физиотерапию с нуля под ваш поток
</h2>
<SectionLead tone="dark"> <SectionLead tone="dark">
Оставьте контакты специалист уточнит профиль центра, пациентский поток и доступные помещения, после чего Оставьте контакты специалист уточнит профиль центра, пациентский поток и доступные помещения, после чего
подготовит предварительную концепцию, конфигурацию и финансовую модель направления. подготовит предварительную концепцию, конфигурацию и финансовую модель направления.
@@ -20,10 +20,10 @@ export function ScenariosSection() {
Сценарии выручки Сценарии выручки
</p> </p>
<h2 className="mb-[10px] max-w-[980px] text-[clamp(32px,4.8vw,56px)] leading-[1.04] tracking-[-0.04em]"> <h2 className="mb-[10px] max-w-[980px] text-[clamp(32px,4.8vw,56px)] leading-[1.04] tracking-[-0.04em] compact:text-[29px] compact:tracking-[-0.03em]">
Даже умеренная загрузка кабинета даёт понятную выручку Даже умеренная загрузка кабинета даёт понятную выручку
</h2> </h2>
<p className="mb-[34px] max-w-[900px] text-[clamp(17px,1.8vw,21px)] leading-[1.55] text-white/[0.72]"> <p className="mb-[34px] max-w-[900px] text-[clamp(17px,1.8vw,21px)] leading-[1.55] text-white/[0.72] compact:mb-[26px]">
Модель считается просто: количество оплаченных курсов × средний чек курса. Дальше на аудите добавляем расходы и Модель считается просто: количество оплаченных курсов × средний чек курса. Дальше на аудите добавляем расходы и
срок окупаемости. срок окупаемости.
</p> </p>
@@ -4,7 +4,8 @@ import { Container } from './Layout'
export function SiteFooter() { export function SiteFooter() {
return ( return (
<footer className="bg-footer py-[26px] text-[12px] text-white/[0.52]"> /* The extra bottom padding below 980px is the landing strip for <ThumbBar />. */
<footer className="bg-footer py-[26px] pb-[96px] text-[12px] text-white/[0.52] lg:pb-[26px]">
<Container className="grid gap-[14px] md:grid-cols-[220px_1fr]"> <Container className="grid gap-[14px] md:grid-cols-[220px_1fr]">
<div className="flex max-w-[180px] shrink-0 items-center"> <div className="flex max-w-[180px] shrink-0 items-center">
<img <img
@@ -6,14 +6,20 @@ import { ButtonLink, buttonClass, headerSize } from './Button'
import { PhoneIcon } from './Icons' import { PhoneIcon } from './Icons'
import { Container } from './Layout' import { Container } from './Layout'
/** Transparent over the hero; gains the blurred bar once the page scrolls. */ /**
* Transparent over the hero; gains the blurred bar once the page scrolls.
*
* Below 980px it carries a logo and a call button and nothing else. The nav is
* not shown at that width and the proposal button has moved to <ThumbBar />,
* so there is nothing left up here to compete with the page.
*/
export function SiteHeader() { export function SiteHeader() {
const scrolled = useScrolled() const scrolled = useScrolled()
return ( return (
<header <header
className={cx( className={cx(
'fixed inset-x-0 top-0 z-[60] py-[14px] transition-[background-color,box-shadow,backdrop-filter] duration-250', 'fixed inset-x-0 top-0 z-[60] py-[14px] transition-[background-color,box-shadow,backdrop-filter] duration-250 phone:py-[9px]',
scrolled && 'bg-navy-deep/[0.88] shadow-[0_12px_42px_rgb(3_20_34/0.18)] backdrop-blur-[18px]', scrolled && 'bg-navy-deep/[0.88] shadow-[0_12px_42px_rgb(3_20_34/0.18)] backdrop-blur-[18px]',
)} )}
> >
@@ -43,12 +49,15 @@ export function SiteHeader() {
<a <a
href={contacts.phoneHref} href={contacts.phoneHref}
aria-label="Позвонить в Экзо Групп" aria-label="Позвонить в Экзо Групп"
className={buttonClass('call', 'phone:px-[12px] [&>svg]:phone:size-[18px]', headerSize)} className={buttonClass('call', 'phone:min-h-[40px] phone:px-[13px] [&>svg]:phone:size-[18px]', headerSize)}
> >
<PhoneIcon /> <PhoneIcon />
<span className="phone:hidden">Звонок</span> <span className="phone:hidden">Звонок</span>
</a> </a>
<ButtonLink href="#proposal" size={headerSize}> {/* `max-lg:hidden`, not `hidden lg:inline-flex`: `inline-flex` from
the button base is emitted after `hidden` in the utility layer and
would win at every width. */}
<ButtonLink href="#proposal" className="max-lg:hidden" size={headerSize}>
Получить предложение Получить предложение
</ButtonLink> </ButtonLink>
</div> </div>
@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from 'react'
import { contacts, sectionNav } from '../data/content'
import { useReadingPosition } from '../hooks/useReadingPosition'
import { cx } from '../lib/cx'
import { ChevronUpIcon, CloseIcon, PhoneIcon } from './Icons'
const hrefs = sectionNav.map((section) => section.href)
/**
* Wayfinding and the one call to action, moved to the thumb — below 980px,
* which is exactly where the header nav stops being rendered.
*
* The page runs to seventeen screens on a phone. What a reader needs there is
* "where am I" and "how do I ask", and until now the top of the screen carried
* neither: it carried a logo and a button, pinned over the content the whole
* way down. The header gives that width back to the page; this takes over.
*/
export function ThumbBar() {
const { index, progress } = useReadingPosition(hrefs)
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const current = sectionNav[index]
/* Hidden over the hero, which has its own call to action, and again over the
form, where the bar would sit on top of the submit button. */
const visible = index > 0 && current.href !== '#proposal'
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
const { overflow } = document.body.style
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', onKeyDown)
return () => {
document.body.style.overflow = overflow
window.removeEventListener('keydown', onKeyDown)
}
}, [open])
const close = () => {
setOpen(false)
triggerRef.current?.focus()
}
return (
<>
<div
className={cx(
'fixed inset-x-0 bottom-0 z-70 transition-[transform,opacity] duration-300 lg:hidden',
visible ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-full opacity-0',
)}
>
<div className="border-t border-white/[0.1] bg-navy-deep/[0.93] backdrop-blur-[20px]">
<div aria-hidden="true" className="h-[2px] bg-white/[0.08]">
<i className="block h-full bg-teal transition-[width] duration-150" style={{ width: `${progress * 100}%` }} />
</div>
<div className="mx-auto flex w-[min(calc(100%_-_24px),var(--container-page))] items-center gap-[10px] py-[10px]">
<button
ref={triggerRef}
type="button"
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen(true)}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-[10px] rounded-[16px] border border-white/[0.14] bg-white/[0.06]
px-[13px] py-[8px] text-left transition-[background-color,border-color] duration-200 outline-none
hover:bg-white/[0.1] focus-visible:border-teal focus-visible:ring-2 focus-visible:ring-teal/[0.4]"
>
<span className="min-w-0">
<span className="block text-[10px] leading-[1.4] font-[850] tracking-[0.13em] text-teal uppercase">
Раздел {index + 1} / {sectionNav.length}
</span>
<span className="block truncate text-[14px] font-[760] text-white">{current.label}</span>
</span>
<ChevronUpIcon className="ml-auto size-[18px] shrink-0 text-white/[0.5]" />
</button>
<a
href="#proposal"
className="inline-flex min-h-[46px] shrink-0 cursor-pointer items-center rounded-full bg-teal px-[18px] text-[14px]
font-[790] text-navy shadow-[0_12px_32px_rgb(0_196_180/0.28)] transition-colors duration-200 hover:bg-teal-bright"
>
Получить аудит
</a>
</div>
</div>
</div>
{open ? (
<div className="fixed inset-0 z-80 lg:hidden" role="dialog" aria-modal="true" aria-label="Разделы страницы">
<button
type="button"
aria-label="Закрыть список разделов"
onClick={close}
className="veil-in absolute inset-0 size-full cursor-pointer bg-navy-deep/[0.66] backdrop-blur-[3px]"
/>
<div
className="sheet-in absolute inset-x-0 bottom-0 max-h-[86vh] overflow-y-auto rounded-t-[26px] border-t border-white/[0.12]
bg-navy-deep px-[16px] pt-[12px] pb-[20px] shadow-[0_-24px_60px_rgb(3_20_34/0.5)]"
>
<div className="mb-[14px] flex items-center justify-between">
<span className="text-[11px] font-[850] tracking-[0.14em] text-teal uppercase">Разделы страницы</span>
<button
type="button"
onClick={close}
aria-label="Закрыть"
className="grid size-[34px] cursor-pointer place-items-center rounded-full border border-white/[0.14] text-white/[0.6]
transition-colors duration-200 hover:text-white"
>
<CloseIcon className="size-[16px]" />
</button>
</div>
<nav className="grid gap-[2px]">
{sectionNav.map((section, position) => (
<a
key={section.href}
href={section.href}
onClick={close}
aria-current={position === index ? 'true' : undefined}
className={cx(
'flex items-center gap-[14px] rounded-[14px] px-[13px] py-[12px] text-[15px] transition-colors duration-200',
position === index ? 'bg-white/[0.09] font-[780] text-white' : 'text-white/[0.66] hover:text-white',
)}
>
<span
className={cx(
'w-[18px] shrink-0 text-[11px] font-[820] tabular-nums',
position === index ? 'text-teal' : 'text-white/[0.34]',
)}
>
{String(position + 1).padStart(2, '0')}
</span>
{section.label}
</a>
))}
</nav>
<a
href={contacts.phoneHref}
className="mt-[14px] flex min-h-[52px] items-center justify-center gap-[10px] rounded-[16px] border border-white/[0.16]
bg-white/[0.07] text-[15px] font-[760] text-white transition-colors duration-200 hover:bg-white/[0.12]
[&>svg]:size-[18px]"
>
<PhoneIcon />
{contacts.phone}
</a>
</div>
</div>
) : null}
</>
)
}
@@ -4,6 +4,7 @@ import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { whyIcons } from './Icons' import { whyIcons } from './Icons'
import { Container, Section, SectionHead, SmallNote } from './Layout' import { Container, Section, SectionHead, SmallNote } from './Layout'
import { Rail } from './Rail'
export function WhyExoSection() { export function WhyExoSection() {
const photo = useReveal<HTMLDivElement>() const photo = useReveal<HTMLDivElement>()
@@ -41,9 +42,11 @@ export function WhyExoSection() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div <Rail
ref={list.ref} outerRef={list.ref}
className={cx('scroll-cards auto-cols-[minmax(280px,84%)] gap-[10px] md:grid-still', list.revealClass)} wrapperClassName={list.revealClass}
label="Почему Экзо Групп"
className="scroll-cards auto-cols-[minmax(280px,84%)] gap-[10px] md:grid-still"
> >
{whyItems.map((item) => ( {whyItems.map((item) => (
<div <div
@@ -59,7 +62,7 @@ export function WhyExoSection() {
</div> </div>
</div> </div>
))} ))}
</div> </Rail>
<div ref={stats.ref} className={cx('mt-[18px] grid grid-cols-2 gap-[10px] md:grid-cols-4', stats.revealClass)}> <div ref={stats.ref} className={cx('mt-[18px] grid grid-cols-2 gap-[10px] md:grid-cols-4', stats.revealClass)}>
{companyStats.map((stat) => ( {companyStats.map((stat) => (
+19
View File
@@ -18,6 +18,25 @@ export const navLinks = [
{ href: '#launch', label: 'Запуск' }, { href: '#launch', label: 'Запуск' },
] ]
/**
* Every section, in reading order — the list behind the thumb bar below 980px,
* where the header nav above is not shown. It is longer than `navLinks` on
* purpose: the four header links are a summary for a reader who can see the
* whole page at once, this is a map for one scrolling through seventeen
* screens of it.
*/
export const sectionNav = [
{ href: '#top', label: 'Начало' },
{ href: '#market', label: 'Почему сейчас' },
{ href: '#financial-scenarios', label: 'Сценарии выручки' },
{ href: '#project', label: 'Что запускаем' },
{ href: '#programs', label: 'Курсовая модель' },
{ href: '#technology', label: 'Технологии' },
{ href: '#launch', label: 'Запуск' },
{ href: '#why-exo', label: 'Почему Экзо' },
{ href: '#proposal', label: 'Заявка на аудит' },
]
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Первый экран */ /* Первый экран */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react'
/**
* For the few places where a phone needs different markup, not just different
* classes — the launch timeline, which becomes a set of disclosures rather
* than four full-height cards. `query` must be a literal, not a computed
* string.
*/
export function useMediaQuery(query: string) {
const [matches, setMatches] = useState(false)
useEffect(() => {
const list = window.matchMedia(query)
const onChange = () => setMatches(list.matches)
onChange()
list.addEventListener('change', onChange)
return () => list.removeEventListener('change', onChange)
}, [query])
return matches
}
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
/**
* Which section the reader is currently in, plus how far through the page they
* are. Driven by scroll position rather than by an IntersectionObserver: every
* section here is taller than a phone viewport, so the question is not "is this
* visible" but "which one is under the reading line", and a threshold-based
* observer cannot answer that.
*
* `hrefs` must be a stable array — pass a module-level constant.
*/
export function useReadingPosition(hrefs: readonly string[]) {
const [index, setIndex] = useState(0)
const [progress, setProgress] = useState(0)
useEffect(() => {
let frame = 0
const measure = () => {
frame = 0
/* A third of the way down the screen: the line the eye actually reads
from, not the very top edge. */
const line = window.scrollY + window.innerHeight * 0.3
let current = 0
hrefs.forEach((href, position) => {
const element = document.querySelector(href)
if (element && element.getBoundingClientRect().top + window.scrollY <= line) current = position
})
const scrollable = document.documentElement.scrollHeight - window.innerHeight
setIndex(current)
setProgress(scrollable > 0 ? Math.min(1, window.scrollY / scrollable) : 0)
}
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(measure)
}
measure()
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onScroll)
return () => {
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onScroll)
if (frame) cancelAnimationFrame(frame)
}
}, [hrefs])
return { index, progress }
}
+104
View File
@@ -274,6 +274,110 @@
} }
} }
/* ---------------------------------------------------------------------------
Phone.
The scale above is a display scale. At 375px every level of it collapses
onto the same note — a 42px h1 and a 34px h2 set at display tracking, one
after another for seventeen screens, all shouting equally. Below 560px the
whole page steps down half a level and the tracking opens back up (-0.055em
closes Cyrillic counters at 38px in a way it never does at 84px), so the
figures the argument actually rests on — 2,41, +63%, 2,70 млн ₽ — are left
as the loudest thing on the page.
--------------------------------------------------------------------------- */
@media (max-width: 560px) {
:root {
--spacing-section: 54px;
--spacing-section-compact: 46px;
}
/* The phone header is 62px tall, not 78px. */
section[id] {
scroll-margin-top: 66px;
}
h1 {
margin-bottom: 18px;
font-size: 38px;
line-height: 1;
letter-spacing: -0.035em;
/* `balance` is for two or three lines. Over five it starts trimming lines
to lengths it likes and leaves a ragged right edge instead of a filled
column. */
text-wrap: normal;
}
h2 {
margin-bottom: 14px;
font-size: 29px;
line-height: 1.06;
letter-spacing: -0.03em;
}
h3 {
font-size: 19px;
letter-spacing: -0.02em;
}
}
@layer components {
/* The hero scrim runs left-to-right, which on a 375px screen means it runs
across the whole photo: the image is paid for and then buried. On phones
it turns vertical instead, so the equipment reads across the top third and
the copy sits on solid ground below it. */
@media (max-width: 560px) {
.hero-scrim {
background: linear-gradient(
180deg,
/* The header is transparent over the hero and its logo is white; the
wave wall behind it is cream. This first stop is what keeps it
readable. */ rgb(4 24 41 / 0.82) 0%,
rgb(4 24 41 / 0.3) 12%,
rgb(4 24 41 / 0.34) 22%,
rgb(4 24 41 / 0.62) 34%,
rgb(5 28 47 / 0.93) 54%,
rgb(4 22 37 / 0.99) 100%
);
}
/* At 375px `cover` fits the photo by height, so the only part left above
the copy is the strip the room happens to put at the top — ceiling
tiles. Zooming past cover and dropping the frame lands the backlit wave
wall in that strip instead, which is the one thing in this photo worth
showing at 110px tall. */
.hero-photo {
background-size: auto 150%;
background-position: 55% 72%;
}
/* Room for the rail indicator that replaced the "swipe sideways" hints. */
.scroll-cards {
padding-bottom: 12px;
}
}
@keyframes sheet-in {
from {
transform: translateY(16px);
opacity: 0;
}
}
@keyframes veil-in {
from {
opacity: 0;
}
}
.sheet-in {
animation: sheet-in 0.26s cubic-bezier(0.2, 0.7, 0.3, 1);
}
.veil-in {
animation: veil-in 0.2s ease;
}
}
/* Turns a `.scroll-cards` row back into an ordinary grid. Applied with a /* Turns a `.scroll-cards` row back into an ordinary grid. Applied with a
breakpoint prefix, because sections switch over at different widths. */ breakpoint prefix, because sections switch over at different widths. */
@utility grid-still { @utility grid-still {