feat: enhance resume interactions

This commit is contained in:
湛兮
2026-05-22 22:09:47 +08:00
parent daef1657aa
commit 6bdc7eea9b
2 changed files with 443 additions and 49 deletions
+317 -39
View File
@@ -231,10 +231,6 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
<h2 id="rail-name">{displayNameFor(resume)}</h2>
<p id="rail-intent">{resume.intent}</p>
<dl>
<div>
<dt id="phone-label">{labels.zh.phone}</dt>
<dd><a id="rail-phone" href={`tel:${resume.contact.phone}`}>{resume.contact.phone}</a></dd>
</div>
<div>
<dt id="email-label">{labels.zh.email}</dt>
<dd class="email-list" id="rail-emails">
@@ -385,7 +381,6 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
<div class="contact-actions">
<a class="button button-primary" id="send-email" href={`mailto:${resume.contact.email}`}>{labels.zh.sendEmail}</a>
<a class="button" id="contact-email-alt" href={`mailto:${resume.contact.emailAlt}`}>{resume.contact.emailAlt}</a>
<a class="button" id="contact-phone" href={`tel:${resume.contact.phone}`}>{resume.contact.phone}</a>
</div>
</section>
</div>
@@ -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();
+126 -10
View File
@@ -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;