diff --git a/apps/site/src/components/Hero.astro b/apps/site/src/components/Hero.astro
index 993c430..695096c 100644
--- a/apps/site/src/components/Hero.astro
+++ b/apps/site/src/components/Hero.astro
@@ -89,7 +89,7 @@ const { c, ui } = Astro.props;
radial-gradient(46vw 40vw at 22% 18%,
color-mix(in oklab, var(--accent) 40%, transparent), transparent 66%),
radial-gradient(40vw 38vw at 82% 30%,
- color-mix(in oklab, #6E7BFF 32%, transparent), transparent 64%);
+ color-mix(in oklab, var(--glow-2) 32%, transparent), transparent 64%);
filter: blur(28px);
}
.hero-canvas { opacity: 0; transition: opacity 1.2s var(--ease-soft); }
@@ -156,128 +156,51 @@ const { c, ui } = Astro.props;
diff --git a/apps/site/src/components/Materials.astro b/apps/site/src/components/Materials.astro
index b744774..f5f2732 100644
--- a/apps/site/src/components/Materials.astro
+++ b/apps/site/src/components/Materials.astro
@@ -16,6 +16,8 @@ const { c } = Astro.props;
+ {/* GPU 가 만드는 재질. WebGL 이 없으면 위 그라디언트가 그대로 결과물이다 */}
+
{
@@ -87,11 +89,12 @@ const { c } = Astro.props;
max-width: 1180px;
width: 100%;
margin-inline: auto;
- padding: var(--space-6) var(--space-4);
+ padding: var(--space-5) var(--space-4);
border-radius: var(--radius-shell);
overflow: hidden;
}
- .stage-fallback {
+ .stage-fallback,
+ .stage-canvas {
position: absolute;
inset: 0;
z-index: -1;
@@ -106,6 +109,9 @@ const { c } = Astro.props;
var(--surface-raised);
}
+ .stage-canvas { opacity: 0; transition: opacity var(--dur-slow) var(--ease-soft); }
+ .stage-canvas.is-live { opacity: 1; }
+
/* 3개 항목에 3개 셀. 빈 칸이 생기지 않고 균등 3열도 아니다 */
.tiles {
list-style: none;
@@ -122,8 +128,8 @@ const { c } = Astro.props;
.tile:nth-child(2) { grid-area: top; }
.tile:nth-child(3) { grid-area: bottom; }
/* 큰 셀은 더 넓은 화면을 갖는다 */
- .tile:nth-child(1) .swatch { aspect-ratio: 5 / 4; }
- .tile:nth-child(n + 2) .swatch { aspect-ratio: 16 / 7; }
+ .tile:nth-child(1) .swatch { aspect-ratio: 16 / 11; }
+ .tile:nth-child(n + 2) .swatch { aspect-ratio: 16 / 6; }
.tile { display: grid; gap: var(--space-2); align-content: start; }
.tile .shell { box-shadow: var(--glass-hi), var(--shadow-2); }
.tile .core { overflow: hidden; }
@@ -156,3 +162,47 @@ const { c } = Astro.props;
.tile:nth-child(n + 2) .swatch { aspect-ratio: 5 / 4; }
}
+
+
diff --git a/apps/site/src/lib/shader-plane.ts b/apps/site/src/lib/shader-plane.ts
new file mode 100644
index 0000000..4be7545
--- /dev/null
+++ b/apps/site/src/lib/shader-plane.ts
@@ -0,0 +1,150 @@
+/**
+ * 셰이더 플레인 부트스트랩.
+ *
+ * 스킬의 references/three.md 가 권하는 그 구조다.
+ * - three 는 초기 번들에 없다. 화면에 가까워질 때만 받는다
+ * - WebGL 이 없거나 모션을 줄이는 설정이면 아예 받지 않는다
+ * - DPR 를 클램프한다. 3 을 그대로 쓰면 픽셀이 네 배가 된다
+ * - 화면 밖이거나 탭이 숨으면 그리지 않는다
+ * - 실패하면 조용히 물러난다. CSS 폴백이 그대로 결과물이 된다
+ *
+ * 무거운 씬을 임포트하지 않고 평면 하나에 프래그먼트 셰이더만 얹는다.
+ */
+
+export interface ShaderPlaneOptions {
+ canvas: HTMLCanvasElement;
+ /** GLSL 프래그먼트 셰이더. vUv, uTime, uAspect, uPointer 를 받는다 */
+ fragment: string;
+ /** CSS 커스텀 프로퍼티에서 읽어 vec3 로 넘길 색 */
+ colors?: Record;
+ /** 포인터를 따라가게 할지. 히어로처럼 큰 면적에만 쓴다 */
+ pointer?: boolean;
+ /** 시간 진행 속도 배수 */
+ speed?: number;
+ /** 미리 받아둘 거리 */
+ rootMargin?: string;
+}
+
+const VERTEX = `
+ varying vec2 vUv;
+ void main() {
+ vUv = uv;
+ gl_Position = vec4(position, 1.0);
+ }
+`;
+
+/** 두 셰이더가 공유하는 값 노이즈. 라이브러리를 끌어오지 않는다 */
+export const GLSL_NOISE = `
+ float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
+ float noise(vec2 p) {
+ vec2 i = floor(p), f = fract(p);
+ vec2 u = f * f * (3.0 - 2.0 * f);
+ return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x),
+ mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
+ }
+ float fbm(vec2 p) {
+ float v = 0.0, a = 0.5;
+ for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
+ return v;
+ }
+`;
+
+export function mountShaderPlane(opts: ShaderPlaneOptions): void {
+ const { canvas } = opts;
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+
+ const io = new IntersectionObserver(
+ (entries) => {
+ if (!entries.some((e) => e.isIntersecting)) return;
+ io.disconnect();
+ void boot(opts);
+ },
+ { rootMargin: opts.rootMargin ?? "300px 0px" },
+ );
+ io.observe(canvas);
+}
+
+async function boot(opts: ShaderPlaneOptions): Promise {
+ const { canvas, fragment, colors = {}, pointer = false, speed = 1 } = opts;
+
+ let THREE: typeof import("three");
+ try {
+ THREE = await import("three");
+ } catch {
+ return; // CSS 폴백이 그대로 결과물이 된다
+ }
+
+ let renderer: import("three").WebGLRenderer;
+ try {
+ renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true });
+ } catch {
+ return; // WebGL 이 없는 환경
+ }
+
+ const css = getComputedStyle(document.documentElement);
+ const uniforms: Record = {
+ uTime: { value: 0 },
+ uAspect: { value: 1 },
+ uPointer: { value: new THREE.Vector2(0.5, 0.5) },
+ };
+ for (const [name, cssVar] of Object.entries(colors)) {
+ const raw = css.getPropertyValue(cssVar).trim();
+ uniforms[name] = { value: new THREE.Color(raw || "#4FD6C4") };
+ }
+
+ const material = new THREE.ShaderMaterial({
+ transparent: true,
+ uniforms,
+ vertexShader: VERTEX,
+ fragmentShader: fragment,
+ });
+
+ const scene = new THREE.Scene();
+ scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
+
+ const fit = () => {
+ const r = canvas.getBoundingClientRect();
+ if (!r.width || !r.height) return;
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
+ renderer.setSize(r.width, r.height, false);
+ (uniforms["uAspect"] as { value: number }).value = r.width / r.height;
+ };
+ fit();
+ addEventListener("resize", fit, { passive: true });
+
+ if (pointer) {
+ const target = new THREE.Vector2(0.5, 0.5);
+ addEventListener(
+ "pointermove",
+ (e) => {
+ const r = canvas.getBoundingClientRect();
+ target.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
+ },
+ { passive: true },
+ );
+ // 매 프레임 목표값으로 조금씩 따라간다. 즉시 반응하면 값싸 보인다
+ material.userData["follow"] = () => {
+ const p = uniforms["uPointer"] as { value: import("three").Vector2 };
+ p.value.lerp(target, 0.045);
+ };
+ }
+
+ let tabVisible = true;
+ document.addEventListener("visibilitychange", () => {
+ tabVisible = !document.hidden;
+ });
+
+ let onScreen = true;
+ new IntersectionObserver((es) => {
+ onScreen = es.some((e) => e.isIntersecting);
+ }).observe(canvas);
+
+ canvas.classList.add("is-live");
+ renderer.setAnimationLoop((ms) => {
+ if (!tabVisible || !onScreen) return;
+ (material.userData["follow"] as (() => void) | undefined)?.();
+ (uniforms["uTime"] as { value: number }).value = (ms * 0.001) * speed;
+ renderer.render(scene, camera);
+ });
+}
diff --git a/apps/site/src/styles/base.css b/apps/site/src/styles/base.css
index 901e992..1cbe570 100644
--- a/apps/site/src/styles/base.css
+++ b/apps/site/src/styles/base.css
@@ -55,7 +55,7 @@ body::before {
radial-gradient(58vw 46vw at 14% -6%,
color-mix(in oklab, var(--accent) 26%, transparent), transparent 68%),
radial-gradient(52vw 44vw at 92% 8%,
- color-mix(in oklab, #6E7BFF 20%, transparent), transparent 66%),
+ color-mix(in oklab, var(--glow-2) 20%, transparent), transparent 66%),
radial-gradient(70vw 60vw at 62% 108%,
color-mix(in oklab, var(--accent) 14%, transparent), transparent 70%);
}
@@ -210,13 +210,18 @@ a:hover { color: var(--accent); }
mask-composite: exclude;
}
-/* 투명도를 줄여달라는 사용자에게는 불투명 판으로 준다 */
+/* 투명도를 줄여달라는 요청에 불투명한 판을 깔면 그냥 상자가 된다.
+ 배경에 가까운 톤으로 낮추고 형태는 테두리와 그림자가 유지한다. */
@media (prefers-reduced-transparency: reduce) {
.glass {
- background: var(--surface-raised);
+ background: color-mix(in oklab, var(--surface) 84%, var(--ink));
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
+ /* 떠 있는 헤더는 배경과 더 가깝게. 여기 판이 깔리면 즉시 눈에 띈다 */
+ .site-header .glass {
+ background: color-mix(in oklab, var(--surface) 93%, var(--ink));
+ }
}
@supports not (backdrop-filter: blur(1px)) {
.glass { background: var(--surface-raised); }
@@ -241,7 +246,10 @@ a:hover { color: var(--accent); }
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
}
@media (prefers-reduced-transparency: reduce) {
- .shell { background: var(--surface-raised); backdrop-filter: none; }
+ .shell {
+ background: color-mix(in oklab, var(--surface) 88%, var(--ink));
+ backdrop-filter: none;
+ }
}
/* ---- 진입 연출 ----------------------------------------------
diff --git a/apps/site/src/styles/tokens.css b/apps/site/src/styles/tokens.css
index 78d8270..9d971b8 100644
--- a/apps/site/src/styles/tokens.css
+++ b/apps/site/src/styles/tokens.css
@@ -33,6 +33,8 @@
--accent-ink: #04211D;
--accent-inverse: #0E7A6E;
--accent-ink-inverse: #FFFFFF;
+ /* 셰이더가 쓰는 두 번째 광색. 강조색이 아니라 광원이다 */
+ --glow-2: #6E7BFF;
/* ---- 유리 ------------------------------------------------
backdrop-filter 만으로는 흐린 유리다. 굴절은 SVG 가 만든다. */