feat(hero): 3D-наклон фото на главной по курсору (parallax + блик)

This commit is contained in:
root committed 2026-09-23 13:34:17 +02:00
1 parent b6161c92cd
commit 2b7361664e
3 files changed
+426 -18

No files matched your search

+358 -18
View File
@@ -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;
+4
View File
@@ -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);
+64
View File
@@ -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);
}