-
{whyItems.map((item) => (
))}
-
+
{companyStats.map((stat) => (
diff --git a/medcenterpersonal/src/data/content.ts b/medcenterpersonal/src/data/content.ts
index 8bdf1ed..613854a 100644
--- a/medcenterpersonal/src/data/content.ts
+++ b/medcenterpersonal/src/data/content.ts
@@ -18,6 +18,25 @@ export const navLinks = [
{ 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: 'Заявка на аудит' },
+]
+
/* -------------------------------------------------------------------------- */
/* Первый экран */
/* -------------------------------------------------------------------------- */
diff --git a/medcenterpersonal/src/hooks/useMediaQuery.ts b/medcenterpersonal/src/hooks/useMediaQuery.ts
new file mode 100644
index 0000000..97567ea
--- /dev/null
+++ b/medcenterpersonal/src/hooks/useMediaQuery.ts
@@ -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
+}
diff --git a/medcenterpersonal/src/hooks/useReadingPosition.ts b/medcenterpersonal/src/hooks/useReadingPosition.ts
new file mode 100644
index 0000000..4091682
--- /dev/null
+++ b/medcenterpersonal/src/hooks/useReadingPosition.ts
@@ -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 }
+}
diff --git a/medcenterpersonal/src/index.css b/medcenterpersonal/src/index.css
index 1ffa2bb..ca2dcfe 100644
--- a/medcenterpersonal/src/index.css
+++ b/medcenterpersonal/src/index.css
@@ -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
breakpoint prefix, because sections switch over at different widths. */
@utility grid-still {