File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
"use client";
import type { CSSProperties, ReactNode } from "react";
import type { TokenEntry } from "./read-tokens";
import styles from "./dev-test.module.scss";
/**
* 토큰 **목록**은 CSSOM에서 읽으므로 여기에 적지 않는다. 이 표는 각 그룹을 어떻게
* 보여줄지와 한글 이름만 정한다. 목록에 없는 그룹이 새로 생겨도 값 표로 그려진다.
*
* - `swatch` 색을 칠한 카드
* - `type` 그 서체 속성을 실제로 적용한 문장
* - `demo` 그 토큰을 실제로 적용한 요소 (그룹별 렌더는 `DEMOS`)
* - `value` 값만
*/
export const TOKEN_GROUPS: {
key: string;
label: string;
kind: "swatch" | "type" | "demo" | "value";
note?: string;
}[] = [
{
key: "color",
label: "시맨틱 색상",
kind: "swatch",
note: "light/dark 값이 light-dark() 한 줄에 함께 정의돼 있습니다. 좌측 전환기로 두 테마를 대조해 보세요.",
},
{ key: "theme", label: "브랜드 램프", kind: "swatch", note: "primary · secondary · accent" },
{
key: "primitive",
label: "원시 팔레트",
kind: "swatch",
note: "화면에서 직접 쓰지 않는 바닥층입니다. 시맨틱 색상이 이 값들을 참조합니다.",
},
{
key: "font",
label: "서체",
kind: "type",
note: "각 토큰을 실제로 적용한 문장입니다. size 계열은 768px 경계에서 값이 바뀝니다.",
},
{
key: "padding",
label: "패딩",
kind: "demo",
note: "테두리와 문단 사이의 여백이 토큰 값입니다.",
},
{ key: "gap", label: "간격", kind: "demo", note: "두 문단 사이의 거리가 토큰 값입니다." },
{ key: "radius", label: "모서리 반경", kind: "demo" },
{
key: "icon",
label: "아이콘 크기",
kind: "demo",
note: "브라우저가 그리는 기본 체크박스를 토큰 크기로 키운 것입니다.",
},
{ key: "border", label: "테두리 두께", kind: "demo", note: "border-0은 값이 0이라 보이지 않습니다." },
{ key: "number", label: "원시 수치", kind: "value" },
{
key: "shadow",
label: "그림자 수치",
kind: "demo",
note: "Figma에는 offset·blur 수치만 있고 완성된 그림자 토큰이 없습니다. 각 수치의 효과를 보이려고 location은 `0 값 0`, blur는 `0 0 값`으로 조합했고 색은 alpha-default-inverse25를 씁니다 — 조합 자체는 토큰이 아닙니다.",
},
{
key: "backdrop",
label: "배경 흐림",
kind: "demo",
note: "오른쪽 절반에만 backdrop-filter를 걸어 원본과 대조합니다.",
},
{ key: "form", label: "폼", kind: "value" },
{ key: "card", label: "카드", kind: "value" },
{ key: "modal", label: "모달", kind: "value" },
{ key: "grid", label: "그리드", kind: "value" },
{ key: "spacing", label: "여백", kind: "value" },
{ key: "section", label: "섹션", kind: "value" },
];
/**
* ⚠️ 인라인 `style`을 쓰는 **유일하게 허용되는 예외**다. 개수를 미리 알 수 없는 토큰을
* 런타임에 순회해야 하는데, CSS 클래스는 알 수 없는 이름에 대해 만들 수 없다. 여기서
* 넣는 값은 디자인 값이 아니라 **토큰 참조**(`var(--fox-*)`)이며 실제 값은 여전히
* 토큰이 소유한다. 일반 컴포넌트에서는 이 패턴을 쓰지 않는다.
*/
function v(token: TokenEntry): string {
return `var(${token.property})`;
}
/**
* 화면에 표시할 토큰 식별자. `--fox-` 접두사만 뗀 **실제 이름**이라 CSS 변수
* (`--fox-color-background-default`)와 그대로 대응한다 — 함수 표기(`fox.color(...)`)는
* 그룹명이 괄호 밖으로 빠져 목록을 훑을 때 이름이 한눈에 안 들어온다.
*/
function label(token: TokenEntry): string {
return token.property.replace("--fox-", "");
}
// ── 그룹별 미리보기 ─────────────────────────────────────────────────────────
const DEMOS: Record<string, (token: TokenEntry) => ReactNode> = {
padding: (token) => (
<div className={styles.demoPadBox} style={{ padding: v(token) }}>
<p className={styles.demoText}>첫 번째 문단</p>
<p className={styles.demoText}>두 번째 문단</p>
</div>
),
gap: (token) => (
<div className={styles.demoGapBox} style={{ gap: v(token) }}>
<p className={styles.demoText}>첫 번째 문단</p>
<p className={styles.demoText}>두 번째 문단</p>
</div>
),
radius: (token) => <div className={styles.demoRadius} style={{ borderRadius: v(token) }} />,
// 브라우저가 자체적으로 그리는 컨트롤이라 별도 에셋 없이 크기만 확인할 수 있다.
icon: (token) => (
<input
type="checkbox"
defaultChecked
aria-label={`icon-${token.name} 크기 미리보기`}
className={styles.demoIcon}
style={{ inlineSize: v(token), blockSize: v(token) }}
/>
),
border: (token) => <div className={styles.demoBorder} style={{ borderWidth: v(token) }} />,
shadow: (token) => (
<div
className={styles.demoShadow}
style={{
boxShadow: token.name.startsWith("blur")
? `0 0 ${v(token)} var(--demo-shadow-color)`
: `0 ${v(token)} 0 var(--demo-shadow-color)`,
}}
/>
),
backdrop: (token) => (
<div className={styles.demoBackdrop}>
{/* eslint-disable-next-line @next/next/no-img-element -- @fox는 프레임워크에 의존하지
않아야 이식된다. next/image를 쓰면 이 폴더가 Next 전용이 되고 외부 호스트마다
remotePatterns 설정도 필요해진다. */}
<img
src="https://picsum.photos/seed/picsum/200/300"
alt=""
className={styles.demoBackdropImage}
/>
<div
className={styles.demoBackdropOverlay}
style={{
backdropFilter: `blur(${v(token)})`,
WebkitBackdropFilter: `blur(${v(token)})`,
}}
/>
</div>
),
};
// ── 렌더러 ──────────────────────────────────────────────────────────────────
function Swatches({ tokens }: { tokens: TokenEntry[] }) {
return (
<div className={styles.swatchGrid}>
{tokens.map((token) => (
<div key={token.property} className={styles.swatch}>
<div className={styles.swatchChip} style={{ background: v(token) }} />
<div className={styles.swatchMeta}>
<span className={styles.tokenName}>{label(token)}</span>
<span className={styles.tokenValue}>{token.value}</span>
</div>
</div>
))}
</div>
);
}
/** 서체 토큰은 이름 앞머리가 곧 적용할 CSS 속성이다. */
function typeStyle(token: TokenEntry): CSSProperties {
if (token.name.startsWith("family-")) {
return { fontFamily: v(token) };
}
if (token.name.startsWith("weight-")) {
return { fontWeight: v(token) };
}
return { fontSize: v(token) };
}
function TypeList({ tokens }: { tokens: TokenEntry[] }) {
return (
<div className={styles.typeList}>
{tokens.map((token) => (
<div key={token.property} className={styles.typeRow}>
<div className={styles.typeMeta}>
<span className={styles.tokenName}>{label(token)}</span>
<span className={styles.tokenValue}>{token.value}</span>
</div>
<p className={styles.typeSample} style={typeStyle(token)}>
다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123
</p>
</div>
))}
</div>
);
}
function TokenTable({
tokens,
demo,
}: {
tokens: TokenEntry[];
demo?: (token: TokenEntry) => ReactNode;
}) {
return (
<table className={styles.table}>
<thead>
<tr>
<th scope="col">토큰</th>
<th scope="col">값</th>
{demo ? <th scope="col">미리보기</th> : null}
</tr>
</thead>
<tbody>
{tokens.map((token) => (
<tr key={token.property}>
<td>{label(token)}</td>
<td className={styles.tdValue}>{token.value}</td>
{demo ? <td className={styles.tdDemo}>{demo(token)}</td> : null}
</tr>
))}
</tbody>
</table>
);
}
export function TokenView({ group, tokens }: { group: string; tokens: TokenEntry[] }) {
const meta = TOKEN_GROUPS.find((entry) => entry.key === group);
const kind = meta?.kind ?? "value";
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>
{meta?.label ?? group} · {tokens.length}
</h2>
{meta?.note ? <p className={styles.sectionNote}>{meta.note}</p> : null}
{kind === "swatch" ? <Swatches tokens={tokens} /> : null}
{kind === "type" ? <TypeList tokens={tokens} /> : null}
{kind === "demo" ? <TokenTable tokens={tokens} demo={DEMOS[group]} /> : null}
{kind === "value" ? <TokenTable tokens={tokens} /> : null}
</section>
);
}