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.email}
-
@@ -385,7 +381,6 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
@@ -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;