From 989cc254650b4274b5fa48854f4f359baee83eab Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 30 Jun 2026 14:40:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(redesign):=20=EC=87=BC=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EA=B7=B8=EB=9E=98=EB=94=94=EC=96=B8=ED=8A=B8=20?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=E2=86=92=20=EB=9D=BC=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?MockWindow=20=EC=B9=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/showcase.ts를 mock 키 기반으로 교체(보라 4슬롯 제거, 목업 6종 다양화). ShowcaseCard 캔버스/시드/그래디언트 제거 → surface-alt 스테이지 + 흰 MockWindow. 키 목록을 JSX-free keys.ts로 분리해 vitest 가드레일 테스트 추가. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52 --- app/components/deepfield/ShowcaseCard.tsx | 315 ++-------------------- app/components/mock/keys.ts | 17 ++ app/components/mock/registry.ts | 12 +- lib/__tests__/showcase.test.ts | 40 +++ lib/showcase.ts | 33 +-- 5 files changed, 105 insertions(+), 312 deletions(-) create mode 100644 app/components/mock/keys.ts create mode 100644 lib/__tests__/showcase.test.ts diff --git a/app/components/deepfield/ShowcaseCard.tsx b/app/components/deepfield/ShowcaseCard.tsx index 6f98825..0043c75 100644 --- a/app/components/deepfield/ShowcaseCard.tsx +++ b/app/components/deepfield/ShowcaseCard.tsx @@ -1,9 +1,8 @@ -'use client'; - import Link from 'next/link'; -import { useEffect, useRef, useState } from 'react'; import type { ShowcaseSlot } from '@/lib/showcase'; +import MockWindow from '@/app/components/mock/MockWindow'; +import { MOCK_REGISTRY } from '@/app/components/mock/registry'; interface Props { slot: ShowcaseSlot; @@ -11,316 +10,58 @@ interface Props { index: number; } -// ───────────────────────── 시드 PRNG (결정적) ───────────────────────── - -/** slug → 32bit 정수 시드 (cyrb-ish 해시) */ -function hashSlug(s: string): number { - let h = 2166136261 >>> 0; - for (let i = 0; i < s.length; i++) { - h ^= s.charCodeAt(i); - h = Math.imul(h, 16777619); - } - return h >>> 0; -} - -/** mulberry32 — 시드 하나로 결정적 난수열 생성 */ -function mulberry32(seed: number): () => number { - let a = seed >>> 0; - return () => { - a |= 0; - a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** #rrggbb → {r,g,b} */ -function hexToRgb(hex: string): { r: number; g: number; b: number } { - const h = hex.replace('#', ''); - return { - r: parseInt(h.slice(0, 2), 16), - g: parseInt(h.slice(2, 4), 16), - b: parseInt(h.slice(4, 6), 16), - }; -} - -// ───────────────────────── 패턴 3종 (정적 텍스처) ───────────────────────── - -type RGB = { r: number; g: number; b: number }; - -/** 1. 등고선 — accent 동심 곡선 흐름 */ -function drawContour( - ctx: CanvasRenderingContext2D, - w: number, - h: number, - rng: () => number, - c: RGB, -) { - const cx = w * (0.3 + rng() * 0.4); - const cy = h * (0.3 + rng() * 0.4); - const rings = 9 + Math.floor(rng() * 5); - const step = Math.max(w, h) / rings; - const wobble = 0.12 + rng() * 0.1; - const phase = rng() * Math.PI * 2; - ctx.lineWidth = 1.25; - for (let i = 1; i <= rings; i++) { - const baseR = i * step; - const alpha = 0.1 + (1 - i / rings) * 0.08; // 0.10~0.18 - ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${alpha})`; - ctx.beginPath(); - const segs = 72; - for (let s = 0; s <= segs; s++) { - const a = (s / segs) * Math.PI * 2; - const r = baseR * (1 + Math.sin(a * 3 + phase + i * 0.6) * wobble); - const x = cx + Math.cos(a) * r; - const y = cy + Math.sin(a) * r * 0.82; - if (s === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.stroke(); - } -} - -/** 2. 격자 왜곡 — 미세하게 휘어진 그리드 라인 */ -function drawGrid( - ctx: CanvasRenderingContext2D, - w: number, - h: number, - rng: () => number, - c: RGB, -) { - const cols = 7 + Math.floor(rng() * 4); - const rows = 5 + Math.floor(rng() * 4); - const amp = 6 + rng() * 10; - const fx = 1.5 + rng() * 2; - const fy = 1.5 + rng() * 2; - const phase = rng() * Math.PI * 2; - ctx.lineWidth = 1; - ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},0.14)`; - // 세로선 - for (let i = 0; i <= cols; i++) { - const baseX = (i / cols) * w; - ctx.beginPath(); - for (let j = 0; j <= 40; j++) { - const ny = j / 40; - const y = ny * h; - const x = baseX + Math.sin(ny * Math.PI * fy + phase + i * 0.4) * amp; - if (j === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.stroke(); - } - // 가로선 - for (let i = 0; i <= rows; i++) { - const baseY = (i / rows) * h; - ctx.beginPath(); - for (let j = 0; j <= 40; j++) { - const nx = j / 40; - const x = nx * w; - const y = baseY + Math.cos(nx * Math.PI * fx + phase + i * 0.4) * amp; - if (j === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.stroke(); - } -} - -/** 3. 도트 필드 — 밀도 그라데이션 도트 */ -function drawDots( - ctx: CanvasRenderingContext2D, - w: number, - h: number, - rng: () => number, - c: RGB, -) { - const gap = 16 + rng() * 8; - const ox = rng() * Math.PI * 2; - const oy = rng() * Math.PI * 2; - // 밀도 중심 (가장 진한 지점) - const dcx = w * (0.2 + rng() * 0.6); - const dcy = h * (0.2 + rng() * 0.6); - const maxD = Math.hypot(w, h); - for (let y = gap * 0.5; y < h; y += gap) { - for (let x = gap * 0.5; x < w; x += gap) { - const jx = Math.sin((y / gap) * 1.3 + ox) * 2.5; - const jy = Math.cos((x / gap) * 1.3 + oy) * 2.5; - const px = x + jx; - const py = y + jy; - const d = Math.hypot(px - dcx, py - dcy) / maxD; // 0~~1 - const density = 1 - d; // 중심부 1 - const alpha = 0.06 + density * 0.16; // 0.06~0.22 - const radius = 0.9 + density * 1.8; - ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},${alpha.toFixed(3)})`; - ctx.beginPath(); - ctx.arc(px, py, radius, 0, Math.PI * 2); - ctx.fill(); - } - } -} - -const PATTERNS = [drawContour, drawGrid, drawDots]; - -// ───────────────────────── 컴포넌트 ───────────────────────── - -export default function ShowcaseCard({ slot, size = 'standard', index }: Props) { - const canvasRef = useRef(null); - const wrapRef = useRef(null); - const [hovered, setHovered] = useState(false); - - // 슬러그 시드 — 결정적 패턴 선택/파라미터 - const seed = hashSlug(slot.slug); - const patternType = seed % PATTERNS.length; - - // 캔버스 1회 정적 렌더 (DPR 반영, 애니메이션 루프 없음) - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const draw = () => { - const rect = canvas.getBoundingClientRect(); - const w = rect.width; - const h = rect.height; - if (w === 0 || h === 0) return; - const dpr = Math.min(window.devicePixelRatio || 1, 2); - canvas.width = Math.round(w * dpr); - canvas.height = Math.round(h * dpr); - const ctx = canvas.getContext('2d'); - if (!ctx) return; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, w, h); - const rng = mulberry32(seed); - const c = hexToRgb(slot.accent); - PATTERNS[patternType](ctx, w, h, rng, c); - }; - - draw(); - - // 컨테이너 리사이즈 시 재렌더 (정적 — 루프 아님) - const ro = new ResizeObserver(() => draw()); - ro.observe(canvas); - return () => ro.disconnect(); - }, [seed, patternType, slot.accent]); - - // 호버 시차 — 리스너/rAF는 hover 중에만 가동 (상시 rAF 금지) - useEffect(() => { - if (!hovered) return; - if (typeof window === 'undefined') return; - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; - const canvas = canvasRef.current; - const wrap = wrapRef.current; - if (!canvas || !wrap) return; - - let rafId = 0; - let tx = 0; - let ty = 0; - - const apply = () => { - rafId = 0; - canvas.style.transform = `translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px)`; - }; - - const onMove = (e: MouseEvent) => { - const rect = wrap.getBoundingClientRect(); - const nx = (e.clientX - rect.left) / rect.width - 0.5; // -0.5~0.5 - const ny = (e.clientY - rect.top) / rect.height - 0.5; - tx = nx * 12; // ±6px - ty = ny * 12; - if (!rafId) rafId = requestAnimationFrame(apply); - }; - - wrap.addEventListener('mousemove', onMove); - return () => { - wrap.removeEventListener('mousemove', onMove); - if (rafId) cancelAnimationFrame(rafId); - canvas.style.transform = ''; - }; - }, [hovered]); - +// 라이트 쇼케이스 카드 — surface-alt 스테이지 위에 흰 MockWindow가 떠 있는 "framed screen". +// 서버 컴포넌트 (캔버스/시드/그래디언트 전량 제거). +export default function ShowcaseCard({ slot, size = 'standard' }: Props) { + const Mock = MOCK_REGISTRY[slot.mock]; const isFeature = size === 'feature'; const isLink = Boolean(slot.href); - // 타일 본체 (링크/div 공통) - const tile = ( + const body = (
setHovered(true)} - onMouseLeave={() => setHovered(false)} className={[ - 'group/card relative isolate h-full w-full overflow-hidden rounded-2xl', - 'transition-[transform,box-shadow] duration-500', + 'group/card flex h-full flex-col rounded-2xl border p-5 lg:p-6', + 'transition-[transform,box-shadow,border-color] duration-300', '[transition-timing-function:cubic-bezier(0.16,1,0.3,1)]', - 'motion-safe:hover:scale-[1.03]', - isLink ? 'cursor-pointer' : 'cursor-default', - isFeature ? 'aspect-[16/10]' : 'aspect-[4/3]', + 'motion-safe:hover:-translate-y-1 hover:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.4)]', + isLink ? 'cursor-pointer' : '', ].join(' ')} - style={ - { - '--card-accent': slot.accent, - backgroundImage: `linear-gradient(135deg, ${slot.palette[0]}, ${slot.palette[1]})`, - // 기본 보더는 다크 라인, hover 시 accent 점등 + 코너 글로우 (인라인 hover는 className으로) - boxShadow: hovered - ? `0 0 0 1px ${slot.accent}, 0 18px 50px -20px ${slot.accent}66, inset 0 0 60px -30px ${slot.accent}80` - : '0 0 0 1px var(--jsm-dark-line, rgba(148,163,184,0.14))', - } as React.CSSProperties - } + style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }} > - {/* 제너러티브 텍스처 (정적) */} -