Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
921b67b553 Refactor code structure for improved readability and maintainability
Some checks failed
/ print-content (push) Failing after 1m44s
2026-09-10 20:53:15 +09:00
68ac80ac12 Merge pull request 'rewrite' (#16) from rewrite into main
Some checks failed
/ print-content (push) Failing after 1m38s
Reviewed-on: #16
2026-09-09 14:38:23 +09:00
9 changed files with 415 additions and 21 deletions

View file

@ -8,10 +8,12 @@
"@sun-typeface/suit": "^2.0.5",
"astro": "7.3.1",
"clsx": "^2.1.1",
"three": "^0.186.0",
},
"devDependencies": {
"@biomejs/biome": "2.5.12",
"@tailwindcss/vite": "^4.3.3",
"@types/three": "^0.185.4",
"tailwindcss": "^4.3.3",
},
},
@ -101,6 +103,8 @@
"@clack/prompts": ["@clack/prompts@1.5.0", "", { "dependencies": { "@clack/core": "1.4.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA=="],
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
@ -309,6 +313,8 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@ -321,8 +327,14 @@
"@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="],
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/three@0.185.4", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
"am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="],
@ -417,6 +429,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"find-proc": ["find-proc@0.1.0", "", {}, "sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg=="],
"flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],
@ -491,6 +505,8 @@
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
@ -591,6 +607,8 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"three": ["three@0.186.0", "", {}, "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tinyclip": ["tinyclip@0.1.13", "", {}, "sha512-8OqlXQ35euK9+e7L68u8UwcODxkHoIkjbGsgXuARKNyQ5G6xt8nw1YPeMbxMLgCPFkToU+UEK5j05t2t8edKpQ=="],

View file

@ -14,11 +14,13 @@
"dependencies": {
"@sun-typeface/suit": "^2.0.5",
"astro": "7.3.1",
"clsx": "^2.1.1"
"clsx": "^2.1.1",
"three": "^0.186.0"
},
"devDependencies": {
"@biomejs/biome": "2.5.12",
"@tailwindcss/vite": "^4.3.3",
"@types/three": "^0.185.4",
"tailwindcss": "^4.3.3"
},
"overrides": {

BIN
public/about/cards/0.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
public/about/cards/1.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
public/about/cards/2.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
public/about/cards/3.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
public/about/cards/4.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,5 +1,13 @@
---
import AboutRow from "@/components/AboutRow.astro";
const cards = [
{ src: "/about/cards/0.avif", alt: "활동 사진 카드" },
{ src: "/about/cards/1.avif", alt: "활동 사진 카드" },
{ src: "/about/cards/2.avif", alt: "활동 일러스트 카드" },
{ src: "/about/cards/3.avif", alt: "활동 일러스트 카드" },
{ src: "/about/cards/4.avif", alt: "활동 사진 카드" },
];
---
<section
@ -8,7 +16,7 @@ import AboutRow from "@/components/AboutRow.astro";
class="min-h-[calc(100svh-64px)] snap-start bg-background text-foreground"
aria-labelledby="about-title"
>
<div class="grid min-h-full grid-cols-1 lg:grid-cols-2">
<div class="grid min-h-full grid-cols-1 lg:grid-cols-[45fr_55fr]">
<div
class="relative z-10 flex flex-col justify-center px-6 py-16 sm:px-10 lg:px-[clamp(52px,5.208vw,100px)] lg:py-[clamp(58px,9.26vh,100px)]"
>
@ -16,8 +24,8 @@ import AboutRow from "@/components/AboutRow.astro";
About<span class="text-foreground/35" aria-hidden="true">.</span>
</h2>
<p class="mt-6 max-w-lg text-base leading-8 text-foreground">
안녕하세요. 정보보안과 소프트웨어 엔지니어링을 공부하는 암냥입니다.
새로운 기술을 배우고, 머릿속의 아이디어를 실제로 작동하는 것으로
안녕하세요. 정보보안과 소프트웨어 엔지니어링을 공부하는 암냥입니다.<br/>
새로운 기술을 배우고, 머릿속의 아이디어를 실제로 작동하는 것으로<br/>
만드는 일을 좋아해요.
</p>
<section class="mt-16" aria-labelledby="experience-title">
@ -93,23 +101,389 @@ import AboutRow from "@/components/AboutRow.astro";
</div>
</div>
<div
class="relative order-last h-[min(100vw,480px)] min-h-0 overflow-hidden bg-muted lg:sticky lg:top-0 lg:order-none lg:h-[calc(100svh-64px)] lg:min-h-0 lg:self-start"
aria-label="활동 사진"
>
<img
class="absolute inset-0 h-full w-full select-none object-cover object-center"
src="/about/hand.avif"
alt="손"
width="1920"
height="1080"
loading="lazy"
decoding="async"
/>
<div
class="pointer-events-none absolute inset-0 bg-gradient-to-t from-background/35 via-transparent to-black/5"
aria-hidden="true"
></div>
<div class="about-card-panel order-last min-h-[min(100vw,540px)] overflow-hidden bg-muted lg:sticky lg:top-0 lg:order-none lg:h-[calc(100svh-64px)] lg:min-h-0" aria-label="활동 사진 선택">
<div class="about-card-stage" data-card-stage>
<canvas class="about-card-canvas" data-card-canvas aria-hidden="true"></canvas>
<div class="about-card-access" role="group" aria-label="활동 카드 선택">
{cards.map((card, index) => (
<button
type="button"
aria-label={`활동 카드 ${index + 1} 선택`}
aria-pressed="false"
data-card-control={index}
data-card-src={card.src}
>
{card.alt}
</button>
))}
</div>
<button class="about-card-flip" type="button" data-card-flip aria-label="선택한 카드 앞뒷면 전환" hidden>
<span aria-hidden="true">↻</span>
</button>
</div>
</div>
</div>
</section>
<script>
import * as THREE from "three/src/Three.js";
const stage = document.querySelector<HTMLElement>("[data-card-stage]");
const canvas = document.querySelector<HTMLCanvasElement>("[data-card-canvas]");
const aboutSection = stage?.closest<HTMLElement>("#about");
const controls = [...document.querySelectorAll<HTMLButtonElement>("[data-card-control]")];
const flipButton = document.querySelector<HTMLButtonElement>("[data-card-flip]");
if (stage && canvas && controls.length) {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(27, 1, 0.1, 100);
camera.position.z = 15;
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.25));
scene.add(new THREE.HemisphereLight(0xffffff, 0x4b3447, 1.8));
const keyLight = new THREE.DirectionalLight(0xffffff, 3.6);
keyLight.position.set(-4, 6, 8);
scene.add(keyLight);
const glowLight = new THREE.PointLight(0xffc8e1, 2.4, 12);
glowLight.position.set(0, 1.5, 6);
scene.add(glowLight);
const cardWidth = 2.481875;
const cardHeight = 3.474625;
const cardDepth = 0.028;
const cardCornerRadius = 6;
const fanOffsets = [-1.95, -0.98, 0, 0.98, 1.95];
const fanHeights = [-1.45, -1.34, -1.38, -1.34, -1.45];
const fanRotations = [-0.3, -0.15, 0, 0.15, 0.3];
const loader = new THREE.TextureLoader();
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
function createCardTexture(image: HTMLImageElement) {
const texture = new THREE.Texture(image);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy());
texture.needsUpdate = true;
return texture;
}
function createRoundedAlphaTexture() {
const maskCanvas = document.createElement("canvas");
maskCanvas.width = 256;
maskCanvas.height = 360;
const context = maskCanvas.getContext("2d");
if (!context) return new THREE.Texture();
context.fillStyle = "white";
context.beginPath();
context.roundRect(2, 2, 252, 356, cardCornerRadius);
context.fill();
const texture = new THREE.CanvasTexture(maskCanvas);
texture.needsUpdate = true;
return texture;
}
const roundedAlphaTexture = createRoundedAlphaTexture();
let loadedTextureCount = 0;
let texturesReady = false;
let sectionVisible = false;
let deckLiftStarted = false;
let fanExpanded = false;
let deckLiftTimer: number | undefined;
const markTextureReady = () => {
loadedTextureCount += 1;
if (loadedTextureCount === controls.length + 1) {
texturesReady = true;
startDeckReveal();
}
scheduleRender();
};
const cards = controls.map((control, index) => {
const group = new THREE.Group();
const frontMaterial = new THREE.MeshBasicMaterial({
alphaMap: roundedAlphaTexture,
transparent: true,
});
const front = new THREE.Mesh(new THREE.PlaneGeometry(cardWidth, cardHeight), frontMaterial);
loader.load(control.dataset.cardSrc ?? "", (texture: THREE.Texture) => {
frontMaterial.map = createCardTexture(texture.image as HTMLImageElement);
frontMaterial.needsUpdate = true;
front.visible = true;
markTextureReady();
scheduleRender();
});
const backMaterial = new THREE.MeshBasicMaterial({
alphaMap: roundedAlphaTexture,
transparent: true,
});
const back = new THREE.Mesh(new THREE.PlaneGeometry(cardWidth, cardHeight), backMaterial);
front.visible = false;
back.visible = false;
front.position.z = cardDepth / 2 + 0.008;
back.position.z = -cardDepth / 2 - 0.008;
back.rotation.y = Math.PI;
group.add(front, back);
group.userData = { index };
group.position.set(0, -0.28, index * 0.04);
group.rotation.y = Math.PI;
group.rotation.z = 0;
scene.add(group);
return group;
});
loader.load("/about/cards/back.avif", (texture: THREE.Texture) => {
const backTexture = createCardTexture(texture.image as HTMLImageElement);
cards.forEach((card) => {
const back = card.children[1] as THREE.Mesh;
const material = back.material as THREE.MeshBasicMaterial;
material.map = backTexture;
material.needsUpdate = true;
back.visible = true;
});
markTextureReady();
scheduleRender();
});
let selectedIndex = -1;
let revealedIndex = -1;
let revealTimer: number | undefined;
let animationFrameId: number | undefined;
const pointerDown = new THREE.Vector2();
function scheduleRender() {
if (animationFrameId === undefined) animationFrameId = requestAnimationFrame(render);
}
function startDeckReveal() {
if (!texturesReady || !sectionVisible || deckLiftStarted) return;
deckLiftStarted = true;
scheduleRender();
deckLiftTimer = window.setTimeout(() => {
fanExpanded = true;
scheduleRender();
}, 620);
}
function updateControls() {
controls.forEach((control, index) => {
control.setAttribute("aria-pressed", String(index === selectedIndex));
});
if (flipButton) flipButton.hidden = selectedIndex < 0;
}
function selectCard(index: number) {
if (revealTimer !== undefined) window.clearTimeout(revealTimer);
if (index === selectedIndex) {
selectedIndex = -1;
revealedIndex = -1;
updateControls();
scheduleRender();
return;
}
selectedIndex = index;
revealedIndex = -1;
updateControls();
revealTimer = window.setTimeout(() => {
if (selectedIndex === index) revealedIndex = index;
scheduleRender();
}, 280);
scheduleRender();
}
function setPointer(event: PointerEvent) {
const bounds = canvas!.getBoundingClientRect();
pointer.set(
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
);
}
function hitCard() {
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(cards, true)[0];
return hit?.object.parent?.userData.index ?? hit?.object.userData.index ?? -1;
}
controls.forEach((control, index) => {
control.addEventListener("click", () => selectCard(index));
});
flipButton?.addEventListener("click", () => {
if (selectedIndex >= 0) {
revealedIndex = revealedIndex === selectedIndex ? -1 : selectedIndex;
scheduleRender();
}
});
canvas.addEventListener("pointermove", (event) => {
setPointer(event);
});
canvas.addEventListener("pointerleave", () => {
canvas.style.cursor = "grab";
});
canvas.addEventListener("pointerdown", (event) => {
pointerDown.set(event.clientX, event.clientY);
});
canvas.addEventListener("pointerup", (event) => {
if (Math.hypot(event.clientX - pointerDown.x, event.clientY - pointerDown.y) > 8) return;
setPointer(event);
const index = hitCard();
if (index >= 0) selectCard(index);
});
const scrollRoot = aboutSection?.closest<HTMLElement>("main") ?? null;
const revealObserver = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
sectionVisible = true;
startDeckReveal();
scheduleRender();
}
},
{ root: scrollRoot, threshold: 0.35 },
);
if (aboutSection) revealObserver.observe(aboutSection);
function resize() {
const { width, height } = stage!.getBoundingClientRect();
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
const visibleWidth = 2 * camera.position.z * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * camera.aspect;
const scale = Math.min(1, Math.max(0.58, (visibleWidth / (fanOffsets.at(-1)! * 2 + cardWidth)) * 0.92));
scene.scale.setScalar(scale * 0.9);
scheduleRender();
}
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(stage);
resize();
updateControls();
let previousTime = performance.now();
function render() {
animationFrameId = undefined;
const now = performance.now();
const delta = Math.min((now - previousTime) / 1000, 0.05);
previousTime = now;
let moving = false;
cards.forEach((card, index) => {
const selected = index === selectedIndex;
card.renderOrder = selected ? 20 : index;
const targetX = fanExpanded ? fanOffsets[index] : 0;
const targetY = selected
? fanHeights[index] + 2
: (fanExpanded ? fanHeights[index] : (deckLiftStarted ? -0.28 : -2.4));
const targetZ = selected ? 2.6 : index * 0.04;
const targetScale = selected ? 1.05 : 1;
card.position.x = THREE.MathUtils.damp(card.position.x, targetX, 5.5, delta);
card.position.y = THREE.MathUtils.damp(card.position.y, targetY, 5.5, delta);
card.position.z = THREE.MathUtils.damp(card.position.z, targetZ, 5.5, delta);
card.scale.setScalar(THREE.MathUtils.damp(card.scale.x, targetScale, 5.5, delta));
const targetRotation = fanExpanded ? fanRotations[index] : 0;
card.rotation.z = THREE.MathUtils.damp(card.rotation.z, targetRotation, 5.5, delta);
card.rotation.y = THREE.MathUtils.damp(card.rotation.y, revealedIndex === index ? 0 : Math.PI, 7, delta);
moving ||= Math.abs(card.position.x - targetX) > 0.001
|| Math.abs(card.position.y - targetY) > 0.001
|| Math.abs(card.position.z - targetZ) > 0.001
|| Math.abs(card.rotation.z - targetRotation) > 0.001
|| Math.abs(card.rotation.y - (revealedIndex === index ? 0 : Math.PI)) > 0.001;
});
renderer.render(scene, camera);
if (moving) scheduleRender();
}
scheduleRender();
}
</script>
<style>
.about-card-panel {
position: relative;
isolation: isolate;
}
.about-card-stage {
position: relative;
display: grid;
height: 100%;
min-height: 540px;
place-items: center;
overflow: hidden;
background: radial-gradient(circle at 50% 40%, color-mix(in srgb, var(--background) 96%, white), var(--muted));
}
.about-card-stage::before {
position: absolute;
left: 50%;
bottom: 16%;
width: min(78%, 640px);
height: 18%;
border-radius: 50%;
background: color-mix(in srgb, var(--foreground) 14%, transparent);
content: "";
filter: blur(28px);
opacity: .22;
pointer-events: none;
transform: translateX(-50%);
}
.about-card-canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
position: relative;
z-index: 1;
}
.about-card-access {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.about-card-access button {
width: 1px;
height: 1px;
}
.about-card-flip {
position: absolute;
right: 28px;
bottom: 28px;
display: grid;
width: 42px;
height: 42px;
place-items: center;
z-index: 2;
border: 1px solid color-mix(in srgb, var(--foreground) 18%, transparent);
border-radius: 999px;
color: var(--foreground);
background: color-mix(in srgb, var(--background) 72%, transparent);
font-size: 21px;
cursor: pointer;
backdrop-filter: blur(10px);
box-shadow: 0 8px 24px rgb(32 17 29 / 10%);
transition: transform 220ms ease, background 220ms ease, box-shadow 220ms ease;
}
.about-card-flip:hover,
.about-card-flip:focus-visible {
outline: none;
background: var(--background);
transform: rotate(45deg);
box-shadow: 0 10px 28px rgb(32 17 29 / 16%);
}
@media (prefers-reduced-motion: reduce) {
.about-card-flip {
transition: none;
}
}
</style>