feat: 디자인 시스템 토큰 카탈로그 라우트 추가
토큰 목록을 코드에 두지 않고 CSSOM에서 읽는다 — tokens/에 값을 추가하면 카탈로그에 자동으로 나타난다. CSS Modules의 :export를 먼저 시도했으나 Turbopack의 Lightning CSS가 이를 인식하지 못해(빌드는 통과하되 값이 조용히 undefined가 된다) CSSOM 열거로 대체했다. 개발 전용 라우트이며 프로덕션에서는 notFound()로 차단된다. proxy는 개발 환경에서만 인증을 면제하므로 프로덕션 빌드에서는 fail-closed가 유지된다. Co-Authored-By: Claude Opus 5
@af1027d84d03cfc21321f6f09bfe6a2905b8047e
+++ app/design/_components/theme-switch.tsx
... | ... | @@ -0,0 +1,80 @@ |
| 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
... | ... | @@ -0,0 +1,187 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { CSSProperties } from "react"; | |
| 4 | +import { useTokenRegistry } from "../_hooks/use-token-registry"; | |
| 5 | +import { typoPresetNames, type TokenEntry } from "../_lib/read-tokens"; | |
| 6 | +import { ThemeSwitch } from "./theme-switch"; | |
| 7 | +import styles from "../design.module.scss"; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * ⚠️ 이 파일은 인라인 `style`을 쓰는 **유일하게 허용되는 예외**다. 카탈로그는 토큰 | |
| 11 | + * 레지스트리를 런타임에 순회하며 개수를 미리 알 수 없는 토큰을 렌더해야 하는데, CSS | |
| 12 | + * 클래스는 알 수 없는 이름에 대해 만들 수 없다. 여기서 인라인으로 넣는 값은 디자인 | |
| 13 | + * 값이 아니라 **토큰 참조**(`var(--fox-*)`)이며 실제 값은 여전히 토큰이 소유한다. | |
| 14 | + * 일반 화면·컴포넌트에서는 이 패턴을 쓰지 않는다. | |
| 15 | + */ | |
| 16 | +function ref(property: keyof CSSProperties, token: TokenEntry): CSSProperties { | |
| 17 | + return { [property]: `var(${token.property})` } as CSSProperties; | |
| 18 | +} | |
| 19 | + | |
| 20 | +function typoRef(preset: string): CSSProperties { | |
| 21 | + return { | |
| 22 | + fontSize: `var(--fox-typo-${preset}-size)`, | |
| 23 | + fontWeight: `var(--fox-typo-${preset}-weight)`, | |
| 24 | + lineHeight: `var(--fox-typo-${preset}-line-height)`, | |
| 25 | + letterSpacing: `var(--fox-typo-${preset}-letter-spacing)`, | |
| 26 | + }; | |
| 27 | +} | |
| 28 | + | |
| 29 | +function ValueTable({ rows }: { rows: { label: string; value: string }[] }) { | |
| 30 | + return ( | |
| 31 | + <table className={styles.table}> | |
| 32 | + <thead> | |
| 33 | + <tr> | |
| 34 | + <th scope="col">토큰</th> | |
| 35 | + <th scope="col">값</th> | |
| 36 | + </tr> | |
| 37 | + </thead> | |
| 38 | + <tbody> | |
| 39 | + {rows.map((row) => ( | |
| 40 | + <tr key={row.label}> | |
| 41 | + <td>{row.label}</td> | |
| 42 | + <td>{row.value}</td> | |
| 43 | + </tr> | |
| 44 | + ))} | |
| 45 | + </tbody> | |
| 46 | + </table> | |
| 47 | + ); | |
| 48 | +} | |
| 49 | + | |
| 50 | +function rows(group: TokenEntry[] | undefined, fn: string) { | |
| 51 | + return (group ?? []).map((token) => ({ | |
| 52 | + label: `fox.${fn}(${token.name})`, | |
| 53 | + value: token.value, | |
| 54 | + })); | |
| 55 | +} | |
| 56 | + | |
| 57 | +export function TokenCatalog() { | |
| 58 | + // CSSOM은 브라우저에만 있으므로 하이드레이션 이후에 채워진다. 서버 렌더 결과가 | |
| 59 | + // 비어 있어도 개발 전용 페이지라 문제되지 않는다. | |
| 60 | + const registry = useTokenRegistry(); | |
| 61 | + | |
| 62 | + if (!registry) { | |
| 63 | + return ( | |
| 64 | + <main className={styles.page}> | |
| 65 | + <p className={styles.lede}>토큰을 읽는 중…</p> | |
| 66 | + </main> | |
| 67 | + ); | |
| 68 | + } | |
| 69 | + | |
| 70 | + const colors = registry.color ?? []; | |
| 71 | + const spaces = registry.space ?? []; | |
| 72 | + const radii = registry.radius ?? []; | |
| 73 | + const shadows = registry.shadow ?? []; | |
| 74 | + const presets = typoPresetNames(registry); | |
| 75 | + | |
| 76 | + return ( | |
| 77 | + <main className={styles.page}> | |
| 78 | + <header className={styles.header}> | |
| 79 | + <div> | |
| 80 | + <h1 className={styles.title}>디자인 시스템 카탈로그</h1> | |
| 81 | + <p className={styles.lede}> | |
| 82 | + @fox/styles/tokens 전량을 CSSOM에서 읽어 그립니다. 개발 환경에서만 열립니다. | |
| 83 | + </p> | |
| 84 | + </div> | |
| 85 | + <ThemeSwitch /> | |
| 86 | + </header> | |
| 87 | + | |
| 88 | + <section className={styles.section}> | |
| 89 | + <h2 className={styles.sectionTitle}>색상 · {colors.length}</h2> | |
| 90 | + <p className={styles.sectionNote}> | |
| 91 | + 라이트/다크 값이 light-dark() 한 줄에 함께 정의돼 있습니다. 위 전환기로 두 테마를 | |
| 92 | + 대조해 보세요. | |
| 93 | + </p> | |
| 94 | + <div className={styles.grid}> | |
| 95 | + {colors.map((token) => ( | |
| 96 | + <div key={token.property} className={styles.swatch}> | |
| 97 | + <div className={styles.swatchChip} style={ref("background", token)} /> | |
| 98 | + <div className={styles.swatchMeta}> | |
| 99 | + <span className={styles.name}>fox.color({token.name})</span> | |
| 100 | + <span className={styles.ref}>{token.value}</span> | |
| 101 | + </div> | |
| 102 | + </div> | |
| 103 | + ))} | |
| 104 | + </div> | |
| 105 | + </section> | |
| 106 | + | |
| 107 | + <section className={styles.section}> | |
| 108 | + <h2 className={styles.sectionTitle}>타이포 프리셋 · {presets.length}</h2> | |
| 109 | + <p className={styles.sectionNote}> | |
| 110 | + 크기·굵기·행간·자간 4종이 한 묶음입니다. @include fox.typo(이름)으로 적용합니다. | |
| 111 | + </p> | |
| 112 | + {presets.map((preset) => ( | |
| 113 | + <div key={preset} className={styles.typoRow}> | |
| 114 | + <span className={styles.typoName}>{preset}</span> | |
| 115 | + <span className={styles.typoSample} style={typoRef(preset)}> | |
| 116 | + 다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123 | |
| 117 | + </span> | |
| 118 | + </div> | |
| 119 | + ))} | |
| 120 | + </section> | |
| 121 | + | |
| 122 | + <section className={styles.section}> | |
| 123 | + <h2 className={styles.sectionTitle}>간격 · {spaces.length}</h2> | |
| 124 | + <p className={styles.sectionNote}> | |
| 125 | + 4px 배수 스케일. 1rem = 10px이므로 fox.space(4) = 1.6rem = 16px입니다. | |
| 126 | + </p> | |
| 127 | + {spaces.map((token) => ( | |
| 128 | + <div key={token.property} className={styles.spaceRow}> | |
| 129 | + <span className={styles.typoName}> | |
| 130 | + space({token.name}) · {token.value} | |
| 131 | + </span> | |
| 132 | + <span className={styles.spaceBar} style={ref("inlineSize", token)} /> | |
| 133 | + </div> | |
| 134 | + ))} | |
| 135 | + </section> | |
| 136 | + | |
| 137 | + <section className={styles.section}> | |
| 138 | + <h2 className={styles.sectionTitle}>모서리 반경 · {radii.length}</h2> | |
| 139 | + <div className={styles.grid}> | |
| 140 | + {radii.map((token) => ( | |
| 141 | + <div key={token.property} className={styles.tile} style={ref("borderRadius", token)}> | |
| 142 | + {token.name} · {token.value} | |
| 143 | + </div> | |
| 144 | + ))} | |
| 145 | + </div> | |
| 146 | + </section> | |
| 147 | + | |
| 148 | + <section className={styles.section}> | |
| 149 | + <h2 className={styles.sectionTitle}>그림자 · {shadows.length}</h2> | |
| 150 | + <p className={styles.sectionNote}>크기가 아니라 쓰임(고도)으로 이름 붙였습니다.</p> | |
| 151 | + <div className={styles.grid}> | |
| 152 | + {shadows.map((token) => ( | |
| 153 | + <div key={token.property} className={styles.shadowCell}> | |
| 154 | + <div className={styles.shadowTile} style={ref("boxShadow", token)}> | |
| 155 | + {token.name} | |
| 156 | + </div> | |
| 157 | + </div> | |
| 158 | + ))} | |
| 159 | + </div> | |
| 160 | + </section> | |
| 161 | + | |
| 162 | + <section className={styles.section}> | |
| 163 | + <h2 className={styles.sectionTitle}>모션</h2> | |
| 164 | + <ValueTable | |
| 165 | + rows={[...rows(registry.duration, "duration"), ...rows(registry.easing, "easing")]} | |
| 166 | + /> | |
| 167 | + </section> | |
| 168 | + | |
| 169 | + <section className={styles.section}> | |
| 170 | + <h2 className={styles.sectionTitle}>서체 · 굵기 · z 층 · 브레이크포인트</h2> | |
| 171 | + <p className={styles.sectionNote}> | |
| 172 | + 브레이크포인트는 미디어 쿼리가 var()를 해석하지 못하므로 실제 사용은 fox.media() | |
| 173 | + 믹스인 경유입니다. 또한 미디어 쿼리 안의 rem은 html font-size의 영향을 받지 않아 | |
| 174 | + 48rem은 480px가 아니라 768px입니다. | |
| 175 | + </p> | |
| 176 | + <ValueTable | |
| 177 | + rows={[ | |
| 178 | + ...rows(registry.font, "font"), | |
| 179 | + ...rows(registry.weight, "weight"), | |
| 180 | + ...rows(registry.z, "z"), | |
| 181 | + ...rows(registry.breakpoint, "media"), | |
| 182 | + ]} | |
| 183 | + /> | |
| 184 | + </section> | |
| 185 | + </main> | |
| 186 | + ); | |
| 187 | +} |
+++ app/design/_hooks/use-token-registry.ts
... | ... | @@ -0,0 +1,45 @@ |
| 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/_lib/read-tokens.ts
... | ... | @@ -0,0 +1,99 @@ |
| 1 | +export interface TokenEntry { | |
| 2 | + /** 토큰 이름 (`--fox-color-primary` → `primary`) */ | |
| 3 | + name: string; | |
| 4 | + /** | |
| 5 | + * 현재 테마에서 실제로 해석된 값 (`#818cf8`). | |
| 6 | + * | |
| 7 | + * 선언 원문이 아니라 계산값을 쓴다 — Lightning CSS가 `light-dark()`를 | |
| 8 | + * `var(--lightningcss-light, …) var(--lightningcss-dark, …)`로 폴리필해 내보내므로 | |
| 9 | + * 원문은 읽기 어렵고, 계산값은 지금 테마에서 눈에 보이는 색과 정확히 일치한다. | |
| 10 | + */ | |
| 11 | + value: string; | |
| 12 | + /** 스타일에서 참조할 커스텀 프로퍼티 전체 이름 */ | |
| 13 | + property: string; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export type TokenRegistry = Record<string, TokenEntry[]>; | |
| 17 | + | |
| 18 | +const PREFIX = "--fox-"; | |
| 19 | +const TYPO_FIELDS = ["-size", "-weight", "-line-height", "-letter-spacing"]; | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * 문서의 모든 스타일시트를 훑어 `--fox-*` 커스텀 프로퍼티를 그룹별로 모은다. | |
| 23 | + * | |
| 24 | + * 토큰 목록을 카탈로그가 따로 들고 있지 않게 하려는 것이 목적이다 — `tokens/`에 값을 | |
| 25 | + * 추가하면 카탈로그에 자동으로 나타나고, 목록이 두 곳에 존재해 어긋날 일이 없다. | |
| 26 | + * CSSOM은 선언 순서를 보존하므로 SCSS map에 적은 순서가 그대로 유지된다. | |
| 27 | + * | |
| 28 | + * 브라우저에서만 동작한다(문서가 있어야 한다). | |
| 29 | + */ | |
| 30 | +export function readTokenRegistry(): TokenRegistry { | |
| 31 | + const registry: TokenRegistry = {}; | |
| 32 | + const seen = new Set<string>(); | |
| 33 | + // 이름·순서는 CSSOM 규칙에서, 값은 계산 스타일에서 가져온다. | |
| 34 | + const computed = getComputedStyle(document.documentElement); | |
| 35 | + | |
| 36 | + for (const sheet of Array.from(document.styleSheets)) { | |
| 37 | + let rules: CSSRuleList; | |
| 38 | + try { | |
| 39 | + rules = sheet.cssRules; | |
| 40 | + } catch { | |
| 41 | + // 교차 출처 스타일시트는 규칙을 읽을 수 없다 — 우리 토큰은 동일 출처이므로 건너뛴다. | |
| 42 | + continue; | |
| 43 | + } | |
| 44 | + | |
| 45 | + for (const rule of Array.from(rules)) { | |
| 46 | + if (!(rule instanceof CSSStyleRule) || rule.selectorText !== ":root") { | |
| 47 | + continue; | |
| 48 | + } | |
| 49 | + | |
| 50 | + for (const property of Array.from(rule.style)) { | |
| 51 | + if (!property.startsWith(PREFIX) || seen.has(property)) { | |
| 52 | + continue; | |
| 53 | + } | |
| 54 | + seen.add(property); | |
| 55 | + | |
| 56 | + const rest = property.slice(PREFIX.length); | |
| 57 | + const separator = rest.indexOf("-"); | |
| 58 | + if (separator <= 0) { | |
| 59 | + continue; | |
| 60 | + } | |
| 61 | + | |
| 62 | + const group = rest.slice(0, separator); | |
| 63 | + const name = rest.slice(separator + 1); | |
| 64 | + | |
| 65 | + (registry[group] ??= []).push({ | |
| 66 | + name, | |
| 67 | + value: | |
| 68 | + computed.getPropertyValue(property).trim() || | |
| 69 | + rule.style.getPropertyValue(property).trim(), | |
| 70 | + property, | |
| 71 | + }); | |
| 72 | + } | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + return registry; | |
| 77 | +} | |
| 78 | + | |
| 79 | +/** | |
| 80 | + * 타이포 프리셋은 이름 하나가 4개의 커스텀 프로퍼티로 펼쳐져 있으므로 | |
| 81 | + * (`--fox-typo-title-md-size` 등) 접미사를 떼어 프리셋 이름만 순서대로 복원한다. | |
| 82 | + */ | |
| 83 | +export function typoPresetNames(registry: TokenRegistry): string[] { | |
| 84 | + const names: string[] = []; | |
| 85 | + | |
| 86 | + for (const entry of registry.typo ?? []) { | |
| 87 | + const field = TYPO_FIELDS.find((suffix) => entry.name.endsWith(suffix)); | |
| 88 | + if (!field) { | |
| 89 | + continue; | |
| 90 | + } | |
| 91 | + | |
| 92 | + const preset = entry.name.slice(0, -field.length); | |
| 93 | + if (!names.includes(preset)) { | |
| 94 | + names.push(preset); | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + return names; | |
| 99 | +} |
+++ app/design/design.module.scss
... | ... | @@ -0,0 +1,211 @@ |
| 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.space(8) fox.space(6); | |
| 15 | + | |
| 16 | + @include fox.container(xl, 6); | |
| 17 | +} | |
| 18 | + | |
| 19 | +.header { | |
| 20 | + display: flex; | |
| 21 | + flex-wrap: wrap; | |
| 22 | + gap: fox.space(4); | |
| 23 | + align-items: baseline; | |
| 24 | + justify-content: space-between; | |
| 25 | + padding-block-end: fox.space(6); | |
| 26 | + border-block-end: 1px solid fox.color(border); | |
| 27 | +} | |
| 28 | + | |
| 29 | +.title { | |
| 30 | + @include fox.typo(headline-lg); | |
| 31 | +} | |
| 32 | + | |
| 33 | +.lede { | |
| 34 | + margin-block-start: fox.space(2); | |
| 35 | + color: fox.color(foreground-muted); | |
| 36 | + | |
| 37 | + @include fox.typo(body-md); | |
| 38 | +} | |
| 39 | + | |
| 40 | +// ── 테마 전환 ─────────────────────────────────────────────────────────────── | |
| 41 | +.themeSwitch { | |
| 42 | + display: flex; | |
| 43 | + gap: fox.space(1); | |
| 44 | + padding: fox.space(1); | |
| 45 | + border: 1px solid fox.color(border); | |
| 46 | + border-radius: fox.radius(full); | |
| 47 | + background: fox.color(surface-muted); | |
| 48 | +} | |
| 49 | + | |
| 50 | +.themeButton { | |
| 51 | + padding: fox.space(2) fox.space(4); | |
| 52 | + border-radius: fox.radius(full); | |
| 53 | + color: fox.color(foreground-muted); | |
| 54 | + | |
| 55 | + @include fox.typo(label-md); | |
| 56 | + @include fox.focus-ring; | |
| 57 | + @include fox.transition((background-color, color)); | |
| 58 | + | |
| 59 | + &[aria-pressed="true"] { | |
| 60 | + background: fox.color(primary); | |
| 61 | + color: fox.color(on-primary); | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +.section { | |
| 66 | + padding-block: fox.space(8); | |
| 67 | + border-block-end: 1px solid fox.color(border-subtle); | |
| 68 | +} | |
| 69 | + | |
| 70 | +.sectionTitle { | |
| 71 | + margin-block-end: fox.space(1); | |
| 72 | + | |
| 73 | + @include fox.typo(title-lg); | |
| 74 | +} | |
| 75 | + | |
| 76 | +.sectionNote { | |
| 77 | + margin-block-end: fox.space(5); | |
| 78 | + color: fox.color(foreground-muted); | |
| 79 | + | |
| 80 | + @include fox.typo(body-sm); | |
| 81 | +} | |
| 82 | + | |
| 83 | +.grid { | |
| 84 | + display: grid; | |
| 85 | + grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); | |
| 86 | + gap: fox.space(4); | |
| 87 | +} | |
| 88 | + | |
| 89 | +// ── 색상 ──────────────────────────────────────────────────────────────────── | |
| 90 | +.swatch { | |
| 91 | + overflow: hidden; | |
| 92 | + border: 1px solid fox.color(border); | |
| 93 | + border-radius: fox.radius(md); | |
| 94 | + background: fox.color(surface); | |
| 95 | +} | |
| 96 | + | |
| 97 | +.swatchChip { | |
| 98 | + block-size: 6.4rem; | |
| 99 | +} | |
| 100 | + | |
| 101 | +.swatchMeta { | |
| 102 | + padding: fox.space(3); | |
| 103 | +} | |
| 104 | + | |
| 105 | +.name { | |
| 106 | + display: block; | |
| 107 | + color: fox.color(foreground); | |
| 108 | + word-break: break-all; | |
| 109 | + | |
| 110 | + @include fox.typo(label-lg); | |
| 111 | +} | |
| 112 | + | |
| 113 | +.ref { | |
| 114 | + display: block; | |
| 115 | + margin-block-start: fox.space(1); | |
| 116 | + color: fox.color(foreground-subtle); | |
| 117 | + font-family: fox.font(mono); | |
| 118 | + word-break: break-all; | |
| 119 | + | |
| 120 | + @include fox.typo(body-sm); | |
| 121 | +} | |
| 122 | + | |
| 123 | +// ── 타이포 ────────────────────────────────────────────────────────────────── | |
| 124 | +.typoRow { | |
| 125 | + display: flex; | |
| 126 | + flex-wrap: wrap; | |
| 127 | + gap: fox.space(2) fox.space(6); | |
| 128 | + align-items: baseline; | |
| 129 | + padding-block: fox.space(3); | |
| 130 | + border-block-end: 1px solid fox.color(border-subtle); | |
| 131 | +} | |
| 132 | + | |
| 133 | +.typoName { | |
| 134 | + flex: 0 0 14rem; | |
| 135 | + color: fox.color(foreground-subtle); | |
| 136 | + font-family: fox.font(mono); | |
| 137 | + | |
| 138 | + @include fox.typo(body-sm); | |
| 139 | +} | |
| 140 | + | |
| 141 | +.typoSample { | |
| 142 | + flex: 1; | |
| 143 | + min-inline-size: 20rem; | |
| 144 | +} | |
| 145 | + | |
| 146 | +// ── 간격 ──────────────────────────────────────────────────────────────────── | |
| 147 | +.spaceRow { | |
| 148 | + display: flex; | |
| 149 | + gap: fox.space(4); | |
| 150 | + align-items: center; | |
| 151 | + padding-block: fox.space(2); | |
| 152 | +} | |
| 153 | + | |
| 154 | +.spaceBar { | |
| 155 | + block-size: 1.6rem; | |
| 156 | + border-radius: fox.radius(xs); | |
| 157 | + background: fox.color(primary); | |
| 158 | +} | |
| 159 | + | |
| 160 | +// ── radius · shadow ───────────────────────────────────────────────────────── | |
| 161 | +.tile { | |
| 162 | + display: flex; | |
| 163 | + block-size: 9.6rem; | |
| 164 | + align-items: center; | |
| 165 | + justify-content: center; | |
| 166 | + border: 1px solid fox.color(border); | |
| 167 | + background: fox.color(surface); | |
| 168 | + color: fox.color(foreground-muted); | |
| 169 | + | |
| 170 | + @include fox.typo(label-md); | |
| 171 | +} | |
| 172 | + | |
| 173 | +.shadowTile { | |
| 174 | + display: flex; | |
| 175 | + block-size: 9.6rem; | |
| 176 | + align-items: center; | |
| 177 | + justify-content: center; | |
| 178 | + border-radius: fox.radius(lg); | |
| 179 | + background: fox.color(surface); | |
| 180 | + color: fox.color(foreground-muted); | |
| 181 | + | |
| 182 | + @include fox.typo(label-md); | |
| 183 | +} | |
| 184 | + | |
| 185 | +.shadowCell { | |
| 186 | + padding: fox.space(4); | |
| 187 | +} | |
| 188 | + | |
| 189 | +// ── 값 테이블 ─────────────────────────────────────────────────────────────── | |
| 190 | +.table { | |
| 191 | + inline-size: 100%; | |
| 192 | + text-align: start; | |
| 193 | +} | |
| 194 | + | |
| 195 | +.table th, | |
| 196 | +.table td { | |
| 197 | + padding: fox.space(2) fox.space(3); | |
| 198 | + border-block-end: 1px solid fox.color(border-subtle); | |
| 199 | +} | |
| 200 | + | |
| 201 | +.table th { | |
| 202 | + color: fox.color(foreground-muted); | |
| 203 | + | |
| 204 | + @include fox.typo(label-md); | |
| 205 | +} | |
| 206 | + | |
| 207 | +.table td { | |
| 208 | + font-family: fox.font(mono); | |
| 209 | + | |
| 210 | + @include fox.typo(body-sm); | |
| 211 | +} |
+++ app/design/page.tsx
... | ... | @@ -0,0 +1,20 @@ |
| 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 | +} |
--- proxy.ts
+++ proxy.ts
... | ... | @@ -16,12 +16,32 @@ |
| 16 | 16 |
*/ |
| 17 | 17 |
const PUBLIC_PATHS = ['/login']; |
| 18 | 18 |
|
| 19 |
+/** |
|
| 20 |
+ * 개발 환경에서만 인증을 면제하는 경로. 디자인 시스템 카탈로그(`/design`)는 개발 전용 |
|
| 21 |
+ * 라우트이며 프로덕션에서는 페이지 자체가 `notFound()`로 404를 낸다 — 즉 여기서 열어도 |
|
| 22 |
+ * 프로덕션 노출 경로가 생기지 않는다. 백엔드나 세션 없이도 토큰을 확인할 수 있게 한다. |
|
| 23 |
+ * |
|
| 24 |
+ * fail-closed 원칙은 유지된다: 이 목록은 개발 빌드에서만 평가되고, 프로덕션 빌드에서는 |
|
| 25 |
+ * `PUBLIC_PATHS`만 남는다. |
|
| 26 |
+ */ |
|
| 27 |
+const DEV_ONLY_PUBLIC_PATHS = ['/design']; |
|
| 28 |
+ |
|
| 29 |
+function isPublicPath(pathname: string): boolean {
|
|
| 30 |
+ if (PUBLIC_PATHS.includes(pathname)) {
|
|
| 31 |
+ return true; |
|
| 32 |
+ } |
|
| 33 |
+ |
|
| 34 |
+ return ( |
|
| 35 |
+ process.env.NODE_ENV !== 'production' && |
|
| 36 |
+ DEV_ONLY_PUBLIC_PATHS.includes(pathname) |
|
| 37 |
+ ); |
|
| 38 |
+} |
|
| 39 |
+ |
|
| 19 | 40 |
export function proxy(request: NextRequest) {
|
| 20 | 41 |
const { pathname } = request.nextUrl;
|
| 21 |
- const isPublicPath = PUBLIC_PATHS.includes(pathname); |
|
| 22 | 42 |
const hasSessionCookie = request.cookies.has(SESSION_COOKIE_NAME); |
| 23 | 43 |
|
| 24 |
- if (!isPublicPath && !hasSessionCookie) {
|
|
| 44 |
+ if (!isPublicPath(pathname) && !hasSessionCookie) {
|
|
| 25 | 45 |
return NextResponse.redirect(new URL('/login', request.url));
|
| 26 | 46 |
} |
| 27 | 47 |
|
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?