From 6bdc7eea9bb00df249ee382051c8c720fbdb56ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Fri, 22 May 2026 22:09:47 +0800 Subject: [PATCH] feat: enhance resume interactions --- src/pages/index.astro | 356 +++++++++++++++++++++++++++++++++++++----- src/styles/global.css | 136 ++++++++++++++-- 2 files changed, 443 insertions(+), 49 deletions(-) diff --git a/src/pages/index.astro b/src/pages/index.astro index e6a54a4..22a3f2d 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -231,10 +231,6 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;

{displayNameFor(resume)}

{resume.intent}

-
-
{labels.zh.phone}
-
{resume.contact.phone}
-
{labels.zh.email}
@@ -604,11 +599,8 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; setText("rail-label", text.candidate); setText("rail-name", name); setText("rail-intent", data.intent); - setText("phone-label", text.phone); setText("email-label", text.email); setText("education-label", text.education); - setText("rail-phone", data.contact.phone); - $("rail-phone").href = `tel:${data.contact.phone}`; setText("rail-email", data.contact.email); $("rail-email").href = `mailto:${data.contact.email}`; setText("rail-email-alt", data.contact.emailAlt); @@ -632,8 +624,6 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; $("send-email").href = `mailto:${data.contact.email}`; setText("contact-email-alt", data.contact.emailAlt); $("contact-email-alt").href = `mailto:${data.contact.emailAlt}`; - setText("contact-phone", data.contact.phone); - $("contact-phone").href = `tel:${data.contact.phone}`; setText("footer-text", `© 2026 ${name}. ${text.footer}`); renderBasics(data); @@ -687,6 +677,10 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; ty: window.innerHeight * 0.42, speed: 0, }; + const trail = []; + const bursts = []; + const tokens = []; + const tokenLexicon = ["AI", "SSE", "Agent", "LLM", "RAG", "Vision", "Next", "RN", "3D"]; let width = 0; let height = 0; let dpr = 1; @@ -698,19 +692,32 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; ? { dot: "rgba(139, 180, 255, 0.55)", dotAlt: "rgba(99, 215, 155, 0.5)", - line: "rgba(244, 239, 230, 0.12)", + dotHot: "rgba(255, 138, 168, 0.64)", + line: "rgba(244, 239, 230, 0.11)", + lineActive: "rgba(139, 180, 255, 0.48)", cursor: "rgba(240, 179, 90, 0.34)", + trail: "rgba(139, 180, 255, 0.58)", + trailFast: "rgba(255, 138, 168, 0.62)", + burst: "rgba(240, 179, 90, 0.68)", + token: "rgba(244, 239, 230, 0.7)", } : { dot: "rgba(47, 95, 190, 0.5)", dotAlt: "rgba(22, 133, 94, 0.45)", - line: "rgba(23, 23, 23, 0.11)", + dotHot: "rgba(180, 60, 100, 0.55)", + line: "rgba(23, 23, 23, 0.1)", + lineActive: "rgba(47, 95, 190, 0.42)", cursor: "rgba(167, 101, 22, 0.28)", + trail: "rgba(47, 95, 190, 0.5)", + trailFast: "rgba(180, 60, 100, 0.52)", + burst: "rgba(167, 101, 22, 0.52)", + token: "rgba(23, 23, 23, 0.58)", }; } function seedParticles() { - const count = Math.min(86, Math.max(38, Math.floor((width * height) / 17000))); + const compact = width < 760; + const count = Math.min(compact ? 58 : 96, Math.max(compact ? 30 : 44, Math.floor((width * height) / (compact ? 23000 : 15500)))); particles = Array.from({ length: count }, (_, index) => ({ x: Math.random() * width, y: Math.random() * height, @@ -718,6 +725,7 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; vy: (Math.random() - 0.5) * 0.24, size: 0.8 + Math.random() * 1.8, hue: index % 3, + pulse: 0, })); } @@ -734,10 +742,146 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; function onPointerMove(event) { const dx = event.clientX - pointer.tx; const dy = event.clientY - pointer.ty; + const speed = Math.min(1, Math.hypot(dx, dy) / 80); pointer.tx = event.clientX; pointer.ty = event.clientY; - pointer.speed = Math.min(1, Math.hypot(dx, dy) / 80); + pointer.speed = Math.max(pointer.speed, speed); pointer.active = true; + if (Math.hypot(dx, dy) > 2) { + trail.push({ x: pointer.tx, y: pointer.ty, speed, life: 1 }); + if (trail.length > (width < 760 ? 18 : 28)) trail.shift(); + } + } + + function emitBurst(x, y, power = 1) { + const compact = width < 760; + bursts.push({ x, y, age: 0, life: compact ? 42 : 54, power }); + if (bursts.length > 8) bursts.shift(); + + particles.forEach((particle) => { + const dx = particle.x - x; + const dy = particle.y - y; + const dist = Math.hypot(dx, dy); + if (dist < 260) { + const force = (1 - dist / 260) * (1.7 + power); + particle.vx += (dx / (dist || 1)) * force * 0.58; + particle.vy += (dy / (dist || 1)) * force * 0.58; + particle.pulse = 1; + } + }); + + const tokenCount = compact ? 4 : 7; + for (let index = 0; index < tokenCount; index += 1) { + const angle = Math.random() * Math.PI * 2; + const velocity = 1.15 + Math.random() * 2.15; + tokens.push({ + text: tokenLexicon[Math.floor(Math.random() * tokenLexicon.length)], + x, + y, + vx: Math.cos(angle) * velocity, + vy: Math.sin(angle) * velocity - 0.35, + age: 0, + life: 34 + Math.random() * 24, + size: 9 + Math.random() * 4, + }); + } + if (tokens.length > 42) tokens.splice(0, tokens.length - 42); + } + + function onPointerDown(event) { + onPointerMove(event); + emitBurst(event.clientX, event.clientY, 0.75 + pointer.speed); + if (event.target instanceof Element) { + const target = event.target.closest( + "a, button, .project-row, .map-node, .strength-grid article, .domain-strip article, .skill-list span, .tech-line span", + ); + if (target) { + target.classList.add("is-pressed"); + window.setTimeout(() => target.classList.remove("is-pressed"), 420); + } + } + } + + function drawTrail(colors) { + for (let index = trail.length - 1; index >= 0; index -= 1) { + trail[index].life *= 0.935; + if (trail[index].life < 0.035) trail.splice(index, 1); + } + if (trail.length < 2) return; + + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + for (let index = 1; index < trail.length; index += 1) { + const previous = trail[index - 1]; + const point = trail[index]; + const strength = Math.min(previous.life, point.life) * (index / trail.length); + ctx.globalAlpha = strength * (0.34 + point.speed * 0.42); + ctx.strokeStyle = point.speed > 0.46 ? colors.trailFast : colors.trail; + ctx.lineWidth = 1.1 + point.speed * 2.4 + (index / trail.length) * 1.2; + ctx.beginPath(); + ctx.moveTo(previous.x, previous.y); + ctx.lineTo(point.x, point.y); + ctx.stroke(); + } + ctx.globalAlpha = 1; + } + + function drawBursts(colors) { + bursts.forEach((burst) => { + const t = burst.age / burst.life; + const fade = Math.max(0, 1 - t); + for (let ring = 0; ring < 3; ring += 1) { + const radius = 10 + t * (62 + ring * 28) * burst.power + ring * 9; + ctx.globalAlpha = fade * (0.38 - ring * 0.08); + ctx.strokeStyle = ring === 1 ? colors.trail : colors.burst; + ctx.lineWidth = 1.2 + ring * 0.35; + ctx.beginPath(); + ctx.arc(burst.x, burst.y, radius, 0, Math.PI * 2); + ctx.stroke(); + } + + ctx.globalAlpha = fade * 0.34; + ctx.strokeStyle = colors.burst; + ctx.lineWidth = 1; + for (let spoke = 0; spoke < 10; spoke += 1) { + const angle = (Math.PI * 2 * spoke) / 10 + t * 0.7; + const inner = 20 + t * 46; + const outer = inner + 12 + burst.power * 8; + ctx.beginPath(); + ctx.moveTo(burst.x + Math.cos(angle) * inner, burst.y + Math.sin(angle) * inner); + ctx.lineTo(burst.x + Math.cos(angle) * outer, burst.y + Math.sin(angle) * outer); + ctx.stroke(); + } + + burst.age += 1; + }); + for (let index = bursts.length - 1; index >= 0; index -= 1) { + if (bursts[index].age > bursts[index].life) bursts.splice(index, 1); + } + ctx.globalAlpha = 1; + } + + function drawTokens(colors) { + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + tokens.forEach((token) => { + const t = token.age / token.life; + const fade = Math.max(0, 1 - t); + token.x += token.vx; + token.y += token.vy; + token.vx *= 0.985; + token.vy = token.vy * 0.985 - 0.004; + token.age += 1; + + ctx.globalAlpha = fade * 0.72; + ctx.fillStyle = colors.token; + ctx.font = `700 ${token.size}px Inter, ui-sans-serif, system-ui, sans-serif`; + ctx.fillText(token.text, token.x, token.y); + }); + for (let index = tokens.length - 1; index >= 0; index -= 1) { + if (tokens[index].age > tokens[index].life) tokens.splice(index, 1); + } + ctx.globalAlpha = 1; } function draw() { @@ -746,19 +890,21 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; pointer.x += (pointer.tx - pointer.x) * 0.08; pointer.y += (pointer.ty - pointer.y) * 0.08; pointer.speed *= 0.92; + drawTrail(colors); particles.forEach((particle, index) => { const dx = particle.x - pointer.x; const dy = particle.y - pointer.y; const dist = Math.hypot(dx, dy); - if (pointer.active && dist < 150) { - const force = (1 - dist / 150) * (0.55 + pointer.speed); + if (pointer.active && dist < 172) { + const force = (1 - dist / 172) * (0.62 + pointer.speed); particle.vx += (dx / (dist || 1)) * force * 0.018; particle.vy += (dy / (dist || 1)) * force * 0.018; } particle.vx *= 0.988; particle.vy *= 0.988; + particle.pulse *= 0.92; particle.x += particle.vx; particle.y += particle.vy; @@ -770,10 +916,10 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; for (let next = index + 1; next < particles.length; next += 1) { const other = particles[next]; const lineDistance = Math.hypot(particle.x - other.x, particle.y - other.y); - if (lineDistance < 112) { - ctx.globalAlpha = (1 - lineDistance / 112) * 0.46; - ctx.strokeStyle = colors.line; - ctx.lineWidth = 1; + if (lineDistance < 118) { + ctx.globalAlpha = (1 - lineDistance / 118) * (0.36 + particle.pulse * 0.25 + other.pulse * 0.25); + ctx.strokeStyle = particle.pulse + other.pulse > 0.2 ? colors.lineActive : colors.line; + ctx.lineWidth = 0.9 + Math.max(particle.pulse, other.pulse) * 0.8; ctx.beginPath(); ctx.moveTo(particle.x, particle.y); ctx.lineTo(other.x, other.y); @@ -781,19 +927,38 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; } } - ctx.globalAlpha = 0.58; - ctx.fillStyle = particle.hue === 1 ? colors.dotAlt : colors.dot; + if (pointer.active && dist < 198) { + ctx.globalAlpha = (1 - dist / 198) * (0.2 + pointer.speed * 0.38); + ctx.strokeStyle = particle.hue === 2 ? colors.trailFast : colors.lineActive; + ctx.lineWidth = 0.8 + pointer.speed * 1.2; + ctx.beginPath(); + ctx.moveTo(pointer.x, pointer.y); + ctx.lineTo(particle.x, particle.y); + ctx.stroke(); + } + + ctx.globalAlpha = 0.5 + particle.pulse * 0.28; + ctx.fillStyle = particle.pulse > 0.35 ? colors.dotHot : particle.hue === 1 ? colors.dotAlt : colors.dot; ctx.beginPath(); - ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + ctx.arc(particle.x, particle.y, particle.size + particle.pulse * 1.4, 0, Math.PI * 2); ctx.fill(); }); + drawBursts(colors); + drawTokens(colors); + if (pointer.active) { - ctx.globalAlpha = 0.45; + ctx.globalAlpha = 0.4 + pointer.speed * 0.16; ctx.strokeStyle = colors.cursor; - ctx.lineWidth = 1; + ctx.lineWidth = 1 + pointer.speed * 0.8; ctx.beginPath(); - ctx.arc(pointer.x, pointer.y, 26 + pointer.speed * 18, 0, Math.PI * 2); + ctx.arc(pointer.x, pointer.y, 24 + pointer.speed * 24, 0, Math.PI * 2); + ctx.stroke(); + + ctx.globalAlpha = 0.26 + pointer.speed * 0.2; + ctx.strokeStyle = colors.trail; + ctx.beginPath(); + ctx.arc(pointer.x, pointer.y, 7 + pointer.speed * 7, 0.3, Math.PI * 1.45); ctx.stroke(); } ctx.globalAlpha = 1; @@ -802,7 +967,7 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; window.addEventListener("resize", resize); window.addEventListener("pointermove", onPointerMove, { passive: true }); - window.addEventListener("pointerdown", onPointerMove, { passive: true }); + window.addEventListener("pointerdown", onPointerDown, { passive: true }); document.addEventListener("pointerleave", () => { pointer.active = false; }); @@ -820,11 +985,14 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; } const pointer = { active: false, x: 0, y: 0 }; + const pulses = []; const travelers = Array.from({ length: 9 }, (_, index) => ({ edge: index % 4, progress: Math.random(), speed: 0.002 + Math.random() * 0.0025, })); + let routeFocus = -1; + let routePulse = 0; let width = 0; let height = 0; let dpr = 1; @@ -835,12 +1003,16 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; ? { line: "rgba(244, 239, 230, 0.22)", active: "rgba(139, 180, 255, 0.55)", + route: "rgba(240, 179, 90, 0.66)", pulse: "rgba(99, 215, 155, 0.86)", + glow: "rgba(139, 180, 255, 0.16)", } : { line: "rgba(23, 23, 23, 0.18)", active: "rgba(47, 95, 190, 0.52)", + route: "rgba(167, 101, 22, 0.58)", pulse: "rgba(22, 133, 94, 0.78)", + glow: "rgba(47, 95, 190, 0.14)", }; } @@ -859,49 +1031,122 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; return [...map.querySelectorAll(".map-node")].map((node) => { const rect = node.getBoundingClientRect(); return { + element: node, main: node.classList.contains("map-node-main"), + route: node.dataset.mapNode === undefined ? -1 : Number(node.dataset.mapNode), x: rect.left - mapRect.left + rect.width / 2, y: rect.top - mapRect.top + rect.height / 2, }; }); } - function pointOnEdge(start, end, progress) { + function curveControl(start, end) { + const horizontal = end.x < start.x ? -1 : 1; + const vertical = end.y < start.y ? -1 : 1; return { - x: start.x + (end.x - start.x) * progress, - y: start.y + (end.y - start.y) * progress, + x: (start.x + end.x) / 2 + horizontal * 18, + y: (start.y + end.y) / 2 + vertical * 12, }; } + function pointOnEdge(start, end, progress) { + const control = curveControl(start, end); + const inverse = 1 - progress; + return { + x: inverse * inverse * start.x + 2 * inverse * progress * control.x + progress * progress * end.x, + y: inverse * inverse * start.y + 2 * inverse * progress * control.y + progress * progress * end.y, + }; + } + + function strokeRoute(start, end, color, widthValue, alpha) { + const control = curveControl(start, end); + ctx.globalAlpha = alpha; + ctx.strokeStyle = color; + ctx.lineWidth = widthValue; + ctx.beginPath(); + ctx.moveTo(start.x, start.y); + ctx.quadraticCurveTo(control.x, control.y, end.x, end.y); + ctx.stroke(); + ctx.globalAlpha = 1; + } + + function setRouteFocus(route, node) { + routeFocus = route; + routePulse = 1; + map.querySelectorAll(".map-node.is-active-route").forEach((item) => item.classList.remove("is-active-route")); + map.querySelector(".map-node-main")?.classList.add("is-active-route"); + node?.classList.add("is-active-route"); + window.setTimeout(() => { + if (routeFocus === route) { + map.querySelectorAll(".map-node.is-active-route").forEach((item) => item.classList.remove("is-active-route")); + } + }, 760); + } + + function focusNearestRoute() { + const nodes = centers(); + const targets = nodes.filter((node) => !node.main); + let nearest = null; + let nearestDistance = Infinity; + targets.forEach((target) => { + const distance = Math.hypot(pointer.x - target.x, pointer.y - target.y); + if (distance < nearestDistance) { + nearest = target; + nearestDistance = distance; + } + }); + if (nearest && nearestDistance < 180) setRouteFocus(nearest.route, nearest.element); + } + function draw() { const colors = palette(); const nodes = centers(); const main = nodes.find((node) => node.main) || nodes[0]; const targets = nodes.filter((node) => node !== main); + routePulse *= 0.94; ctx.clearRect(0, 0, width, height); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; targets.forEach((target) => { const distanceToPointer = pointer.active ? Math.hypot(pointer.x - target.x, pointer.y - target.y) : 999; - ctx.strokeStyle = distanceToPointer < 135 ? colors.active : colors.line; - ctx.lineWidth = distanceToPointer < 135 ? 1.6 : 1; - ctx.beginPath(); - ctx.moveTo(main.x, main.y); - ctx.lineTo(target.x, target.y); - ctx.stroke(); + const hoverIntensity = distanceToPointer < 135 ? 1 - distanceToPointer / 135 : 0; + const focusIntensity = routeFocus === target.route ? routePulse : 0; + const intensity = Math.max(hoverIntensity, focusIntensity); + if (intensity > 0.08) { + strokeRoute(main, target, colors.glow, 6 + intensity * 5, intensity * 0.72); + } + strokeRoute( + main, + target, + focusIntensity > hoverIntensity ? colors.route : intensity > 0 ? colors.active : colors.line, + 1 + intensity * 1.4, + 0.82 + intensity * 0.18, + ); }); travelers.forEach((traveler) => { if (!targets.length) return; const target = targets[traveler.edge % targets.length]; - traveler.progress += traveler.speed; + const focusBoost = routeFocus === target.route ? 1 + routePulse * 3.2 : 1; + traveler.progress += traveler.speed * focusBoost; if (traveler.progress > 1) { traveler.progress = 0; traveler.edge = (traveler.edge + 1) % targets.length; } const point = pointOnEdge(main, target, traveler.progress); + const isFocused = routeFocus === target.route && routePulse > 0.08; + if (isFocused) { + ctx.globalAlpha = routePulse * 0.26; + ctx.fillStyle = colors.route; + ctx.beginPath(); + ctx.arc(point.x, point.y, 7.5, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; ctx.fillStyle = colors.pulse; ctx.beginPath(); - ctx.arc(point.x, point.y, 2.2, 0, Math.PI * 2); + ctx.arc(point.x, point.y, isFocused ? 3 : 2.2, 0, Math.PI * 2); ctx.fill(); }); @@ -909,8 +1154,10 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; targets.forEach((target) => { const distance = Math.hypot(pointer.x - target.x, pointer.y - target.y); if (distance < 170) { - ctx.globalAlpha = 1 - distance / 170; + const alpha = 1 - distance / 170; + ctx.globalAlpha = alpha; ctx.strokeStyle = colors.active; + ctx.lineWidth = 1 + alpha; ctx.beginPath(); ctx.moveTo(pointer.x, pointer.y); ctx.lineTo(target.x, target.y); @@ -920,6 +1167,22 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; ctx.globalAlpha = 1; } + pulses.forEach((pulse) => { + const t = pulse.age / pulse.life; + const fade = Math.max(0, 1 - t); + ctx.globalAlpha = fade * 0.58; + ctx.strokeStyle = colors.route; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.arc(pulse.x, pulse.y, 10 + t * 42, 0, Math.PI * 2); + ctx.stroke(); + pulse.age += 1; + }); + for (let index = pulses.length - 1; index >= 0; index -= 1) { + if (pulses[index].age > pulses[index].life) pulses.splice(index, 1); + } + ctx.globalAlpha = 1; + requestAnimationFrame(draw); } @@ -932,6 +1195,21 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null; map.addEventListener("pointerleave", () => { pointer.active = false; }); + map.addEventListener("pointerdown", (event) => { + const rect = map.getBoundingClientRect(); + pointer.x = event.clientX - rect.left; + pointer.y = event.clientY - rect.top; + pointer.active = true; + pulses.push({ x: pointer.x, y: pointer.y, age: 0, life: 34 }); + if (pulses.length > 6) pulses.shift(); + focusNearestRoute(); + }); + map.querySelectorAll(".map-node").forEach((node) => { + node.addEventListener("pointerenter", () => { + if (node.dataset.mapNode === undefined) return; + setRouteFocus(Number(node.dataset.mapNode), node); + }); + }); window.addEventListener("resize", resize); if ("ResizeObserver" in window) new ResizeObserver(resize).observe(map); resize(); diff --git a/src/styles/global.css b/src/styles/global.css index f67b5ab..4c5d875 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -189,6 +189,7 @@ a { text-decoration: none; font-size: 0.82rem; font-weight: 750; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, box-shadow 180ms ease; } .topbar-actions { @@ -213,6 +214,7 @@ a { font: inherit; font-size: 0.8rem; font-weight: 800; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, box-shadow 180ms ease; } .github-link { @@ -234,6 +236,7 @@ a { .github-link:hover, .control-button:hover, .topbar-cta:hover { + transform: translateY(-1px); border-color: var(--ink); background: var(--button-hover); } @@ -309,7 +312,7 @@ main { text-decoration: none; font-size: 0.88rem; font-weight: 750; - transition: transform 180ms ease, background 180ms ease, border-color 180ms ease; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, box-shadow 180ms ease; } .button:hover { @@ -468,6 +471,21 @@ main { font-weight: 800; box-shadow: 0 10px 24px rgba(23, 23, 23, 0.06); backdrop-filter: blur(8px); + transition: border-color 180ms ease, box-shadow 180ms ease, filter 180ms ease; +} + +.map-node.is-active-route { + border-color: color-mix(in srgb, var(--blue) 58%, var(--ink)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--blue) 24%, transparent), + 0 14px 34px color-mix(in srgb, var(--blue) 18%, transparent); + filter: saturate(1.16); +} + +.map-node-main.is-active-route { + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--amber) 38%, transparent), + 0 18px 42px color-mix(in srgb, var(--amber) 20%, transparent); } .map-node-main { @@ -661,6 +679,13 @@ main { border-radius: var(--radius); background: var(--surface); backdrop-filter: blur(10px); + transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease; +} + +.strength-grid article:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--green) 42%, var(--line-strong)); + box-shadow: 0 16px 34px rgba(23, 23, 23, 0.07); } .strength-grid span { @@ -713,6 +738,14 @@ main { color: var(--muted); font-size: 0.76rem; font-weight: 700; + transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, color 160ms ease; +} + +.skill-list span:hover, +.tech-line span:hover { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--blue) 42%, var(--line)); + color: var(--ink); } .with-action { @@ -735,10 +768,12 @@ main { font: inherit; font-size: 0.76rem; font-weight: 800; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, box-shadow 180ms ease; } .filter-tabs button:hover, .filter-tabs button.active { + transform: translateY(-1px); border-color: var(--ink); background: var(--ink); color: var(--paper); @@ -758,6 +793,13 @@ main { overflow: hidden; box-shadow: 0 16px 36px rgba(23, 23, 23, 0.07); backdrop-filter: blur(12px); + transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease; +} + +.project-row:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--blue) 42%, var(--line-strong)); + box-shadow: 0 20px 42px rgba(23, 23, 23, 0.09); } .project-row[hidden] { @@ -893,6 +935,13 @@ main { linear-gradient(135deg, rgba(31, 143, 95, 0.14), transparent 48%), var(--surface-strong); backdrop-filter: blur(10px); + transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease; +} + +.domain-strip article:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--amber) 46%, var(--line-strong)); + box-shadow: 0 16px 34px rgba(23, 23, 23, 0.07); } .domain-strip .brand-logo { @@ -908,11 +957,11 @@ main { .contact-section { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 1.2rem; - align-items: center; + grid-template-columns: minmax(0, 0.92fr) minmax(280px, 0.68fr); + gap: clamp(1.2rem, 4vw, 2.2rem); + align-items: stretch; margin-top: 3.2rem; - padding: clamp(1.1rem, 3.4vw, 1.6rem); + padding: clamp(1.2rem, 3.6vw, 2rem); border: 1px solid rgba(23, 23, 23, 0.82); border-radius: var(--radius); background: var(--ink); @@ -920,17 +969,46 @@ main { box-shadow: var(--shadow); } +.contact-section > div:first-child { + display: grid; + align-content: start; +} + +.contact-section h2 { + max-width: 440px; + font-size: clamp(1.65rem, 3.2vw, 2.55rem); + line-height: 1.1; +} + .contact-section span { display: block; margin-top: 0.62rem; - color: rgba(244, 241, 234, 0.64); + color: color-mix(in srgb, currentColor 62%, transparent); font-size: 0.88rem; } +.contact-actions { + display: grid; + gap: 0.6rem; + align-content: center; + min-width: 0; + margin-top: 0; + padding: 0.78rem; + border: 1px solid color-mix(in srgb, currentColor 14%, transparent); + border-radius: var(--radius); + background: color-mix(in srgb, currentColor 5%, transparent); +} + .contact-section .button { - border-color: rgba(244, 241, 234, 0.3); - background: transparent; - color: var(--paper); + width: 100%; + min-height: 40px; + justify-content: flex-start; + padding-inline: 0.82rem; + border-color: color-mix(in srgb, currentColor 16%, transparent); + border-radius: var(--radius); + background: color-mix(in srgb, currentColor 4%, transparent); + color: currentColor; + font-size: 0.82rem; } .contact-section .button-primary { @@ -957,6 +1035,29 @@ main { transform: translateY(0); } +.is-pressed { + animation: press-feedback 420ms ease; +} + +@keyframes press-feedback { + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--amber) 36%, transparent); + filter: saturate(1); + } + + 44% { + box-shadow: + 0 0 0 5px color-mix(in srgb, var(--amber) 20%, transparent), + 0 0 22px color-mix(in srgb, var(--blue) 18%, transparent); + filter: saturate(1.18); + } + + 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--amber) 0%, transparent); + filter: saturate(1); + } +} + @media (max-width: 1080px) { .topbar { grid-template-columns: auto 1fr auto; @@ -1065,6 +1166,7 @@ main { } .contact-actions { + width: 100%; margin-top: 0; } } @@ -1106,10 +1208,24 @@ main { .reveal, .button, - .nav a { + .nav a, + .topbar-cta, + .github-link, + .control-button, + .filter-tabs button, + .project-row, + .strength-grid article, + .domain-strip article, + .skill-list span, + .tech-line span, + .map-node { transition: none; } + .is-pressed { + animation: none; + } + .reveal { opacity: 1; transform: none;