feat(redesign): 쇼케이스 그래디언트 타일 → 라이트 MockWindow 카드
lib/showcase.ts를 mock 키 기반으로 교체(보라 4슬롯 제거, 목업 6종 다양화). ShowcaseCard 캔버스/시드/그래디언트 제거 → surface-alt 스테이지 + 흰 MockWindow. 키 목록을 JSX-free keys.ts로 분리해 vitest 가드레일 테스트 추가. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
This commit is contained in:
@@ -1,9 +1,8 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import type { ShowcaseSlot } from '@/lib/showcase';
|
import type { ShowcaseSlot } from '@/lib/showcase';
|
||||||
|
import MockWindow from '@/app/components/mock/MockWindow';
|
||||||
|
import { MOCK_REGISTRY } from '@/app/components/mock/registry';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
slot: ShowcaseSlot;
|
slot: ShowcaseSlot;
|
||||||
@@ -11,316 +10,58 @@ interface Props {
|
|||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────── 시드 PRNG (결정적) ─────────────────────────
|
// 라이트 쇼케이스 카드 — surface-alt 스테이지 위에 흰 MockWindow가 떠 있는 "framed screen".
|
||||||
|
// 서버 컴포넌트 (캔버스/시드/그래디언트 전량 제거).
|
||||||
/** slug → 32bit 정수 시드 (cyrb-ish 해시) */
|
export default function ShowcaseCard({ slot, size = 'standard' }: Props) {
|
||||||
function hashSlug(s: string): number {
|
const Mock = MOCK_REGISTRY[slot.mock];
|
||||||
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<HTMLCanvasElement>(null);
|
|
||||||
const wrapRef = useRef<HTMLDivElement>(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]);
|
|
||||||
|
|
||||||
const isFeature = size === 'feature';
|
const isFeature = size === 'feature';
|
||||||
const isLink = Boolean(slot.href);
|
const isLink = Boolean(slot.href);
|
||||||
|
|
||||||
// 타일 본체 (링크/div 공통)
|
const body = (
|
||||||
const tile = (
|
|
||||||
<div
|
<div
|
||||||
ref={wrapRef}
|
|
||||||
data-index={index}
|
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
className={[
|
className={[
|
||||||
'group/card relative isolate h-full w-full overflow-hidden rounded-2xl',
|
'group/card flex h-full flex-col rounded-2xl border p-5 lg:p-6',
|
||||||
'transition-[transform,box-shadow] duration-500',
|
'transition-[transform,box-shadow,border-color] duration-300',
|
||||||
'[transition-timing-function:cubic-bezier(0.16,1,0.3,1)]',
|
'[transition-timing-function:cubic-bezier(0.16,1,0.3,1)]',
|
||||||
'motion-safe:hover:scale-[1.03]',
|
'motion-safe:hover:-translate-y-1 hover:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.4)]',
|
||||||
isLink ? 'cursor-pointer' : 'cursor-default',
|
isLink ? 'cursor-pointer' : '',
|
||||||
isFeature ? 'aspect-[16/10]' : 'aspect-[4/3]',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
style={
|
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
|
||||||
{
|
|
||||||
'--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
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{/* 제너러티브 텍스처 (정적) */}
|
<MockWindow title={`${slot.slug}.app`} className="group-hover/card:border-[var(--jsm-accent-soft)]">
|
||||||
<canvas
|
<Mock />
|
||||||
ref={canvasRef}
|
</MockWindow>
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute inset-0 h-full w-full will-change-transform"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 하단 스크림 — 텍스트 가독성 */}
|
<div className="mt-5">
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-2/3"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'linear-gradient(to top, rgba(7,13,26,0.92) 0%, rgba(7,13,26,0.55) 45%, transparent 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 텍스트 레이어 */}
|
|
||||||
<div className="absolute inset-x-0 bottom-0 flex flex-col gap-1.5 p-5 sm:p-6">
|
|
||||||
<span
|
<span
|
||||||
className="font-mono text-[11px] uppercase tracking-[0.18em]"
|
className="font-mono text-[11px] uppercase tracking-[0.18em]"
|
||||||
style={{ color: slot.accent }}
|
style={{ color: 'var(--jsm-accent)' }}
|
||||||
>
|
>
|
||||||
{slot.label}
|
{slot.label}
|
||||||
</span>
|
</span>
|
||||||
<h3
|
<h3
|
||||||
className={[
|
className={[
|
||||||
'font-bold leading-snug [word-break:keep-all]',
|
'mt-1.5 font-bold [word-break:keep-all]',
|
||||||
isFeature ? 'text-xl sm:text-2xl' : 'text-lg',
|
isFeature ? 'text-xl' : 'text-lg',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
style={{ color: 'var(--jsm-dark-ink, #f8fafc)' }}
|
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
|
||||||
>
|
>
|
||||||
{slot.title}
|
{slot.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
className="line-clamp-1 text-sm [word-break:keep-all]"
|
className="mt-1.5 text-sm leading-relaxed [word-break:keep-all]"
|
||||||
style={{ color: 'var(--jsm-dark-soft, #94a3b8)' }}
|
style={{ color: 'var(--jsm-ink-soft)', letterSpacing: '-0.01em' }}
|
||||||
>
|
>
|
||||||
{slot.desc}
|
{slot.desc}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isLink && (
|
{isLink && (
|
||||||
<span
|
<span
|
||||||
className="mt-1 inline-flex items-center gap-1.5 text-[13px] font-medium transition-transform duration-500 [transition-timing-function:cubic-bezier(0.16,1,0.3,1)] group-hover/card:translate-x-1"
|
className="mt-3 inline-flex items-center gap-1.5 text-[13px] font-semibold transition-transform duration-300 group-hover/card:translate-x-1"
|
||||||
style={{ color: slot.accent }}
|
style={{ color: 'var(--jsm-accent)' }}
|
||||||
>
|
>
|
||||||
데모 보기
|
데모 보기
|
||||||
<svg
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
className="transition-transform duration-500 [transition-timing-function:cubic-bezier(0.16,1,0.3,1)] group-hover/card:translate-x-0.5"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
d="M5 12h14M13 6l6 6-6 6"
|
d="M5 12h14M13 6l6 6-6 6"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -337,11 +78,11 @@ export default function ShowcaseCard({ slot, size = 'standard', index }: Props)
|
|||||||
|
|
||||||
if (isLink) {
|
if (isLink) {
|
||||||
return (
|
return (
|
||||||
<Link href={slot.href!} aria-label={slot.title} className="block h-full w-full">
|
<Link href={slot.href!} aria-label={slot.title} className="block h-full">
|
||||||
{tile}
|
{body}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tile;
|
return body;
|
||||||
}
|
}
|
||||||
|
|||||||
17
app/components/mock/keys.ts
Normal file
17
app/components/mock/keys.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// 목업 키 — JSX를 끌어오지 않는 순수 모듈 (vitest/showcase가 안전하게 참조).
|
||||||
|
export type MockKey =
|
||||||
|
| 'dashboard'
|
||||||
|
| 'feed'
|
||||||
|
| 'match'
|
||||||
|
| 'commerce'
|
||||||
|
| 'site'
|
||||||
|
| 'booking';
|
||||||
|
|
||||||
|
export const MOCK_KEYS: MockKey[] = [
|
||||||
|
'dashboard',
|
||||||
|
'feed',
|
||||||
|
'match',
|
||||||
|
'commerce',
|
||||||
|
'site',
|
||||||
|
'booking',
|
||||||
|
];
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// 목업 스크린 레지스트리 — showcase 슬롯의 mock 키를 컴포넌트로 해석.
|
// 목업 스크린 레지스트리 — showcase 슬롯의 mock 키를 컴포넌트로 해석.
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
import type { MockKey } from './keys';
|
||||||
import {
|
import {
|
||||||
DashboardMock,
|
DashboardMock,
|
||||||
FeedMock,
|
FeedMock,
|
||||||
@@ -10,13 +11,8 @@ import {
|
|||||||
BookingMock,
|
BookingMock,
|
||||||
} from './screens';
|
} from './screens';
|
||||||
|
|
||||||
export type MockKey =
|
export type { MockKey } from './keys';
|
||||||
| 'dashboard'
|
export { MOCK_KEYS } from './keys';
|
||||||
| 'feed'
|
|
||||||
| 'match'
|
|
||||||
| 'commerce'
|
|
||||||
| 'site'
|
|
||||||
| 'booking';
|
|
||||||
|
|
||||||
export const MOCK_REGISTRY: Record<MockKey, ComponentType> = {
|
export const MOCK_REGISTRY: Record<MockKey, ComponentType> = {
|
||||||
dashboard: DashboardMock,
|
dashboard: DashboardMock,
|
||||||
@@ -26,5 +22,3 @@ export const MOCK_REGISTRY: Record<MockKey, ComponentType> = {
|
|||||||
site: SiteMock,
|
site: SiteMock,
|
||||||
booking: BookingMock,
|
booking: BookingMock,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MOCK_KEYS = Object.keys(MOCK_REGISTRY) as MockKey[];
|
|
||||||
|
|||||||
40
lib/__tests__/showcase.test.ts
Normal file
40
lib/__tests__/showcase.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { SHOWCASE_SLOTS } from '@/lib/showcase';
|
||||||
|
import { MOCK_KEYS } from '@/app/components/mock/keys';
|
||||||
|
|
||||||
|
// 가드레일: 쇼케이스 슬롯이 라이트 목업 기반이고 보라/그래디언트 잔재가 없어야 한다.
|
||||||
|
const VIOLET_HEXES = ['#c4b5fd', '#f0abfc', '#341a4f', '#4a1342', '#7c3aed', '#9c48ea'];
|
||||||
|
|
||||||
|
describe('SHOWCASE_SLOTS 가드레일', () => {
|
||||||
|
it('8슬롯이고 slug가 고유하다', () => {
|
||||||
|
expect(SHOWCASE_SLOTS.length).toBe(8);
|
||||||
|
const slugs = SHOWCASE_SLOTS.map((s) => s.slug);
|
||||||
|
expect(new Set(slugs).size).toBe(slugs.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('각 슬롯의 mock이 유효한 MockKey이고 핵심 필드가 비어있지 않다', () => {
|
||||||
|
for (const s of SHOWCASE_SLOTS) {
|
||||||
|
expect(MOCK_KEYS).toContain(s.mock);
|
||||||
|
expect(s.slug.length).toBeGreaterThan(0);
|
||||||
|
expect(s.label.length).toBeGreaterThan(0);
|
||||||
|
expect(s.title.length).toBeGreaterThan(0);
|
||||||
|
expect(s.desc.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('어떤 슬롯에도 보라/그래디언트 hex가 남아있지 않다', () => {
|
||||||
|
const serialized = JSON.stringify(SHOWCASE_SLOTS).toLowerCase();
|
||||||
|
for (const hex of VIOLET_HEXES) {
|
||||||
|
expect(serialized).not.toContain(hex.toLowerCase());
|
||||||
|
}
|
||||||
|
// 더 이상 palette 필드를 갖지 않는다 (라이트 목업 전환).
|
||||||
|
for (const s of SHOWCASE_SLOTS) {
|
||||||
|
expect('palette' in s).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('목업 종류가 최소 4가지 이상으로 다양하다 (단조 방지)', () => {
|
||||||
|
const uniqueMocks = new Set(SHOWCASE_SLOTS.map((s) => s.mock));
|
||||||
|
expect(uniqueMocks.size).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
/** Deep Field 쇼케이스 8슬롯 — 단일 소스.
|
/** Deep Field 쇼케이스 8슬롯 — 단일 소스 (라이트 MockWindow 목업 기반).
|
||||||
* href가 있는 슬롯만 클릭 가능 (샘플 리뉴얼 완료 시 href 추가). */
|
* href가 있는 슬롯만 클릭 가능 (샘플 데모 완료 시 href 추가). */
|
||||||
|
import type { MockKey } from '@/app/components/mock/keys';
|
||||||
|
|
||||||
export interface ShowcaseSlot {
|
export interface ShowcaseSlot {
|
||||||
slug: string;
|
slug: string;
|
||||||
label: string; // 모노스페이스 컨셉 라벨 (영문)
|
label: string; // 모노스페이스 컨셉 라벨 (영문)
|
||||||
title: string; // 카드 타이틀 (한글)
|
title: string; // 카드 타이틀 (한글)
|
||||||
desc: string; // 한 줄 설명
|
desc: string; // 한 줄 설명
|
||||||
palette: [string, string]; // 카드 고유 그래디언트 월드 [from, to]
|
mock: MockKey; // 카드에 렌더할 라이트 목업 화면
|
||||||
accent: string; // 카드 포인트 컬러
|
href?: string; // 데모 링크 (있으면 클릭 가능)
|
||||||
href?: string; // 리뉴얼 완료된 샘플의 데모 링크
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SHOWCASE_SLOTS: ShowcaseSlot[] = [
|
export const SHOWCASE_SLOTS: ShowcaseSlot[] = [
|
||||||
{ slug: 'corporate', label: 'corporate', title: '기업 브랜드 사이트', desc: '신뢰를 첫인상으로 — 브랜드 스토리와 IR까지', palette: ['#13203a', '#0d2c54'], accent: '#60a5fa' },
|
{ slug: 'corporate', label: 'corporate', title: '기업 브랜드 사이트', desc: '신뢰를 첫인상으로 — 브랜드 스토리와 회사 소개', mock: 'site' },
|
||||||
{ slug: 'shopping', label: 'commerce', title: '커머스 스토어', desc: '탐색부터 결제까지 끊김 없는 구매 동선', palette: ['#1a1430', '#341a4f'], accent: '#c4b5fd' },
|
{ slug: 'commerce', label: 'commerce', title: '커머스 스토어', desc: '탐색부터 결제까지 끊김 없는 구매 동선', mock: 'commerce' },
|
||||||
{ slug: 'dashboard', label: 'dashboard', title: '데이터 대시보드', desc: '실시간 지표를 한눈에 — 의사결정용 화면', palette: ['#0f2922', '#14503c'], accent: '#6ee7b7' },
|
{ slug: 'dashboard', label: 'dashboard', title: '데이터 대시보드', desc: '실시간 지표를 한눈에 — 의사결정용 화면', mock: 'dashboard' },
|
||||||
{ slug: 'bakery', label: 'local shop', title: '로컬 매장 사이트', desc: '예약·주문이 자연스러운 동네 가게의 얼굴', palette: ['#2b1a10', '#4f2d14'], accent: '#fdba74' },
|
{ slug: 'automation', label: 'automation', title: '봇·자동화 알림', desc: '체결·알림·리포트를 사람 손 없이 자동 전송', mock: 'feed' },
|
||||||
{ slug: 'portfolio', label: 'portfolio', title: '포트폴리오', desc: '작업물이 주인공이 되는 미니멀 갤러리', palette: ['#101418', '#23272d'], accent: '#e2e8f0' },
|
{ slug: 'matching', label: 'matching', title: '조건 매칭 시스템', desc: '수집·필터·매칭으로 원하는 것만 골라내는 화면', mock: 'match' },
|
||||||
{ slug: 'game', label: 'game', title: '게임 프로모션', desc: '세계관에 빠져들게 하는 런칭 페이지', palette: ['#250f23', '#4a1342'], accent: '#f0abfc' },
|
{ slug: 'booking', label: 'local shop', title: '예약·매장 사이트', desc: '예약·주문이 자연스러운 동네 가게의 얼굴', mock: 'booking' },
|
||||||
{ slug: 'interior', label: 'interior', title: '인테리어 스튜디오', desc: '공간의 톤을 그대로 옮긴 쇼룸', palette: ['#1f2218', '#3a4028'], accent: '#d9f99d' },
|
{ slug: 'portfolio', label: 'portfolio', title: '포트폴리오', desc: '작업물이 주인공이 되는 미니멀 갤러리', mock: 'site' },
|
||||||
{ slug: 'reading', label: 'editorial', title: '에디토리얼·매거진', desc: '읽는 경험을 설계한 콘텐츠 사이트', palette: ['#101b2b', '#1f3a5f'], accent: '#93c5fd' },
|
{ slug: 'editorial', label: 'editorial', title: '에디토리얼·매거진', desc: '읽는 경험을 설계한 콘텐츠 사이트', mock: 'site' },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user