From 2b7361664ef8ac0dc9d405643c60ff0c43efd361 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 23 Sep 2026 13:34:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(hero):=203D-=D0=BD=D0=B0=D0=BA=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=20=D1=84=D0=BE=D1=82=D0=BE=20=D0=BD=D0=B0=20=D0=B3=D0=BB?= =?UTF-8?q?=D0=B0=D0=B2=D0=BD=D0=BE=D0=B9=20=D0=BF=D0=BE=20=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D1=81=D0=BE=D1=80=D1=83=20(parallax=20+=20=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D0=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/css/main.css | 376 +++++++++++++++++++++++++-- public/js/main.js | 4 + public/js/modules/ui/hero-3d-tilt.js | 64 +++++ 3 files changed, 426 insertions(+), 18 deletions(-) create mode 100644 public/js/modules/ui/hero-3d-tilt.js diff --git a/public/css/main.css b/public/css/main.css index 9c98339..07be3cb 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -252,24 +252,37 @@ body { z-index: -1; pointer-events: none; overflow: hidden; + opacity: var(--mesh-opacity-current, 1); +} + +.page-mesh-bg canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; + opacity: 0.4; /* приглушаем яркость живого шейдера */ +} + +/* Вертикальный градиент-оверлэй: снизу фон закрыт цветом страницы (--bg, + 100% непрозрачности), кверху растворяется — фон становится виден на 100%. + --bg берётся из темы, поэтому на светлой теме верх светлый, на тёмной — тёмный. */ +.page-mesh-bg::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + to top, + var(--bg) 0%, + color-mix(in srgb, var(--bg) 65%, transparent) 35%, + transparent 70% + ); } html[data-mesh-bg="off"] .page-mesh-bg { visibility: hidden; } -.page-mesh-bg::before { - content: ""; - position: absolute; - inset: 0; - background-image: var(--page-bg); - background-repeat: no-repeat; - background-size: 100% 100dvh; - background-position: top center; - background-blend-mode: normal; - opacity: var(--mesh-opacity-current); -} - .mesh-panel { position: fixed; left: max(0.75rem, env(safe-area-inset-left, 0px)); @@ -1317,10 +1330,10 @@ a.fab--secondary:visited { } .hero__eyebrow { - margin: 0 0 0.5rem; + margin: 0 0 0.6rem; font-size: 0.78rem; font-weight: 700; - color: var(--text-muted); + color: color-mix(in srgb, var(--text) 65%, transparent); letter-spacing: 0.14em; text-transform: uppercase; } @@ -2865,7 +2878,6 @@ a.btn--ghost:visited { position: relative; border-radius: var(--radius-card); overflow: hidden; - border: 1px solid var(--line); background: var(--surface); box-shadow: var(--shadow-strong); } @@ -2946,6 +2958,73 @@ a.btn--ghost:visited { z-index: -1; } +/* + * 3D-наклон портрета по курсору (initHero3DTilt). + * perspective уже задан на .hero__visual; здесь — поворот самой карточки, + * параллакс слоёв и мягкий блик под курсором. + */ +.hero__portrait-shell { + --tilt-rx: 0deg; + --tilt-ry: 0deg; + --tilt-px: 50%; + --tilt-py: 36%; + transform: perspective(1300px) rotateX(var(--tilt-rx)) rotateY(var(--tilt-ry)); + transform-style: preserve-3d; + transition: transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1); + will-change: transform; +} + +.hero__portrait-shell--tilting { + transition: none; +} + +.hero__portrait-shell::before { + content: ""; + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + border-radius: inherit; + opacity: 0; + transition: opacity 320ms ease; + background: radial-gradient( + 260px circle at var(--tilt-px) var(--tilt-py), + hsla(0, 0%, 100%, 0.35), + hsla(0, 0%, 100%, 0) 60% + ); + mix-blend-mode: soft-light; +} + +.hero__portrait-shell--tilting::before { + opacity: 1; +} + +/* Параллакс: фоновый слой чуть глубже, motion-слой выше курсора. */ +.hero__portrait-shell--tilting .hero__portrait-layer--bg { + transform: translateZ(-18px) scale(1.06); +} + +.hero__portrait-shell--tilting .hero__portrait-layer--motion { + transform: translateZ(24px); +} + +.hero__portrait-layer { + transition: transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.hero__portrait-shell--tilting .hero__portrait-layer { + transition: none; +} + +@media (prefers-reduced-motion: reduce) { + .hero__portrait-shell { + transform: none; + } + .hero__portrait-shell::before { + display: none; + } +} + html[data-theme="light"] .hero__portrait-shell::after { background: hsla(var(--theme-h), 25%, 24%, 0.28); filter: blur(12px); @@ -5501,7 +5580,7 @@ body.content-edit-shift-active.content-edit-mode-active .content-edit-fab-stack .marquee-grid__cell { margin: 0; flex: 0 0 auto; - opacity: 0.2; + opacity: 0.4; animation: tagFlicker var(--tag-cycle, 15s) ease-in-out infinite; } .marquee-grid__cell .hero__tag { @@ -5516,6 +5595,7 @@ body.content-edit-shift-active.content-edit-mode-active .content-edit-fab-stack font-size: inherit; font-weight: 500; line-height: 1.4; + color: var(--text); } /* Flicker: теги держим на базовом уровне 20%, по очереди (по 3 за раз, вразнобой) вспыхивают до 100%. @@ -5540,8 +5620,8 @@ body.content-edit-shift-active.content-edit-mode-active .content-edit-fab-stack @keyframes tagFlicker { 0% { opacity: 1; } 14% { opacity: 1; } - 19% { opacity: 0.2; } - 95% { opacity: 0.2; } + 19% { opacity: 0.4; } + 95% { opacity: 0.4; } 100% { opacity: 1; } } @@ -5592,6 +5672,266 @@ body.content-edit-shift-active.content-edit-mode-active .content-edit-fab-stack height: 2.5rem; } +/* Резюме: страница /resume/ (article + section/ol/dl/time) */ +.resume-doc { + max-width: 52rem; + margin: 0 auto; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-card); + padding: clamp(1.5rem, 3vw, 2.5rem); +} +.resume-header h1 { + margin: 0 0 0.25rem; +} +/* Верхняя часть документа: flex-колонка, чтобы вертикальные margin не схлопывались */ +.resume-doc__head { + display: flex; + flex-direction: column; +} +/* Список должностей под именем: обычный текст, каждая с новой строки */ +.resume-roles { + line-height: 1.45; + color: var(--text); +} +.resume-header { + display: flex; + gap: 1.5rem; + align-items: flex-start; + margin-bottom: 1.5rem; +} +.resume-photo { + width: auto; + height: 160px; + max-width: 40%; + border-radius: var(--radius-sm); + object-fit: contain; + flex-shrink: 0; + order: 1; + margin-left: auto; +} +@media (max-width: 32rem) { + .resume-photo { + height: 128px; + max-width: 55%; + } +} +@media (max-width: 32rem) { + .resume-header { + flex-direction: column; + align-items: center; + text-align: center; + } + .resume-photo { + width: 100px; + height: 100px; + } +} +/* H2 — раздел документа: самый крупный уровень, акцентный шрифт. Разделение — отступом, не линией */ +.resume-section__title { + margin: 4.5rem 0 1.5rem; + font-family: var(--font-accent); + font-size: clamp(1.3rem, 2.4vw, 1.6rem); + font-weight: 700; + line-height: 1.2; + color: var(--text); + letter-spacing: -0.015em; +} +.resume-doc .section__prose + .section__prose { + margin-top: 0.75rem; +} +.resume-doc .section__prose a { + color: var(--accent); +} +.resume-meta { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem 3rem; + margin: 1.5rem 0; + font-size: 0.92rem; + color: var(--text); +} +@media (max-width: 32rem) { + .resume-meta { + grid-template-columns: 1fr; + } +} +.resume-meta dt { + font-size: 0.8rem; + font-weight: 600; + color: var(--text); + letter-spacing: 0.03em; + margin-bottom: 0.1rem; +} +.resume-meta dd { + margin: 0; +} +.resume-meta a { + color: var(--accent); +} +.resume-exp { + margin: 3rem 0; + padding-bottom: 0; +} +.resume-exp__header { + margin-bottom: 0.5rem; +} +/* H3 — компания: второй уровень. Без акцентного цвета, чтобы не мимикрировать под ссылку */ +.resume-exp__company { + margin: 0; + font-family: var(--font-accent); + font-weight: 700; + font-size: clamp(1.05rem, 1.6vw, 1.2rem); + line-height: 1.25; + color: var(--text); + letter-spacing: -0.01em; +} +.resume-exp__meta-row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: var(--text-muted); + margin-top: 0.25rem; +} +.resume-exp__meta-row a { + color: var(--accent); + text-decoration: none; + border-bottom: 1px solid var(--accent); + transition: opacity 0.2s; +} +.resume-exp__meta-row a:hover { + opacity: 0.7; +} +.resume-exp__sep { + color: var(--line); +} +.resume-exp__meta-row time { + white-space: nowrap; +} +.resume-exp__period { + font-size: 0.8rem; + color: var(--text-muted); + letter-spacing: 0.02em; +} +.resume-exp__desc { + color: var(--text); + font-size: 0.92rem; + margin: 0.5rem 0; + line-height: 1.55; +} +.resume-exp__section { + margin: 1.5rem 0; +} +.resume-exp__section:last-child { + margin-bottom: 0; +} +/* Разделитель-отступ между проектами внутри одного места работы */ +.resume-exp__divider { + height: 1.5rem; +} +.resume-exp__cols { + display: grid; + grid-template-columns: 1fr 1fr 10rem; + gap: 0 1.5rem; +} +@media (max-width: 48rem) { + .resume-exp__cols { + grid-template-columns: 1fr; + } +} +/* H4 — продукт внутри компании: мельче H3, со сдвигом и направляющей (визуальная вложенность) */ +.resume-exp__section { + padding-left: 0.9rem; + border-left: 2px solid var(--line); +} +.resume-exp__section-title { + margin: 0 0 0.5rem; + font-weight: 600; + font-size: clamp(0.88rem, 1.1vw, 0.95rem); + line-height: 1.35; + color: var(--text); + letter-spacing: 0.01em; +} +/* Метки колонок (H4/H5): тот же визуальный стиль, что у .resume-skill__title */ +.resume-exp__label { + font-family: inherit; + font-weight: 600; + color: var(--text); + font-size: 0.85rem; + letter-spacing: 0.02em; + text-transform: none; + margin: 0.75rem 0 0.4rem; +} +/* H4-метка (без продуктов) — тот же размер: различие только по уровню заголовка */ +h4.resume-exp__label { + font-size: 0.85rem; +} +.resume-exp-list { + list-style: none; + margin: 0; + padding: 0; +} +.resume-exp ul { + margin: 0; + padding-left: 1.1rem; + color: var(--text); + font-size: 0.92rem; + line-height: 1.55; +} +/* Буллеты: залитая точка, цвет как у текста */ +.resume-exp ul { + list-style: none; +} +.resume-exp ul li { + position: relative; + padding-left: 0.85rem; +} +.resume-exp ul li::before { + content: ""; + position: absolute; + left: 0; + top: 0.62em; + width: 0.3rem; + height: 0.3rem; + border-radius: 50%; + background: var(--text); +} +.resume-exp ul li + li { + margin-top: 0.6rem; +} +.resume-skills { + margin: 1rem 0; + display: flex; + flex-direction: column; + gap: 1rem; +} +.resume-skill { + display: flex; + align-items: baseline; + gap: 0.75rem; + font-size: 0.92rem; + line-height: 1.55; +} +.resume-skill__title { + font-weight: 600; + color: var(--text); + min-width: 8rem; + flex-shrink: 0; + font-size: 0.85rem; + letter-spacing: 0.02em; + margin: 0; +} +.resume-skill__desc { + color: var(--text); + margin: 0; +} +.resume-about { + margin: 1.5rem 0; + font-size: 0.92rem; + color: var(--text); + line-height: 1.55; +} /* Слайдер изображений в лонгриде кейса: одна картинка + стрелки + точки */ .case-media-slider { --slider-gap: 0; diff --git a/public/js/main.js b/public/js/main.js index f3f0303..41acf56 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -17,6 +17,8 @@ import { initContactCopy } from "./modules/ui/contact-copy.js"; import { initCookieNotice } from "./modules/ui/cookie-notice.js"; import { initHeroWordSlot } from "./modules/ui/hero-word-slot.js"; import { initHeroMotionVideo } from "./modules/ui/hero-motion-video.js"; +import { initHero3DTilt } from "./modules/ui/hero-3d-tilt.js"; +import { initPageMeshShader } from "./modules/ui/page-mesh-shader.js"; import { initPageMeshBackground } from "./modules/ui/page-mesh-bg.js"; import { initPrimaryButtonShine } from "./modules/ui/primary-button-shine.js"; import { initProjectsCarousel } from "./modules/ui/projects-carousel.js"; @@ -44,6 +46,8 @@ initNavOverflow(); initCookieNotice(); initHeroWordSlot(); initHeroMotionVideo(); +initHero3DTilt(); +initPageMeshShader(); initPageMeshBackground(); initExperienceExpand(experienceMasonry); initMiniProjectsExpand(miniProjectsMasonry); diff --git a/public/js/modules/ui/hero-3d-tilt.js b/public/js/modules/ui/hero-3d-tilt.js new file mode 100644 index 0000000..76b39da --- /dev/null +++ b/public/js/modules/ui/hero-3d-tilt.js @@ -0,0 +1,64 @@ +// 3D-наклон портрета на главной по движению курсора. +// Эффект "парящей карточки": карточка поворачивается к курсору, +// слои дают параллакс, поверх ложится мягкий блик. + +const MAX_TILT = 9; // градусов по каждой оси +const GLIDE = 320; // ms возврата в покой + +function isTouchOrReduced() { + const touch = window.matchMedia("(hover: none), (pointer: coarse)").matches; + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + return touch || reduced; +} + +export function initHero3DTilt() { + const shell = document.querySelector(".hero__portrait-shell"); + if (!shell || isTouchOrReduced()) return; + + let rafId = null; + let targetX = 0; + let targetY = 0; + let currentX = 0; + let currentY = 0; + let active = false; + + function onMove(e) { + const rect = shell.getBoundingClientRect(); + const px = (e.clientX - rect.left) / rect.width; // 0..1 + const py = (e.clientY - rect.top) / rect.height; // 0..1 + targetY = (px - 0.5) * 2 * MAX_TILT; // rotateY + targetX = (0.5 - py) * 2 * MAX_TILT; // rotateX + shell.style.setProperty("--tilt-px", `${px * 100}%`); + shell.style.setProperty("--tilt-py", `${py * 100}%`); + if (!active) { + active = true; + shell.classList.add("hero__portrait-shell--tilting"); + } + if (!rafId) rafId = requestAnimationFrame(tick); + } + + function tick() { + currentX += (targetX - currentX) * 0.18; + currentY += (targetY - currentY) * 0.18; + shell.style.setProperty("--tilt-rx", `${currentX.toFixed(2)}deg`); + shell.style.setProperty("--tilt-ry", `${currentY.toFixed(2)}deg`); + const done = + Math.abs(targetX - currentX) < 0.01 && Math.abs(targetY - currentX) < 0.01; + if (done && !active) { + rafId = null; + } else { + rafId = requestAnimationFrame(tick); + } + } + + function onLeave() { + active = false; + targetX = 0; + targetY = 0; + shell.classList.remove("hero__portrait-shell--tilting"); + if (!rafId) rafId = requestAnimationFrame(tick); + } + + shell.addEventListener("mousemove", onMove); + shell.addEventListener("mouseleave", onLeave); +}