feat: 디자인 시스템 테스트 하니스를 @fox/dev-test로 이관
토큰 배치 확인과 컴포넌트 예제를 한 페이지에서 본다. 사이드바는 토큰/컴포넌트 두 대분류이며, 토큰 그룹은 CSSOM에서 읽으므로 목록을 코드에 두지 않는다. 컴포넌트는 component-registry의 배열에 항목 하나만 추가하면 사이드바와 본문이 함께 갱신된다 — 만들 때마다 다른 파일을 고칠 필요가 없다. 하니스를 앱이 아니라 @fox에 두는 이유는 폴더를 다른 프로젝트로 복사할 때 함께 따라가게 하기 위함이다. 그래서 앱 상수(테마 저장 키 등)를 import 하지 않고, 테마 전환은 @fox가 소유한 data-theme 계약만 건드린다(저장하지 않는다). 선택 상태는 URL 해시가 SSOT라 새로고침해도 보던 섹션이 유지된다. 같은 목적의 카탈로그를 두 벌 두면 갈라지므로 구 app/design은 함께 제거한다. Co-Authored-By: Claude Opus 5
@9dc9ee30d32b190ba10dd2ff4a08765e0ed5530e
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -0,0 +1,40 @@ |
| 1 | +import type { ReactNode } from "react"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 컴포넌트 예제 하나. 사이드바의 "컴포넌트" 분류에 항목으로 뜬다. | |
| 5 | + */ | |
| 6 | +export interface ComponentExample { | |
| 7 | + /** 해시 링크와 React key에 쓰이는 식별자. kebab-case. */ | |
| 8 | + id: string; | |
| 9 | + /** 사이드바에 표시할 이름. */ | |
| 10 | + name: string; | |
| 11 | + /** 한 줄 설명 (선택). */ | |
| 12 | + description?: string; | |
| 13 | + /** | |
| 14 | + * 이 컴포넌트가 가질 수 있는 상태들. variant·size·disabled처럼 **눈으로 비교해야 하는 | |
| 15 | + * 조합을 빠짐없이** 넣는다 — 예제가 곧 회귀 확인 수단이다. | |
| 16 | + */ | |
| 17 | + variants: { label: string; node: ReactNode }[]; | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * 컴포넌트를 만들 때마다 여기에 한 항목씩 추가한다. 사이드바·본문은 이 배열만 보고 | |
| 22 | + * 그리므로 다른 파일을 고칠 필요가 없다. | |
| 23 | + * | |
| 24 | + * ```tsx | |
| 25 | + * import { Button } from "../core/components/button"; | |
| 26 | + * | |
| 27 | + * export const COMPONENT_EXAMPLES: ComponentExample[] = [ | |
| 28 | + * { | |
| 29 | + * id: "button", | |
| 30 | + * name: "Button", | |
| 31 | + * description: "기본 액션 버튼", | |
| 32 | + * variants: [ | |
| 33 | + * { label: "primary", node: <Button variant="primary">확인</Button> }, | |
| 34 | + * { label: "disabled", node: <Button disabled>확인</Button> }, | |
| 35 | + * ], | |
| 36 | + * }, | |
| 37 | + * ]; | |
| 38 | + * ``` | |
| 39 | + */ | |
| 40 | +export const COMPONENT_EXAMPLES: ComponentExample[] = []; |
+++ @fox/dev-test/component-view.tsx
... | ... | @@ -0,0 +1,46 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ComponentExample } from "./component-registry"; | |
| 4 | +import styles from "./dev-test.module.scss"; | |
| 5 | + | |
| 6 | +export function ComponentView({ example }: { example: ComponentExample }) { | |
| 7 | + return ( | |
| 8 | + <section className={styles.section}> | |
| 9 | + <h2 className={styles.sectionTitle}>{example.name}</h2> | |
| 10 | + {example.description ? ( | |
| 11 | + <p className={styles.sectionNote}>{example.description}</p> | |
| 12 | + ) : null} | |
| 13 | + | |
| 14 | + <div className={styles.variantList}> | |
| 15 | + {example.variants.map((variant) => ( | |
| 16 | + <div key={variant.label} className={styles.variant}> | |
| 17 | + <span className={styles.variantLabel}>{variant.label}</span> | |
| 18 | + <div className={styles.variantStage}>{variant.node}</div> | |
| 19 | + </div> | |
| 20 | + ))} | |
| 21 | + </div> | |
| 22 | + </section> | |
| 23 | + ); | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function ComponentEmpty() { | |
| 27 | + return ( | |
| 28 | + <section className={styles.section}> | |
| 29 | + <h2 className={styles.sectionTitle}>컴포넌트가 아직 없습니다</h2> | |
| 30 | + <p className={styles.sectionNote}> | |
| 31 | + 컴포넌트를 만들면 <code>@fox/dev-test/component-registry.tsx</code>의{" "} | |
| 32 | + <code>COMPONENT_EXAMPLES</code> 배열에 항목을 하나 추가하세요. 사이드바와 본문은 그 | |
| 33 | + 배열만 보고 그리므로 다른 파일은 고칠 필요가 없습니다. | |
| 34 | + </p> | |
| 35 | + <pre className={styles.code}>{`{ | |
| 36 | + id: "button", | |
| 37 | + name: "Button", | |
| 38 | + description: "기본 액션 버튼", | |
| 39 | + variants: [ | |
| 40 | + { label: "primary", node: <Button variant="primary">확인</Button> }, | |
| 41 | + { label: "disabled", node: <Button disabled>확인</Button> }, | |
| 42 | + ], | |
| 43 | +}`}</pre> | |
| 44 | + </section> | |
| 45 | + ); | |
| 46 | +} |
+++ @fox/dev-test/dev-test-page.tsx
... | ... | @@ -0,0 +1,141 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useSyncExternalStore } from "react"; | |
| 4 | +import { COMPONENT_EXAMPLES } from "./component-registry"; | |
| 5 | +import { ComponentEmpty, ComponentView } from "./component-view"; | |
| 6 | +import { ThemeSwitch } from "./theme-switch"; | |
| 7 | +import { TOKEN_GROUPS, TokenView } from "./token-view"; | |
| 8 | +import { useTokenRegistry } from "./use-token-registry"; | |
| 9 | +import styles from "./dev-test.module.scss"; | |
| 10 | + | |
| 11 | +// 선택 상태는 URL 해시가 SSOT다 — 새로고침하거나 링크를 공유해도 보던 섹션이 유지되고, | |
| 12 | +// React state를 따로 두지 않으므로 둘이 어긋날 일이 없다. | |
| 13 | +// 형식: `#token:color` / `#component:button` | |
| 14 | +function subscribeHash(onChange: () => void): () => void { | |
| 15 | + window.addEventListener("hashchange", onChange); | |
| 16 | + return () => window.removeEventListener("hashchange", onChange); | |
| 17 | +} | |
| 18 | + | |
| 19 | +function getHash(): string { | |
| 20 | + return window.location.hash.slice(1); | |
| 21 | +} | |
| 22 | + | |
| 23 | +function getServerHash(): string { | |
| 24 | + return ""; | |
| 25 | +} | |
| 26 | + | |
| 27 | +function useSelection(): { kind: string; id: string } | null { | |
| 28 | + const hash = useSyncExternalStore(subscribeHash, getHash, getServerHash); | |
| 29 | + const [kind, id] = hash.split(":"); | |
| 30 | + return kind && id ? { kind, id } : null; | |
| 31 | +} | |
| 32 | + | |
| 33 | +function select(kind: string, id: string): void { | |
| 34 | + window.location.hash = `${kind}:${id}`; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export function DevTestPage() { | |
| 38 | + const registry = useTokenRegistry(); | |
| 39 | + const selection = useSelection(); | |
| 40 | + | |
| 41 | + if (!registry) { | |
| 42 | + return ( | |
| 43 | + <div className={styles.shell}> | |
| 44 | + <p className={styles.loading}>토큰을 읽는 중…</p> | |
| 45 | + </div> | |
| 46 | + ); | |
| 47 | + } | |
| 48 | + | |
| 49 | + // 사이드바에 뜰 토큰 그룹 — 정의된 순서를 우선하고, 목록에 없는 그룹은 뒤에 붙인다. | |
| 50 | + const known = TOKEN_GROUPS.filter((group) => registry[group.key]?.length); | |
| 51 | + const extra = Object.keys(registry) | |
| 52 | + .filter((key) => !TOKEN_GROUPS.some((group) => group.key === key)) | |
| 53 | + .map((key) => ({ key, label: key })); | |
| 54 | + const tokenGroups = [...known, ...extra]; | |
| 55 | + | |
| 56 | + const current = selection ?? { kind: "token", id: tokenGroups[0]?.key ?? "" }; | |
| 57 | + const totalTokens = Object.values(registry).reduce((sum, list) => sum + list.length, 0); | |
| 58 | + | |
| 59 | + return ( | |
| 60 | + <div className={styles.shell}> | |
| 61 | + <aside className={styles.sidebar}> | |
| 62 | + <div className={styles.brand}> | |
| 63 | + <span className={styles.brandTitle}>@fox 디자인 시스템</span> | |
| 64 | + <span className={styles.brandNote}>개발 전용 테스트 페이지</span> | |
| 65 | + </div> | |
| 66 | + | |
| 67 | + <ThemeSwitch /> | |
| 68 | + | |
| 69 | + <nav className={styles.nav} aria-label="섹션"> | |
| 70 | + <div className={styles.navGroup}> | |
| 71 | + <h2 className={styles.navGroupTitle}>토큰 · {totalTokens}</h2> | |
| 72 | + <ul className={styles.navList}> | |
| 73 | + {tokenGroups.map((group) => ( | |
| 74 | + <li key={group.key}> | |
| 75 | + <button | |
| 76 | + type="button" | |
| 77 | + className={styles.navItem} | |
| 78 | + aria-current={ | |
| 79 | + current.kind === "token" && current.id === group.key ? "page" : undefined | |
| 80 | + } | |
| 81 | + onClick={() => select("token", group.key)} | |
| 82 | + > | |
| 83 | + <span>{group.label}</span> | |
| 84 | + <span className={styles.navCount}>{registry[group.key].length}</span> | |
| 85 | + </button> | |
| 86 | + </li> | |
| 87 | + ))} | |
| 88 | + </ul> | |
| 89 | + </div> | |
| 90 | + | |
| 91 | + <div className={styles.navGroup}> | |
| 92 | + <h2 className={styles.navGroupTitle}>컴포넌트 · {COMPONENT_EXAMPLES.length}</h2> | |
| 93 | + {COMPONENT_EXAMPLES.length ? ( | |
| 94 | + <ul className={styles.navList}> | |
| 95 | + {COMPONENT_EXAMPLES.map((example) => ( | |
| 96 | + <li key={example.id}> | |
| 97 | + <button | |
| 98 | + type="button" | |
| 99 | + className={styles.navItem} | |
| 100 | + aria-current={ | |
| 101 | + current.kind === "component" && current.id === example.id | |
| 102 | + ? "page" | |
| 103 | + : undefined | |
| 104 | + } | |
| 105 | + onClick={() => select("component", example.id)} | |
| 106 | + > | |
| 107 | + <span>{example.name}</span> | |
| 108 | + <span className={styles.navCount}>{example.variants.length}</span> | |
| 109 | + </button> | |
| 110 | + </li> | |
| 111 | + ))} | |
| 112 | + </ul> | |
| 113 | + ) : ( | |
| 114 | + <button | |
| 115 | + type="button" | |
| 116 | + className={styles.navItem} | |
| 117 | + aria-current={current.kind === "component" ? "page" : undefined} | |
| 118 | + onClick={() => select("component", "none")} | |
| 119 | + > | |
| 120 | + <span>추가 방법</span> | |
| 121 | + </button> | |
| 122 | + )} | |
| 123 | + </div> | |
| 124 | + </nav> | |
| 125 | + </aside> | |
| 126 | + | |
| 127 | + <main className={styles.content}> | |
| 128 | + {current.kind === "component" ? ( | |
| 129 | + (() => { | |
| 130 | + const example = COMPONENT_EXAMPLES.find((entry) => entry.id === current.id); | |
| 131 | + return example ? <ComponentView example={example} /> : <ComponentEmpty />; | |
| 132 | + })() | |
| 133 | + ) : registry[current.id]?.length ? ( | |
| 134 | + <TokenView group={current.id} tokens={registry[current.id]} /> | |
| 135 | + ) : ( | |
| 136 | + <p className={styles.loading}>선택한 토큰 그룹이 없습니다.</p> | |
| 137 | + )} | |
| 138 | + </main> | |
| 139 | + </div> | |
| 140 | + ); | |
| 141 | +} |
+++ @fox/dev-test/dev-test.module.scss
... | ... | @@ -0,0 +1,294 @@ |
| 1 | +// 개발 전용 테스트 페이지 스타일. | |
| 2 | +// | |
| 3 | +// 이 파일 자체가 토큰 규율의 첫 소비처다 — 여기 있는 모든 값은 `fox.*()` 함수를 거치므로, | |
| 4 | +// 없는 토큰을 쓰면 빌드가 실패한다. 페이지가 그려진다는 것은 사용된 토큰이 전부 실재한다는 | |
| 5 | +// 뜻이기도 하다. | |
| 6 | + | |
| 7 | +@use "@fox/styles/abstracts" as fox; | |
| 8 | + | |
| 9 | +.shell { | |
| 10 | + display: grid; | |
| 11 | + grid-template-columns: 1fr; | |
| 12 | + min-block-size: 100vh; | |
| 13 | + background: fox.color(background-default); | |
| 14 | + color: fox.color(font-neutral-default); | |
| 15 | + | |
| 16 | + @include fox.pc { | |
| 17 | + grid-template-columns: 26rem 1fr; | |
| 18 | + } | |
| 19 | +} | |
| 20 | + | |
| 21 | +.loading { | |
| 22 | + padding: fox.padding(8); | |
| 23 | + color: fox.color(font-neutral-subtle); | |
| 24 | + font-size: fox.font-size(body-md); | |
| 25 | +} | |
| 26 | + | |
| 27 | +// ── 사이드바 ──────────────────────────────────────────────────────────────── | |
| 28 | +.sidebar { | |
| 29 | + display: flex; | |
| 30 | + flex-direction: column; | |
| 31 | + gap: fox.gap(5); | |
| 32 | + padding: fox.padding(7); | |
| 33 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 34 | + background: fox.color(surface-neutral-gray); | |
| 35 | + | |
| 36 | + @include fox.pc { | |
| 37 | + position: sticky; | |
| 38 | + inset-block-start: 0; | |
| 39 | + block-size: 100vh; | |
| 40 | + overflow-y: auto; | |
| 41 | + border-block-end: 0; | |
| 42 | + border-inline-end: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +.brand { | |
| 47 | + display: flex; | |
| 48 | + flex-direction: column; | |
| 49 | + gap: fox.gap(1); | |
| 50 | +} | |
| 51 | + | |
| 52 | +.brandTitle { | |
| 53 | + font-size: fox.font-size(heading-sm); | |
| 54 | + font-weight: fox.font-weight(bold); | |
| 55 | +} | |
| 56 | + | |
| 57 | +.brandNote { | |
| 58 | + color: fox.color(font-neutral-subtler); | |
| 59 | + font-size: fox.font-size(label-xsm); | |
| 60 | +} | |
| 61 | + | |
| 62 | +.themeSwitch { | |
| 63 | + display: flex; | |
| 64 | + gap: fox.gap(1); | |
| 65 | + padding: fox.padding(1); | |
| 66 | + border: fox.border(1) solid fox.color(border-neutral-default); | |
| 67 | + border-radius: fox.radius(max); | |
| 68 | + background: fox.color(surface-neutral-default); | |
| 69 | +} | |
| 70 | + | |
| 71 | +.themeButton { | |
| 72 | + flex: 1; | |
| 73 | + padding: fox.padding(3) fox.padding(4); | |
| 74 | + border-radius: fox.radius(max); | |
| 75 | + color: fox.color(font-neutral-subtle); | |
| 76 | + font-size: fox.font-size(label-xsm); | |
| 77 | + font-weight: fox.font-weight(medium); | |
| 78 | + | |
| 79 | + &[aria-pressed="true"] { | |
| 80 | + background: fox.color(button-primary-surface); | |
| 81 | + color: fox.color(button-primary-font); | |
| 82 | + } | |
| 83 | + | |
| 84 | + &:focus-visible { | |
| 85 | + outline: fox.border(2) solid fox.color(border-theme-primary); | |
| 86 | + outline-offset: fox.border(2); | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +.nav { | |
| 91 | + display: flex; | |
| 92 | + flex-direction: column; | |
| 93 | + gap: fox.gap(6); | |
| 94 | +} | |
| 95 | + | |
| 96 | +.navGroup { | |
| 97 | + display: flex; | |
| 98 | + flex-direction: column; | |
| 99 | + gap: fox.gap(2); | |
| 100 | +} | |
| 101 | + | |
| 102 | +.navGroupTitle { | |
| 103 | + padding-inline: fox.padding(3); | |
| 104 | + color: fox.color(font-neutral-subtler); | |
| 105 | + font-size: fox.font-size(label-xsm); | |
| 106 | + font-weight: fox.font-weight(bold); | |
| 107 | + letter-spacing: 0.04em; | |
| 108 | +} | |
| 109 | + | |
| 110 | +.navList { | |
| 111 | + display: flex; | |
| 112 | + flex-direction: column; | |
| 113 | + gap: fox.gap(1); | |
| 114 | + margin: 0; | |
| 115 | + padding: 0; | |
| 116 | + list-style: none; | |
| 117 | +} | |
| 118 | + | |
| 119 | +.navItem { | |
| 120 | + display: flex; | |
| 121 | + inline-size: 100%; | |
| 122 | + align-items: center; | |
| 123 | + justify-content: space-between; | |
| 124 | + gap: fox.gap(3); | |
| 125 | + padding: fox.padding(3) fox.padding(4); | |
| 126 | + border-radius: fox.radius(3); | |
| 127 | + color: fox.color(font-neutral-subtle); | |
| 128 | + font-size: fox.font-size(label-sm); | |
| 129 | + text-align: start; | |
| 130 | + | |
| 131 | + &:hover { | |
| 132 | + background: fox.color(button-default-surface-hover); | |
| 133 | + color: fox.color(font-neutral-default); | |
| 134 | + } | |
| 135 | + | |
| 136 | + &[aria-current="page"] { | |
| 137 | + background: fox.color(button-primary-surface); | |
| 138 | + color: fox.color(button-primary-font); | |
| 139 | + } | |
| 140 | + | |
| 141 | + &:focus-visible { | |
| 142 | + outline: fox.border(2) solid fox.color(border-theme-primary); | |
| 143 | + outline-offset: fox.border(1); | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +.navCount { | |
| 148 | + color: currentcolor; | |
| 149 | + font-size: fox.font-size(label-xsm); | |
| 150 | + opacity: 0.7; | |
| 151 | +} | |
| 152 | + | |
| 153 | +// ── 본문 ──────────────────────────────────────────────────────────────────── | |
| 154 | +.content { | |
| 155 | + min-inline-size: 0; | |
| 156 | + padding: fox.spacing(top-md) fox.padding(7) fox.spacing(bottom-xlg); | |
| 157 | + | |
| 158 | + @include fox.pc { | |
| 159 | + padding: fox.spacing(top-lg) fox.padding(11) fox.spacing(bottom-xlg); | |
| 160 | + } | |
| 161 | +} | |
| 162 | + | |
| 163 | +.section { | |
| 164 | + display: flex; | |
| 165 | + flex-direction: column; | |
| 166 | +} | |
| 167 | + | |
| 168 | +.sectionTitle { | |
| 169 | + font-size: fox.font-size(heading-md); | |
| 170 | + font-weight: fox.font-weight(bold); | |
| 171 | +} | |
| 172 | + | |
| 173 | +.sectionNote { | |
| 174 | + margin-block: fox.gap(2) fox.gap(6); | |
| 175 | + color: fox.color(font-neutral-subtle); | |
| 176 | + font-size: fox.font-size(body-sm); | |
| 177 | +} | |
| 178 | + | |
| 179 | +.code { | |
| 180 | + overflow-x: auto; | |
| 181 | + margin-block-start: fox.gap(4); | |
| 182 | + padding: fox.padding(6); | |
| 183 | + border: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 184 | + border-radius: fox.radius(3); | |
| 185 | + background: fox.color(surface-neutral-gray); | |
| 186 | + color: fox.color(font-neutral-default); | |
| 187 | + font-size: fox.font-size(body-sm); | |
| 188 | +} | |
| 189 | + | |
| 190 | +// ── 색상 스와치 ───────────────────────────────────────────────────────────── | |
| 191 | +.swatchGrid { | |
| 192 | + display: grid; | |
| 193 | + grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr)); | |
| 194 | + gap: fox.gap(4); | |
| 195 | +} | |
| 196 | + | |
| 197 | +.swatch { | |
| 198 | + overflow: hidden; | |
| 199 | + border: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 200 | + border-radius: fox.radius(3); | |
| 201 | + background: fox.color(surface-neutral-default); | |
| 202 | +} | |
| 203 | + | |
| 204 | +.swatchChip { | |
| 205 | + block-size: fox.icon(7); | |
| 206 | + border-block-end: fox.border(1) solid fox.color(border-neutral-overlay); | |
| 207 | +} | |
| 208 | + | |
| 209 | +.swatchMeta { | |
| 210 | + padding: fox.padding(5); | |
| 211 | +} | |
| 212 | + | |
| 213 | +.tokenName { | |
| 214 | + display: block; | |
| 215 | + color: fox.color(font-neutral-default); | |
| 216 | + font-size: fox.font-size(label-sm); | |
| 217 | + font-weight: fox.font-weight(medium); | |
| 218 | + word-break: break-all; | |
| 219 | +} | |
| 220 | + | |
| 221 | +.tokenValue { | |
| 222 | + display: block; | |
| 223 | + margin-block-start: fox.gap(1); | |
| 224 | + color: fox.color(font-neutral-subtler); | |
| 225 | + font-size: fox.font-size(body-xsm); | |
| 226 | + word-break: break-all; | |
| 227 | +} | |
| 228 | + | |
| 229 | +// ── 값 테이블 ─────────────────────────────────────────────────────────────── | |
| 230 | +.table { | |
| 231 | + inline-size: 100%; | |
| 232 | + border-collapse: collapse; | |
| 233 | + text-align: start; | |
| 234 | +} | |
| 235 | + | |
| 236 | +.table th, | |
| 237 | +.table td { | |
| 238 | + padding: fox.padding(3) fox.padding(5); | |
| 239 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 240 | +} | |
| 241 | + | |
| 242 | +.table th { | |
| 243 | + color: fox.color(font-neutral-subtler); | |
| 244 | + font-size: fox.font-size(label-xsm); | |
| 245 | + font-weight: fox.font-weight(medium); | |
| 246 | +} | |
| 247 | + | |
| 248 | +.table td { | |
| 249 | + color: fox.color(font-neutral-default); | |
| 250 | + font-size: fox.font-size(body-sm); | |
| 251 | +} | |
| 252 | + | |
| 253 | +.tdValue { | |
| 254 | + color: fox.color(font-neutral-subtle); | |
| 255 | +} | |
| 256 | + | |
| 257 | +// 크기 토큰을 눈으로 비교하기 위한 막대. 폭은 인라인 style이 토큰을 참조해 넣는다. | |
| 258 | +.bar { | |
| 259 | + display: block; | |
| 260 | + block-size: fox.icon(2); | |
| 261 | + border-radius: fox.radius(1); | |
| 262 | + background: fox.color(surface-theme-primary); | |
| 263 | +} | |
| 264 | + | |
| 265 | +// ── 컴포넌트 예제 ─────────────────────────────────────────────────────────── | |
| 266 | +.variantList { | |
| 267 | + display: flex; | |
| 268 | + flex-direction: column; | |
| 269 | + gap: fox.gap(5); | |
| 270 | +} | |
| 271 | + | |
| 272 | +.variant { | |
| 273 | + display: flex; | |
| 274 | + flex-direction: column; | |
| 275 | + gap: fox.gap(2); | |
| 276 | +} | |
| 277 | + | |
| 278 | +.variantLabel { | |
| 279 | + color: fox.color(font-neutral-subtler); | |
| 280 | + font-size: fox.font-size(label-xsm); | |
| 281 | + font-weight: fox.font-weight(medium); | |
| 282 | +} | |
| 283 | + | |
| 284 | +// 예제를 얹는 무대. 컴포넌트 자체의 배경·여백과 섞이지 않도록 중립 표면 위에 올린다. | |
| 285 | +.variantStage { | |
| 286 | + display: flex; | |
| 287 | + flex-wrap: wrap; | |
| 288 | + gap: fox.gap(4); | |
| 289 | + align-items: center; | |
| 290 | + padding: fox.padding(7); | |
| 291 | + border: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 292 | + border-radius: fox.radius(3); | |
| 293 | + background: fox.color(surface-neutral-default); | |
| 294 | +} |
+++ @fox/dev-test/index.ts
... | ... | @@ -0,0 +1,2 @@ |
| 1 | +export { DevTestPage } from "./dev-test-page"; | |
| 2 | +export { COMPONENT_EXAMPLES, type ComponentExample } from "./component-registry"; |
--- app/design/_lib/read-tokens.ts
+++ @fox/dev-test/read-tokens.ts
... | ... | @@ -6,7 +6,7 @@ |
| 6 | 6 |
* |
| 7 | 7 |
* 선언 원문이 아니라 계산값을 쓴다 — 색상은 Lightning CSS가 `light-dark()`를 |
| 8 | 8 |
* `var(--lightningcss-light, …) var(--lightningcss-dark, …)`로 폴리필해 내보내므로 |
| 9 |
- * 원문이 읽기 어렵고, 크기는 `alias`가 `var(--fox-…)` 참조라 원문만으로는 값을 알 수 |
|
| 9 |
+ * 원문이 읽기 어렵고, 크기는 alias가 `var(--fox-…)` 참조라 원문만으로는 값을 알 수 |
|
| 10 | 10 |
* 없다. 계산값은 지금 화면에 실제로 적용되는 값과 정확히 일치한다. |
| 11 | 11 |
*/ |
| 12 | 12 |
value: string; |
... | ... | @@ -21,8 +21,8 @@ |
| 21 | 21 |
/** |
| 22 | 22 |
* 문서의 모든 스타일시트를 훑어 `--fox-*` 커스텀 프로퍼티를 그룹별로 모은다. |
| 23 | 23 |
* |
| 24 |
- * 토큰 목록을 카탈로그가 따로 들고 있지 않게 하려는 것이 목적이다 — `@fox/styles/tokens/`에 |
|
| 25 |
- * 값을 추가하면 카탈로그에 자동으로 나타나고, 목록이 두 곳에 존재해 어긋날 일이 없다. |
|
| 24 |
+ * 테스트 페이지가 토큰 목록을 따로 들고 있지 않게 하려는 것이 목적이다 — `tokens/`에 |
|
| 25 |
+ * 값을 추가하면 여기에 자동으로 나타나고, 목록이 두 곳에 존재해 어긋날 일이 없다. |
|
| 26 | 26 |
* CSSOM은 선언 순서를 보존하므로 SCSS map에 적은 순서가 그대로 유지된다. |
| 27 | 27 |
* |
| 28 | 28 |
* 브라우저에서만 동작한다(문서가 있어야 한다). |
+++ @fox/dev-test/theme-switch.tsx
... | ... | @@ -0,0 +1,67 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useSyncExternalStore } from "react"; | |
| 4 | +import { THEME_ATTRIBUTE } from "./use-token-registry"; | |
| 5 | +import styles from "./dev-test.module.scss"; | |
| 6 | + | |
| 7 | +type ThemeChoice = "system" | "light" | "dark"; | |
| 8 | + | |
| 9 | +const CHOICES: { value: ThemeChoice; label: string }[] = [ | |
| 10 | + { value: "system", label: "시스템" }, | |
| 11 | + { value: "light", label: "라이트" }, | |
| 12 | + { value: "dark", label: "다크" }, | |
| 13 | +]; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * `<html data-theme>` 자체가 SSOT이므로 React state를 두지 않고 DOM을 구독한다 — | |
| 17 | + * 서버/클라이언트 출력이 갈리지 않아 hydration 불일치 표면적이 0이다. | |
| 18 | + * | |
| 19 | + * 개발용 미리보기 컨트롤이라 **선택을 저장하지 않는다.** 저장 키는 호스트 앱이 소유하는 | |
| 20 | + * 값이라(앱마다 다르다) `@fox`가 알 필요가 없고, 알면 포터빌리티가 깨진다. | |
| 21 | + */ | |
| 22 | +function subscribe(onChange: () => void): () => void { | |
| 23 | + const observer = new MutationObserver(onChange); | |
| 24 | + observer.observe(document.documentElement, { | |
| 25 | + attributes: true, | |
| 26 | + attributeFilter: [THEME_ATTRIBUTE], | |
| 27 | + }); | |
| 28 | + return () => observer.disconnect(); | |
| 29 | +} | |
| 30 | + | |
| 31 | +function getSnapshot(): ThemeChoice { | |
| 32 | + const value = document.documentElement.getAttribute(THEME_ATTRIBUTE); | |
| 33 | + return value === "light" || value === "dark" ? value : "system"; | |
| 34 | +} | |
| 35 | + | |
| 36 | +function getServerSnapshot(): ThemeChoice { | |
| 37 | + return "system"; | |
| 38 | +} | |
| 39 | + | |
| 40 | +function apply(next: ThemeChoice): void { | |
| 41 | + const root = document.documentElement; | |
| 42 | + if (next === "system") { | |
| 43 | + root.removeAttribute(THEME_ATTRIBUTE); | |
| 44 | + } else { | |
| 45 | + root.setAttribute(THEME_ATTRIBUTE, next); | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +export function ThemeSwitch() { | |
| 50 | + const current = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); | |
| 51 | + | |
| 52 | + return ( | |
| 53 | + <div className={styles.themeSwitch} role="group" aria-label="테마 전환"> | |
| 54 | + {CHOICES.map(({ value, label }) => ( | |
| 55 | + <button | |
| 56 | + key={value} | |
| 57 | + type="button" | |
| 58 | + className={styles.themeButton} | |
| 59 | + aria-pressed={current === value} | |
| 60 | + onClick={() => apply(value)} | |
| 61 | + > | |
| 62 | + {label} | |
| 63 | + </button> | |
| 64 | + ))} | |
| 65 | + </div> | |
| 66 | + ); | |
| 67 | +} |
+++ @fox/dev-test/token-view.tsx
... | ... | @@ -0,0 +1,135 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { CSSProperties } from "react"; | |
| 4 | +import type { TokenEntry } from "./read-tokens"; | |
| 5 | +import styles from "./dev-test.module.scss"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 토큰 **목록**은 CSSOM에서 읽으므로 여기에 적지 않는다. 이 표는 각 그룹을 어떻게 | |
| 9 | + * 보여줄지(색은 스와치, 길이는 막대, 나머지는 값)와 한글 이름만 정한다. | |
| 10 | + * 목록에 없는 그룹이 새로 생겨도 값 표로 그려진다 — 빠지지 않는다. | |
| 11 | + */ | |
| 12 | +export const TOKEN_GROUPS: { | |
| 13 | + key: string; | |
| 14 | + label: string; | |
| 15 | + kind: "swatch" | "bar" | "value"; | |
| 16 | + note?: string; | |
| 17 | +}[] = [ | |
| 18 | + { | |
| 19 | + key: "color", | |
| 20 | + label: "시맨틱 색상", | |
| 21 | + kind: "swatch", | |
| 22 | + note: "light/dark 값이 light-dark() 한 줄에 함께 정의돼 있습니다. 좌측 전환기로 두 테마를 대조해 보세요.", | |
| 23 | + }, | |
| 24 | + { key: "theme", label: "브랜드 램프", kind: "swatch", note: "primary · secondary · accent" }, | |
| 25 | + { | |
| 26 | + key: "primitive", | |
| 27 | + label: "원시 팔레트", | |
| 28 | + kind: "swatch", | |
| 29 | + note: "화면에서 직접 쓰지 않는 바닥층입니다. 시맨틱 색상이 이 값들을 참조합니다.", | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + key: "font", | |
| 33 | + label: "서체", | |
| 34 | + kind: "value", | |
| 35 | + note: "size 계열은 768px 경계에서 값이 바뀝니다.", | |
| 36 | + }, | |
| 37 | + { key: "padding", label: "패딩", kind: "bar" }, | |
| 38 | + { key: "gap", label: "간격", kind: "bar" }, | |
| 39 | + { key: "radius", label: "모서리 반경", kind: "bar" }, | |
| 40 | + { key: "icon", label: "아이콘 크기", kind: "bar" }, | |
| 41 | + { key: "border", label: "테두리 두께", kind: "bar" }, | |
| 42 | + { key: "number", label: "원시 수치", kind: "bar" }, | |
| 43 | + { key: "shadow", label: "그림자 수치", kind: "value" }, | |
| 44 | + { key: "backdrop", label: "배경 흐림", kind: "value" }, | |
| 45 | + { key: "form", label: "폼", kind: "value" }, | |
| 46 | + { key: "card", label: "카드", kind: "value" }, | |
| 47 | + { key: "modal", label: "모달", kind: "value" }, | |
| 48 | + { key: "grid", label: "그리드", kind: "value" }, | |
| 49 | + { key: "spacing", label: "여백", kind: "value" }, | |
| 50 | + { key: "section", label: "섹션", kind: "value" }, | |
| 51 | +]; | |
| 52 | + | |
| 53 | +/** | |
| 54 | + * ⚠️ 인라인 `style`을 쓰는 **유일하게 허용되는 예외**다. 개수를 미리 알 수 없는 토큰을 | |
| 55 | + * 런타임에 순회해야 하는데, CSS 클래스는 알 수 없는 이름에 대해 만들 수 없다. 여기서 | |
| 56 | + * 넣는 값은 디자인 값이 아니라 **토큰 참조**(`var(--fox-*)`)이며 실제 값은 여전히 | |
| 57 | + * 토큰이 소유한다. 일반 컴포넌트에서는 이 패턴을 쓰지 않는다. | |
| 58 | + */ | |
| 59 | +function ref(property: keyof CSSProperties, token: TokenEntry): CSSProperties { | |
| 60 | + return { [property]: `var(${token.property})` } as CSSProperties; | |
| 61 | +} | |
| 62 | + | |
| 63 | +function Swatches({ tokens, fn }: { tokens: TokenEntry[]; fn: string }) { | |
| 64 | + return ( | |
| 65 | + <div className={styles.swatchGrid}> | |
| 66 | + {tokens.map((token) => ( | |
| 67 | + <div key={token.property} className={styles.swatch}> | |
| 68 | + <div className={styles.swatchChip} style={ref("background", token)} /> | |
| 69 | + <div className={styles.swatchMeta}> | |
| 70 | + <span className={styles.tokenName}> | |
| 71 | + fox.{fn}({token.name}) | |
| 72 | + </span> | |
| 73 | + <span className={styles.tokenValue}>{token.value}</span> | |
| 74 | + </div> | |
| 75 | + </div> | |
| 76 | + ))} | |
| 77 | + </div> | |
| 78 | + ); | |
| 79 | +} | |
| 80 | + | |
| 81 | +function ValueTable({ | |
| 82 | + tokens, | |
| 83 | + fn, | |
| 84 | + withBar, | |
| 85 | +}: { | |
| 86 | + tokens: TokenEntry[]; | |
| 87 | + fn: string; | |
| 88 | + withBar: boolean; | |
| 89 | +}) { | |
| 90 | + return ( | |
| 91 | + <table className={styles.table}> | |
| 92 | + <thead> | |
| 93 | + <tr> | |
| 94 | + <th scope="col">토큰</th> | |
| 95 | + <th scope="col">값</th> | |
| 96 | + {withBar ? <th scope="col">크기</th> : null} | |
| 97 | + </tr> | |
| 98 | + </thead> | |
| 99 | + <tbody> | |
| 100 | + {tokens.map((token) => ( | |
| 101 | + <tr key={token.property}> | |
| 102 | + <td> | |
| 103 | + fox.{fn}({token.name}) | |
| 104 | + </td> | |
| 105 | + <td className={styles.tdValue}>{token.value}</td> | |
| 106 | + {withBar ? ( | |
| 107 | + <td> | |
| 108 | + <span className={styles.bar} style={ref("inlineSize", token)} /> | |
| 109 | + </td> | |
| 110 | + ) : null} | |
| 111 | + </tr> | |
| 112 | + ))} | |
| 113 | + </tbody> | |
| 114 | + </table> | |
| 115 | + ); | |
| 116 | +} | |
| 117 | + | |
| 118 | +export function TokenView({ group, tokens }: { group: string; tokens: TokenEntry[] }) { | |
| 119 | + const meta = TOKEN_GROUPS.find((entry) => entry.key === group); | |
| 120 | + const kind = meta?.kind ?? "value"; | |
| 121 | + | |
| 122 | + return ( | |
| 123 | + <section className={styles.section}> | |
| 124 | + <h2 className={styles.sectionTitle}> | |
| 125 | + {meta?.label ?? group} · {tokens.length} | |
| 126 | + </h2> | |
| 127 | + {meta?.note ? <p className={styles.sectionNote}>{meta.note}</p> : null} | |
| 128 | + {kind === "swatch" ? ( | |
| 129 | + <Swatches tokens={tokens} fn={group} /> | |
| 130 | + ) : ( | |
| 131 | + <ValueTable tokens={tokens} fn={group} withBar={kind === "bar"} /> | |
| 132 | + )} | |
| 133 | + </section> | |
| 134 | + ); | |
| 135 | +} |
+++ @fox/dev-test/use-token-registry.ts
... | ... | @@ -0,0 +1,58 @@ |
| 1 | +import { useSyncExternalStore } from "react"; | |
| 2 | +import { readTokenRegistry, type TokenRegistry } from "./read-tokens"; | |
| 3 | + | |
| 4 | +// CSSOM은 React 바깥의 외부 시스템이라 `useSyncExternalStore`가 맞는 도구다 | |
| 5 | +// (effect에서 setState 하면 렌더가 연쇄된다). | |
| 6 | +// | |
| 7 | +// 토큰 **값**은 테마와 뷰포트에 따라 달라지므로 둘 중 하나가 바뀌면 다시 읽어야 한다. | |
| 8 | +// 반면 `useSyncExternalStore`는 매 렌더마다 getSnapshot을 호출하므로 매번 새 객체를 | |
| 9 | +// 돌려주면 무한 루프가 된다 — 그래서 (테마, 뷰포트 구간)을 키로 캐시해 같은 조건 | |
| 10 | +// 동안에는 동일한 참조를 반환한다. | |
| 11 | +let cached: { key: string; registry: TokenRegistry } | null = null; | |
| 12 | + | |
| 13 | +/** `@fox/styles/_root.scss`가 읽는 수동 테마 선택 속성. */ | |
| 14 | +export const THEME_ATTRIBUTE = "data-theme"; | |
| 15 | + | |
| 16 | +function snapshotKey(): string { | |
| 17 | + const theme = document.documentElement.getAttribute(THEME_ATTRIBUTE) ?? "system"; | |
| 18 | + // 폭 자체가 아니라 "반응형 토큰이 바뀌는 구간"만 키에 넣는다 — 창을 1px 줄일 때마다 | |
| 19 | + // 전체 토큰을 다시 읽으면 낭비다. | |
| 20 | + const wide = window.matchMedia("(min-width: 768px)").matches ? "pc" : "mobile"; | |
| 21 | + return `${theme}:${wide}`; | |
| 22 | +} | |
| 23 | + | |
| 24 | +function subscribe(onChange: () => void): () => void { | |
| 25 | + const observer = new MutationObserver(onChange); | |
| 26 | + observer.observe(document.documentElement, { | |
| 27 | + attributes: true, | |
| 28 | + attributeFilter: [THEME_ATTRIBUTE], | |
| 29 | + }); | |
| 30 | + | |
| 31 | + const query = window.matchMedia("(min-width: 768px)"); | |
| 32 | + query.addEventListener("change", onChange); | |
| 33 | + | |
| 34 | + return () => { | |
| 35 | + observer.disconnect(); | |
| 36 | + query.removeEventListener("change", onChange); | |
| 37 | + }; | |
| 38 | +} | |
| 39 | + | |
| 40 | +function getSnapshot(): TokenRegistry | null { | |
| 41 | + const key = snapshotKey(); | |
| 42 | + if (!cached || cached.key !== key) { | |
| 43 | + cached = { key, registry: readTokenRegistry() }; | |
| 44 | + } | |
| 45 | + return cached.registry; | |
| 46 | +} | |
| 47 | + | |
| 48 | +function getServerSnapshot(): TokenRegistry | null { | |
| 49 | + return null; | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** | |
| 53 | + * 서버 렌더와 하이드레이션 첫 렌더에서는 `null`, 그 이후 토큰 레지스트리를 반환한다. | |
| 54 | + * 테마를 바꾸거나 768px 경계를 넘으면 그 조건에서 해석된 값으로 갱신된다. | |
| 55 | + */ | |
| 56 | +export function useTokenRegistry(): TokenRegistry | null { | |
| 57 | + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); | |
| 58 | +} |
--- app/design/_components/theme-switch.tsx
... | ... | @@ -1,80 +0,0 @@ |
| 1 | -"use client"; | |
| 2 | - | |
| 3 | -import { useSyncExternalStore } from "react"; | |
| 4 | -import { | |
| 5 | - DEFAULT_THEME_PREFERENCE, | |
| 6 | - THEME_ATTRIBUTE_NAME, | |
| 7 | - THEME_PREFERENCE_CYCLE, | |
| 8 | - THEME_STORAGE_KEY, | |
| 9 | - type ThemePreference, | |
| 10 | -} from "@/lib/constants/theme"; | |
| 11 | -import styles from "../design.module.scss"; | |
| 12 | - | |
| 13 | -const LABELS: Record<ThemePreference, string> = { | |
| 14 | - system: "시스템", | |
| 15 | - light: "라이트", | |
| 16 | - dark: "다크", | |
| 17 | -}; | |
| 18 | - | |
| 19 | -/** | |
| 20 | - * `<html data-theme>` 자체가 SSOT이므로 React state를 두지 않고 DOM을 직접 구독한다 | |
| 21 | - * (앱 헤더의 테마 토글과 동일한 원칙 — 서버/클라이언트 출력이 갈리지 않는다). | |
| 22 | - * 서버 스냅샷은 항상 기본값(system)이라 첫 렌더가 결정적이다. | |
| 23 | - */ | |
| 24 | -function subscribe(onChange: () => void): () => void { | |
| 25 | - const observer = new MutationObserver(onChange); | |
| 26 | - observer.observe(document.documentElement, { | |
| 27 | - attributes: true, | |
| 28 | - attributeFilter: [THEME_ATTRIBUTE_NAME], | |
| 29 | - }); | |
| 30 | - return () => observer.disconnect(); | |
| 31 | -} | |
| 32 | - | |
| 33 | -function getSnapshot(): ThemePreference { | |
| 34 | - const value = document.documentElement.getAttribute(THEME_ATTRIBUTE_NAME); | |
| 35 | - return value === "light" || value === "dark" ? value : DEFAULT_THEME_PREFERENCE; | |
| 36 | -} | |
| 37 | - | |
| 38 | -function getServerSnapshot(): ThemePreference { | |
| 39 | - return DEFAULT_THEME_PREFERENCE; | |
| 40 | -} | |
| 41 | - | |
| 42 | -function applyTheme(next: ThemePreference): void { | |
| 43 | - const root = document.documentElement; | |
| 44 | - | |
| 45 | - // 전환 트랜지션은 200ms 동안만 켜지는 opt-in이다(@fox/styles/_root.scss). | |
| 46 | - root.setAttribute("data-theme-transition", ""); | |
| 47 | - | |
| 48 | - if (next === "system") { | |
| 49 | - root.removeAttribute(THEME_ATTRIBUTE_NAME); | |
| 50 | - localStorage.removeItem(THEME_STORAGE_KEY); | |
| 51 | - } else { | |
| 52 | - root.setAttribute(THEME_ATTRIBUTE_NAME, next); | |
| 53 | - localStorage.setItem(THEME_STORAGE_KEY, next); | |
| 54 | - } | |
| 55 | - | |
| 56 | - window.setTimeout(() => { | |
| 57 | - root.removeAttribute("data-theme-transition"); | |
| 58 | - }, 200); | |
| 59 | -} | |
| 60 | - | |
| 61 | -/** 카탈로그에서 라이트/다크 토큰을 눈으로 대조하기 위한 개발용 전환기. */ | |
| 62 | -export function ThemeSwitch() { | |
| 63 | - const current = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); | |
| 64 | - | |
| 65 | - return ( | |
| 66 | - <div className={styles.themeSwitch} role="group" aria-label="테마 전환"> | |
| 67 | - {THEME_PREFERENCE_CYCLE.map((preference) => ( | |
| 68 | - <button | |
| 69 | - key={preference} | |
| 70 | - type="button" | |
| 71 | - className={styles.themeButton} | |
| 72 | - aria-pressed={current === preference} | |
| 73 | - onClick={() => applyTheme(preference)} | |
| 74 | - > | |
| 75 | - {LABELS[preference]} | |
| 76 | - </button> | |
| 77 | - ))} | |
| 78 | - </div> | |
| 79 | - ); | |
| 80 | -} |
--- app/design/_components/token-catalog.tsx
... | ... | @@ -1,160 +0,0 @@ |
| 1 | -"use client"; | |
| 2 | - | |
| 3 | -import type { CSSProperties } from "react"; | |
| 4 | -import { useTokenRegistry } from "../_hooks/use-token-registry"; | |
| 5 | -import type { TokenEntry } from "../_lib/read-tokens"; | |
| 6 | -import { ThemeSwitch } from "./theme-switch"; | |
| 7 | -import styles from "../design.module.scss"; | |
| 8 | - | |
| 9 | -/** | |
| 10 | - * 렌더 방식만 다를 뿐, 목록은 전부 CSSOM에서 읽는다 — 여기에 토큰 이름을 적지 않는다. | |
| 11 | - * `kind`는 그 그룹을 어떻게 보여줄지만 정한다: 색은 스와치, 길이는 막대, 나머지는 값만. | |
| 12 | - */ | |
| 13 | -const GROUPS: { key: string; label: string; kind: "swatch" | "bar" | "value"; note?: string }[] = [ | |
| 14 | - { | |
| 15 | - key: "color", | |
| 16 | - label: "시맨틱 색상", | |
| 17 | - kind: "swatch", | |
| 18 | - note: "light/dark 값이 light-dark() 한 줄에 함께 정의돼 있습니다. 위 전환기로 두 테마를 대조해 보세요.", | |
| 19 | - }, | |
| 20 | - { key: "theme", label: "브랜드 램프", kind: "swatch", note: "primary · secondary · accent" }, | |
| 21 | - { | |
| 22 | - key: "primitive", | |
| 23 | - label: "원시 팔레트", | |
| 24 | - kind: "swatch", | |
| 25 | - note: "화면에서 직접 쓰지 않는 바닥층입니다. 시맨틱 색상이 이 값들을 참조합니다.", | |
| 26 | - }, | |
| 27 | - { key: "font", label: "서체", kind: "value", note: "size 계열은 뷰포트에 따라 값이 바뀝니다." }, | |
| 28 | - { key: "padding", label: "패딩", kind: "bar" }, | |
| 29 | - { key: "gap", label: "간격", kind: "bar" }, | |
| 30 | - { key: "radius", label: "모서리 반경", kind: "bar" }, | |
| 31 | - { key: "icon", label: "아이콘 크기", kind: "bar" }, | |
| 32 | - { key: "border", label: "테두리 두께", kind: "bar" }, | |
| 33 | - { key: "number", label: "원시 수치", kind: "bar" }, | |
| 34 | - { key: "shadow", label: "그림자 수치", kind: "value" }, | |
| 35 | - { key: "backdrop", label: "배경 흐림", kind: "value" }, | |
| 36 | - { key: "form", label: "폼", kind: "value" }, | |
| 37 | - { key: "card", label: "카드", kind: "value" }, | |
| 38 | - { key: "modal", label: "모달", kind: "value" }, | |
| 39 | - { key: "grid", label: "그리드", kind: "value" }, | |
| 40 | - { key: "spacing", label: "여백", kind: "value" }, | |
| 41 | - { key: "section", label: "섹션", kind: "value" }, | |
| 42 | -]; | |
| 43 | - | |
| 44 | -/** | |
| 45 | - * ⚠️ 이 파일은 인라인 `style`을 쓰는 **유일하게 허용되는 예외**다. 카탈로그는 토큰 | |
| 46 | - * 레지스트리를 런타임에 순회하며 개수를 미리 알 수 없는 토큰을 렌더해야 하는데, CSS | |
| 47 | - * 클래스는 알 수 없는 이름에 대해 만들 수 없다. 여기서 인라인으로 넣는 값은 디자인 | |
| 48 | - * 값이 아니라 **토큰 참조**(`var(--fox-*)`)이며 실제 값은 여전히 토큰이 소유한다. | |
| 49 | - * 일반 화면·컴포넌트에서는 이 패턴을 쓰지 않는다. | |
| 50 | - */ | |
| 51 | -function ref(property: keyof CSSProperties, token: TokenEntry): CSSProperties { | |
| 52 | - return { [property]: `var(${token.property})` } as CSSProperties; | |
| 53 | -} | |
| 54 | - | |
| 55 | -function Swatches({ tokens, fn }: { tokens: TokenEntry[]; fn: string }) { | |
| 56 | - return ( | |
| 57 | - <div className={styles.grid}> | |
| 58 | - {tokens.map((token) => ( | |
| 59 | - <div key={token.property} className={styles.swatch}> | |
| 60 | - <div className={styles.swatchChip} style={ref("background", token)} /> | |
| 61 | - <div className={styles.swatchMeta}> | |
| 62 | - <span className={styles.name}> | |
| 63 | - fox.{fn}({token.name}) | |
| 64 | - </span> | |
| 65 | - <span className={styles.ref}>{token.value}</span> | |
| 66 | - </div> | |
| 67 | - </div> | |
| 68 | - ))} | |
| 69 | - </div> | |
| 70 | - ); | |
| 71 | -} | |
| 72 | - | |
| 73 | -function ValueTable({ | |
| 74 | - tokens, | |
| 75 | - fn, | |
| 76 | - withBar, | |
| 77 | -}: { | |
| 78 | - tokens: TokenEntry[]; | |
| 79 | - fn: string; | |
| 80 | - withBar: boolean; | |
| 81 | -}) { | |
| 82 | - return ( | |
| 83 | - <table className={styles.table}> | |
| 84 | - <thead> | |
| 85 | - <tr> | |
| 86 | - <th scope="col">토큰</th> | |
| 87 | - <th scope="col">값</th> | |
| 88 | - {withBar ? <th scope="col">크기</th> : null} | |
| 89 | - </tr> | |
| 90 | - </thead> | |
| 91 | - <tbody> | |
| 92 | - {tokens.map((token) => ( | |
| 93 | - <tr key={token.property}> | |
| 94 | - <td> | |
| 95 | - fox.{fn}({token.name}) | |
| 96 | - </td> | |
| 97 | - <td className={styles.tdValue}>{token.value}</td> | |
| 98 | - {withBar ? ( | |
| 99 | - <td> | |
| 100 | - <span className={styles.bar} style={ref("inlineSize", token)} /> | |
| 101 | - </td> | |
| 102 | - ) : null} | |
| 103 | - </tr> | |
| 104 | - ))} | |
| 105 | - </tbody> | |
| 106 | - </table> | |
| 107 | - ); | |
| 108 | -} | |
| 109 | - | |
| 110 | -export function TokenCatalog() { | |
| 111 | - // CSSOM은 브라우저에만 있으므로 하이드레이션 이후에 채워진다. 서버 렌더 결과가 | |
| 112 | - // 비어 있어도 개발 전용 페이지라 문제되지 않는다. | |
| 113 | - const registry = useTokenRegistry(); | |
| 114 | - | |
| 115 | - if (!registry) { | |
| 116 | - return ( | |
| 117 | - <main className={styles.page}> | |
| 118 | - <p className={styles.lede}>토큰을 읽는 중…</p> | |
| 119 | - </main> | |
| 120 | - ); | |
| 121 | - } | |
| 122 | - | |
| 123 | - const total = Object.values(registry).reduce((sum, list) => sum + list.length, 0); | |
| 124 | - | |
| 125 | - return ( | |
| 126 | - <main className={styles.page}> | |
| 127 | - <header className={styles.header}> | |
| 128 | - <div> | |
| 129 | - <h1 className={styles.title}>디자인 시스템 카탈로그</h1> | |
| 130 | - <p className={styles.lede}> | |
| 131 | - @fox/styles/tokens 전량 {total}개를 CSSOM에서 읽어 그립니다. 개발 환경에서만 | |
| 132 | - 열립니다. 반응형 토큰은 현재 창 크기 기준 값입니다. | |
| 133 | - </p> | |
| 134 | - </div> | |
| 135 | - <ThemeSwitch /> | |
| 136 | - </header> | |
| 137 | - | |
| 138 | - {GROUPS.map(({ key, label, kind, note }) => { | |
| 139 | - const tokens = registry[key]; | |
| 140 | - if (!tokens?.length) { | |
| 141 | - return null; | |
| 142 | - } | |
| 143 | - | |
| 144 | - return ( | |
| 145 | - <section key={key} className={styles.section}> | |
| 146 | - <h2 className={styles.sectionTitle}> | |
| 147 | - {label} · {tokens.length} | |
| 148 | - </h2> | |
| 149 | - {note ? <p className={styles.sectionNote}>{note}</p> : null} | |
| 150 | - {kind === "swatch" ? ( | |
| 151 | - <Swatches tokens={tokens} fn={key} /> | |
| 152 | - ) : ( | |
| 153 | - <ValueTable tokens={tokens} fn={key} withBar={kind === "bar"} /> | |
| 154 | - )} | |
| 155 | - </section> | |
| 156 | - ); | |
| 157 | - })} | |
| 158 | - </main> | |
| 159 | - ); | |
| 160 | -} |
--- app/design/_hooks/use-token-registry.ts
... | ... | @@ -1,45 +0,0 @@ |
| 1 | -import { useSyncExternalStore } from "react"; | |
| 2 | -import { readTokenRegistry, type TokenRegistry } from "../_lib/read-tokens"; | |
| 3 | -import { THEME_ATTRIBUTE_NAME } from "@/lib/constants/theme"; | |
| 4 | - | |
| 5 | -// CSSOM은 React 바깥의 외부 시스템이라 `useSyncExternalStore`가 맞는 도구다 | |
| 6 | -// (effect에서 setState 하면 렌더가 연쇄된다). | |
| 7 | -// | |
| 8 | -// 토큰 **값**은 현재 테마에 따라 달라지므로 `data-theme`이 바뀌면 다시 읽어야 한다. | |
| 9 | -// 반면 `useSyncExternalStore`는 매 렌더마다 getSnapshot을 호출하므로 매번 새 객체를 | |
| 10 | -// 돌려주면 무한 루프가 된다 — 그래서 테마 키로 캐시해 같은 테마 동안에는 동일한 | |
| 11 | -// 참조를 반환한다. | |
| 12 | -let cached: { theme: string; registry: TokenRegistry } | null = null; | |
| 13 | - | |
| 14 | -function currentTheme(): string { | |
| 15 | - return document.documentElement.getAttribute(THEME_ATTRIBUTE_NAME) ?? "system"; | |
| 16 | -} | |
| 17 | - | |
| 18 | -function subscribe(onChange: () => void): () => void { | |
| 19 | - const observer = new MutationObserver(onChange); | |
| 20 | - observer.observe(document.documentElement, { | |
| 21 | - attributes: true, | |
| 22 | - attributeFilter: [THEME_ATTRIBUTE_NAME], | |
| 23 | - }); | |
| 24 | - return () => observer.disconnect(); | |
| 25 | -} | |
| 26 | - | |
| 27 | -function getSnapshot(): TokenRegistry | null { | |
| 28 | - const theme = currentTheme(); | |
| 29 | - if (!cached || cached.theme !== theme) { | |
| 30 | - cached = { theme, registry: readTokenRegistry() }; | |
| 31 | - } | |
| 32 | - return cached.registry; | |
| 33 | -} | |
| 34 | - | |
| 35 | -function getServerSnapshot(): TokenRegistry | null { | |
| 36 | - return null; | |
| 37 | -} | |
| 38 | - | |
| 39 | -/** | |
| 40 | - * 서버 렌더와 하이드레이션 첫 렌더에서는 `null`, 그 이후 토큰 레지스트리를 반환한다. | |
| 41 | - * 테마를 바꾸면 그 테마에서 해석된 값으로 갱신된다. | |
| 42 | - */ | |
| 43 | -export function useTokenRegistry(): TokenRegistry | null { | |
| 44 | - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); | |
| 45 | -} |
--- app/design/design.module.scss
... | ... | @@ -1,161 +0,0 @@ |
| 1 | -// 디자인 시스템 카탈로그 스타일. | |
| 2 | -// | |
| 3 | -// 토큰 목록은 이 파일이 아니라 **CSSOM에서 런타임에 읽는다**(`_components/token-catalog.tsx`). | |
| 4 | -// SCSS 값을 JS로 내보내는 CSS Modules의 `:export`를 처음 썼으나, Next.js 16의 기본 | |
| 5 | -// 번들러인 Turbopack은 Lightning CSS로 CSS를 파싱해 `:export`(webpack css-loader 시절의 | |
| 6 | -// ICSS 확장)를 인식하지 못한다 — 빌드는 통과하지만 규칙이 통째로 버려져 값이 `undefined`가 | |
| 7 | -// 된다. `--fox-*` 커스텀 프로퍼티를 CSSOM에서 직접 열거하면 같은 목적을 번들러 의존 없이 | |
| 8 | -// 달성한다. | |
| 9 | - | |
| 10 | -@use "@fox/styles/abstracts" as fox; | |
| 11 | - | |
| 12 | -.page { | |
| 13 | - flex: 1; | |
| 14 | - padding: fox.spacing(top-lg) fox.grid(margin-default) fox.spacing(bottom-xlg); | |
| 15 | -} | |
| 16 | - | |
| 17 | -.header { | |
| 18 | - display: flex; | |
| 19 | - flex-wrap: wrap; | |
| 20 | - gap: fox.gap(5); | |
| 21 | - align-items: baseline; | |
| 22 | - justify-content: space-between; | |
| 23 | - padding-block-end: fox.padding(7); | |
| 24 | - border-block-end: fox.border(1) solid fox.color(border-neutral-default); | |
| 25 | -} | |
| 26 | - | |
| 27 | -.title { | |
| 28 | - font-size: fox.font-size(heading-lg); | |
| 29 | - font-weight: fox.font-weight(bold); | |
| 30 | -} | |
| 31 | - | |
| 32 | -.lede { | |
| 33 | - margin-block-start: fox.gap(2); | |
| 34 | - color: fox.color(font-neutral-subtle); | |
| 35 | - font-size: fox.font-size(body-md); | |
| 36 | -} | |
| 37 | - | |
| 38 | -// ── 테마 전환 ─────────────────────────────────────────────────────────────── | |
| 39 | -.themeSwitch { | |
| 40 | - display: flex; | |
| 41 | - gap: fox.gap(1); | |
| 42 | - padding: fox.padding(1); | |
| 43 | - border: fox.border(1) solid fox.color(border-neutral-default); | |
| 44 | - border-radius: fox.radius(max); | |
| 45 | - background: fox.color(surface-neutral-gray); | |
| 46 | -} | |
| 47 | - | |
| 48 | -.themeButton { | |
| 49 | - padding: fox.padding(3) fox.padding(6); | |
| 50 | - border-radius: fox.radius(max); | |
| 51 | - color: fox.color(font-neutral-subtle); | |
| 52 | - font-size: fox.font-size(label-sm); | |
| 53 | - font-weight: fox.font-weight(medium); | |
| 54 | - | |
| 55 | - &[aria-pressed="true"] { | |
| 56 | - background: fox.color(button-primary-surface); | |
| 57 | - color: fox.color(button-primary-font); | |
| 58 | - } | |
| 59 | - | |
| 60 | - &:focus-visible { | |
| 61 | - outline: fox.border(2) solid fox.color(border-theme-primary); | |
| 62 | - outline-offset: fox.border(2); | |
| 63 | - } | |
| 64 | -} | |
| 65 | - | |
| 66 | -// ── 섹션 ──────────────────────────────────────────────────────────────────── | |
| 67 | -.section { | |
| 68 | - padding-block: fox.section(spacing-xsm); | |
| 69 | - border-block-end: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 70 | -} | |
| 71 | - | |
| 72 | -.sectionTitle { | |
| 73 | - margin-block-end: fox.gap(1); | |
| 74 | - font-size: fox.font-size(heading-sm); | |
| 75 | - font-weight: fox.font-weight(bold); | |
| 76 | -} | |
| 77 | - | |
| 78 | -.sectionNote { | |
| 79 | - margin-block-end: fox.gap(5); | |
| 80 | - color: fox.color(font-neutral-subtle); | |
| 81 | - font-size: fox.font-size(body-sm); | |
| 82 | -} | |
| 83 | - | |
| 84 | -.grid { | |
| 85 | - display: grid; | |
| 86 | - grid-template-columns: repeat(auto-fill, minmax(fox.grid(wrap-xsm), 1fr)); | |
| 87 | - gap: fox.gap(4); | |
| 88 | - | |
| 89 | - @include fox.pc { | |
| 90 | - grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr)); | |
| 91 | - } | |
| 92 | -} | |
| 93 | - | |
| 94 | -// ── 색상 스와치 ───────────────────────────────────────────────────────────── | |
| 95 | -.swatch { | |
| 96 | - overflow: hidden; | |
| 97 | - border: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 98 | - border-radius: fox.radius(3); | |
| 99 | - background: fox.color(surface-neutral-default); | |
| 100 | -} | |
| 101 | - | |
| 102 | -.swatchChip { | |
| 103 | - block-size: fox.icon(7); | |
| 104 | - border-block-end: fox.border(1) solid fox.color(border-neutral-overlay); | |
| 105 | -} | |
| 106 | - | |
| 107 | -.swatchMeta { | |
| 108 | - padding: fox.padding(5); | |
| 109 | -} | |
| 110 | - | |
| 111 | -.name { | |
| 112 | - display: block; | |
| 113 | - color: fox.color(font-neutral-default); | |
| 114 | - font-size: fox.font-size(label-sm); | |
| 115 | - font-weight: fox.font-weight(medium); | |
| 116 | - word-break: break-all; | |
| 117 | -} | |
| 118 | - | |
| 119 | -.ref { | |
| 120 | - display: block; | |
| 121 | - margin-block-start: fox.gap(1); | |
| 122 | - color: fox.color(font-neutral-subtler); | |
| 123 | - font-size: fox.font-size(body-xsm); | |
| 124 | - word-break: break-all; | |
| 125 | -} | |
| 126 | - | |
| 127 | -// ── 값 테이블 ─────────────────────────────────────────────────────────────── | |
| 128 | -.table { | |
| 129 | - inline-size: 100%; | |
| 130 | - border-collapse: collapse; | |
| 131 | - text-align: start; | |
| 132 | -} | |
| 133 | - | |
| 134 | -.table th, | |
| 135 | -.table td { | |
| 136 | - padding: fox.padding(3) fox.padding(5); | |
| 137 | - border-block-end: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 138 | -} | |
| 139 | - | |
| 140 | -.table th { | |
| 141 | - color: fox.color(font-neutral-subtle); | |
| 142 | - font-size: fox.font-size(label-xsm); | |
| 143 | - font-weight: fox.font-weight(medium); | |
| 144 | -} | |
| 145 | - | |
| 146 | -.table td { | |
| 147 | - color: fox.color(font-neutral-default); | |
| 148 | - font-size: fox.font-size(body-sm); | |
| 149 | -} | |
| 150 | - | |
| 151 | -.tdValue { | |
| 152 | - color: fox.color(font-neutral-subtle); | |
| 153 | -} | |
| 154 | - | |
| 155 | -// 크기 토큰을 눈으로 비교하기 위한 막대. 폭은 인라인 style이 토큰을 참조해 넣는다. | |
| 156 | -.bar { | |
| 157 | - display: block; | |
| 158 | - block-size: fox.icon(2); | |
| 159 | - border-radius: fox.radius(1); | |
| 160 | - background: fox.color(surface-theme-primary); | |
| 161 | -} |
--- app/design/page.tsx
... | ... | @@ -1,20 +0,0 @@ |
| 1 | -import { notFound } from "next/navigation"; | |
| 2 | -import { TokenCatalog } from "./_components/token-catalog"; | |
| 3 | - | |
| 4 | -export const metadata = { | |
| 5 | - title: "디자인 시스템 카탈로그", | |
| 6 | -}; | |
| 7 | - | |
| 8 | -/** | |
| 9 | - * 디자인 시스템 토큰 카탈로그 — 개발 전용. | |
| 10 | - * | |
| 11 | - * 라우트 자체는 서버에 남기고(프로덕션 차단을 서버에서 판정), 실제 렌더는 CSSOM을 읽어야 | |
| 12 | - * 하는 클라이언트 컴포넌트가 맡는다. | |
| 13 | - */ | |
| 14 | -export default function DesignCatalogPage() { | |
| 15 | - if (process.env.NODE_ENV === "production") { | |
| 16 | - notFound(); | |
| 17 | - } | |
| 18 | - | |
| 19 | - return <TokenCatalog />; | |
| 20 | -} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?