@plott/webkit-design0.4.0
UI 라이브러리가 딛는 디자인 기초 — 움직임 토큰(길이·곡선, 움직임 줄이기), View Transition 대체 경로, 언어별 글꼴 스택(:lang 한자 글꼴), 자체 호스팅 글꼴 선언·대체 글꼴 지표, Google Fonts 주소, WCAG 대비 검사, OKLCH 팔레트. 컴포넌트는 두지 않는다
설치
pnpm add @plott/webkit-design@0.4.0진입점(exports)
| import | 타입 | 파일 |
|---|---|---|
@plott/webkit-design | ./dist/index.d.ts | ./dist/index.js |
@plott/webkit-design/motion | ./dist/motion.d.ts | ./dist/motion.js |
@plott/webkit-design/typography | ./dist/typography.d.ts | ./dist/typography.js |
@plott/webkit-design/color | ./dist/color.d.ts | ./dist/color.js |
@plott/webkit-design/palette | ./dist/palette.d.ts | ./dist/palette.js |
UI 라이브러리가 딛고 설 디자인 기초 값을 모은 패키지입니다. 읽는 사람은 화면 스타일의 바탕(움직임·글꼴·색 대비)을 정하는 프론트엔드 개발자입니다. 컴포넌트와 React 코드는 두지 않습니다.
./palette 만 culori 에 의존하고 나머지는 의존성이 없습니다. 서버 컴포넌트·Node·브라우저 어디서나 불러올 수 있고, 브라우저 전역(matchMedia·document)은 함수를 부르는 순간에만 읽습니다. 이 패키지의 오류 메시지는 @plott/webkit-design: 로 시작합니다.
진입점
| 진입점 | 어디서 도나 | 내보내는 것 |
|---|---|---|
@plott/webkit-design | 서버·브라우저·빌드 | motion + typography + color 전부 |
@plott/webkit-design/motion | 서버·브라우저 | duration·easing(토큰), motionCss, prefersReducedMotion, startViewTransition, viewTransitionName, createViewTransitionRegistry |
@plott/webkit-design/typography | 서버·브라우저·빌드 | fontStack, fontLangCss, googleFontsUrl, fontFace, fallbackMetrics, systemFontMetrics, normalizeFontLang |
@plott/webkit-design/color | 서버·브라우저·테스트 | parseColor, relativeLuminance, contrast, meetsContrast, checkContrastPairs, ensureContrast, requiredRatio |
@plott/webkit-design/palette | 서버·브라우저·빌드 | oklchScale, scaleCss, toOklch, mix, SCALE_LABELS (culori 를 가져오는 유일한 곳) |
붙이기
import { motionCss, fontLangCss, fontStack, fontFace } from "@plott/webkit-design";
const css = [ motionCss(), `:root{--font-sans:${fontStack({ latin: ["Pretendard"] })}}`, fontLangCss({ latin: ["Pretendard"], locales: ["ko", "ja", "zh-CN", "zh-TW"] }), fontFace({ family: "Pretendard", src: "/fonts/Pretendard.woff2", weight: { min: 100, max: 900 } }),].join("\n");import { fallbackMetrics } from "@plott/webkit-design";
const adj = fallbackMetrics({ unitsPerEm: 2048, ascent: 1984, descent: -494, lineGap: 0, xWidthAvg: 978 }, "Arial");const fallbackCss = `@font-face{font-family:"Inter Fallback";src:local("Arial");size-adjust:${adj.sizeAdjust}%;ascent-override:${adj.ascentOverride}%;descent-override:${adj.descentOverride}%;line-gap-override:${adj.lineGapOverride}%}`;adj 는 { sizeAdjust: 107.12, ascentOverride: 90.44, descentOverride: 22.52, lineGapOverride: 0 } 입니다. fontFace 는 url() 원본만 만들므로 대체 글꼴 규칙은 위처럼 직접 씁니다.
import { flushSync } from "react-dom";import { startViewTransition, viewTransitionName } from "@plott/webkit-design/motion";
export function Card({ id, setOpen }: { id: string; setOpen: (open: boolean) => void }) { const open = () => startViewTransition(() => flushSync(() => setOpen(true)), { types: ["open"] }); return <div onClick={open} style={{ viewTransitionName: viewTransitionName(`card-${id}`) }} />;}import { checkContrastPairs, ensureContrast } from "@plott/webkit-design/color";import { expect } from "vitest";
declare const tokens: Record<string, string>;
expect(checkContrastPairs(tokens, [["text", "surface"], ["muted", "surface", "AA"], ["accent", "surface", { size: "large" }]])).toEqual([]);ensureContrast("#999999", "#ffffff", "AA");ensureContrast("#999999", "#ffffff", "AA") 는 "#767676" 을 돌려줍니다.
import { oklchScale, mix } from "@plott/webkit-design/palette";
const blue = oklchScale("#3b82f6", { chroma: "taper", hueShift: 10 });mix("#ffffff", "#000000", 0.5, "oklab");Tailwind v4 의 @theme 로 내보낼 때는 이름공간을 맞춥니다.
import { motionCss } from "@plott/webkit-design/motion";import { oklchScale, scaleCss } from "@plott/webkit-design/palette";
const motion = motionCss({ namespace: "tailwind" });const light = scaleCss("brand", oklchScale("#3b82f6", { anchor: true, output: "oklch" }), { block: "theme" });const darkScale = scaleCss("brand", oklchScale("#3b82f6", { anchor: true, output: "oklch", scheme: "dark" }), { selector: ".dark" });어두운 테마의 반투명 토큰은 backdrop 에 그 아래 색을 줘서 판정합니다.
import { checkContrastPairs } from "@plott/webkit-design/color";
declare const darkTokens: Record<string, string>;
checkContrastPairs(darkTokens, [["foreground", "input"], ["foreground", "card", "AA", "#000000"]], { backdrop: darkTokens["background"]! });옵션과 기본값
| 함수 | 옵션 | 기본값 |
|---|---|---|
motionCss(options | prefix) | prefix: CSS 변수 접두사, namespace: "tailwind": @theme{--ease-*;--transition-duration-*} 로 내고(prefix 는 쓰지 않음) 움직임 줄이기 규칙도 같은 이름으로 0ms 로 만듦, duration: { fast: 150 } 처럼 길이 토큰(ms, 0 이상)을 덮어씀, easing: 곡선 토큰을 덮어씀(글자·숫자·공백·( ) , . - 만) | "motion" (--motion-duration-base), 토큰은 아래 값. 없는 토큰 이름은 RangeError |
startViewTransition(update, options) | types: 전환 유형 목록, skipIf: true 를 돌려주면 전환 없이 update 만 부름 | 없음 |
viewTransitionName(id, options) · createViewTransitionRegistry(options) | 허용 글자 A-Z a-z 0-9 - 외에는 _<16진 코드>_ 로 바꿈. prefix: 이름 접두사(CSS 식별자) | prefix: "vt-" |
fontStack(lang | options) | lang, latin(맨 앞 라틴 글꼴), mono, serif(mono 가 우선), scripts({ th: { sans, serif?, selectors? } } — 내장 CJK 표에 없는 언어를 더하거나 ko 같은 내장 언어를 덮어씀), fallback(마지막 대체 글꼴 목록) | 산세리프, 내장 한·중·일 글꼴 표, 끝은 Arial, sans-serif(serif 는 "Times New Roman", serif, mono 는 "Courier New", monospace) |
fontLangCss(options) | locales(필수), latin, variable, elementRule, mono, serif, scripts, fallback. scripts 의 selectors 는 :lang() 에 쓸 언어 태그(영문·숫자·-만) | variable: "--font-sans", elementRule: true, 선택자는 언어 태그 하나 |
googleFontsUrl(families, options) | display, text(글리프 서브셋), maxLength, baseUrl(https 주소, 쿼리·자격 증명 없음) | display: "swap", maxLength: 2000, baseUrl: "https://fonts.googleapis.com/css2" |
googleFontsUrl 굵기 | 숫자(400), 목록([400, 700]), 가변 범위({ min, max }) | 기본 굵기만 |
fontFace(options) | family, src(주소 또는 { url, format } 목록), weight, style, display, unicodeRange, sizeAdjust, ascentOverride, descentOverride, lineGapOverride | display: "swap", format 은 확장자로 정함 |
fallbackMetrics(font, against) | against: 지표 객체 또는 "Arial"·"Times New Roman" | "Arial" |
contrast(fg, bg, backdrop) | 반투명 배경은 backdrop 위에 합성 | 흰색 |
requiredRatio(spec) | "AA"·"AAA" 또는 { level, size } | AA, normal (AA 4.5/3, AAA 7/4.5) |
meetsContrast(fg, bg, spec, backdrop) | 반투명 배경은 backdrop 위에 합성 | "AA", 흰색 |
checkContrastPairs(tokens, pairs, options) | 짝의 네 번째 원소 backdrop(색 값), 호출 전체 기본값 options.backdrop(짝의 값이 앞섬) | 흰색 |
ensureContrast(fg, bg, target, backdrop) | target: 기준 또는 1–21 비율, backdrop: 반투명 배경 아래 색 | "AA", 흰색 |
oklchScale(base, options) | steps(2–100), hueShift(도), chroma("keep"·"taper"), gamut("clamp"), lightness([밝은 끝, 어두운 끝]), anchor(true: 기준 색과 밝기가 가장 가까운 단계가 기준 색 그대로가 됨, 정수: 그 단계), output("hex"·"oklch" — oklch(L C H) 문자열), scheme("dark": 같은 단계를 거꾸로 늘어놓아 50 이 가장 어두움) | steps: 11, hueShift: 0, chroma: "keep", lightness: [0.97, 0.22], anchor: false, output: "hex", scheme: "light" |
scaleCss(name, colors, options) | labels(색마다 이름), prefix, block("root"·"theme"), selector(block: "root" 의 선택자) | labels: 11단계면 SCALE_LABELS, prefix: "color", block: "root", selector: ":root" — --color-<name>-<label> 변수를 냄 |
mix(a, b, t, space) | space: oklch·oklab·lab·lch·rgb·hsl | "oklch", t 는 0–1 로 맞춤 |
oklchScale 이 11단계일 때 이름은 SCALE_LABELS(50…950)와 순서가 같습니다. duration 토큰은 instant 0, fast 120, base 200, slow 320, slower 480(ms)이고 easing 토큰은 standard·enter·exit·emphasized(cubic-bezier 문자열)입니다.
기본으로 지키는 것
- 글꼴 스택은 항상 기본 글꼴과 일반 계열(
sans-serif등)로 끝납니다. 웹 글꼴이 실패해도 글자가 사라지지 않습니다. - 한자·한글은 언어별 시스템 글꼴을 일반 계열보다 앞에 둡니다. Pretendard 같은 한글 글꼴에는 한자 글리프가 없어서,
lang="ja"문서의 한자가 브라우저가 고른 글꼴(한국·중국 자형)로 그려질 수 있습니다.fontLangCss가:lang(ja)같은 규칙으로 언어마다 알맞은 글꼴을 앞세웁니다.:lang(zh)가 번체까지 잡으므로 간체 규칙을 먼저, 번체 규칙을 나중에 냅니다. -apple-system·BlinkMacSystemFont는 따옴표 없이 씁니다. 따옴표를 치면 키워드가 아니라 글꼴 이름으로 읽혀 시스템 글꼴 별칭이 동작하지 않습니다.- 글꼴 이름·주소는 따옴표로 감싸고 이스케이프합니다. 제어 문자(줄바꿈 등)와 빈 값은 던집니다.
<는 CSS 이스케이프(\3c)로 바꾸므로, 결과를<style>안에 그대로 넣어도</style>로 빠져나오지 못하고 값은 CSS 가 읽으면 원래 글자 그대로입니다. - 한 요소에 쉼표 목록(
["Arial, sans-serif"])을 넣으면 던집니다. 쉼표 목록을 이름 하나로 따옴표 쳐서 아무 글꼴도 못 찾는 일을 막습니다. 글꼴마다 배열 요소를 나눠 주세요. fontLangCss는:lang(xx){--font-sans:…}와 함께[lang]:lang(xx){font-family:var(--font-sans)}를 냅니다.var()는 변수를 선언한 요소에서 값이 정해져 상속되므로, 본문 안의<span lang="ja">가 글꼴을 바꾸려면 그 요소가 스스로font-family: var(--font-sans)를 가져야 합니다.lang속성이 있는 요소에만 적용되며, 이미 요소마다 쓰는 앱은elementRule: false로 끕니다. 글자를 그리는 요소가font-family: var(--font-sans)를 써야 효과가 있습니다.- Google Fonts 주소는 공백을
+로, 굵기를 정렬·중복 제거해 만들고maxLength를 넘으면 던집니다. Google 이 문서로 밝힌 길이 한도는 없습니다. 2000 은 오래된 클라이언트·프록시(Internet Explorer 2083자 등)가 안전하게 다루는 길이에 맞춘 보수적 값입니다. 더 긴 주소가 필요하면maxLength를 올리고, 검사를 끄려면Infinity를 씁니다.text=는 한글 한 자가 퍼센트 인코딩으로 9자라서 주소가 빨리 길어지므로, 쓸 글자가 많으면 빌드 때 글꼴을 서브셋해 자체 호스팅하는 편이 맞습니다. 빌드 시점에 외부 글꼴 서버를 받아 오는 방식은 네트워크 실패가 빌드 실패가 되므로, 글꼴 파일을 저장소에 두는next/font/local이나fontFace자체 호스팅을 권합니다.googleFontsUrl은 런타임에 링크로 받는 경우를 위한 것입니다. - «움직임 줄이기» 설정이면 길이가 0 이 됩니다.
motionCss가@media (prefers-reduced-motion: reduce)안에서 모든 길이 변수를0ms로 만들고,startViewTransition은 전환 없이 갱신만 합니다. 기본으로 움직이는 속성은transform과opacity뿐으로 하는 것을 권합니다(레이아웃을 다시 계산하지 않습니다). - View Transition 을 모르는 브라우저에서도 갱신은 일어납니다.
document.startViewTransition이 없으면update()만 부르고,types객체 형태에서 TypeError 가 나면 함수 형태로 다시 시도합니다. 전환이 건너뛰어지기만 한 경우(AbortError·InvalidStateError)는 삼킵니다.update가 던진 오류는 View Transition 경로에서도updateCallbackDone으로 받아 호출한 쪽으로 다시 던집니다. view-transition-name은 겹치지 않는 유효한 식별자입니다. 같은 id 는 같은 이름, 다른 id 는 다른 이름이고vt-접두사로 숫자 시작·none예약어를 피합니다. 이름이 겹치면 브라우저가 전환 전체를 건너뛰므로, id 가 겹칠 수 있는 목록에서는createViewTransitionRegistry().claim(id)가 이미 차지된 id 에_dupN_을 붙여 줍니다(release(name)으로 놓고size로 차지된 수를 봅니다).parseColor는#hex·rgb()·hsl()·oklch()·oklab()(%·none·deg/rad/grad/turn, 공백·슬래시 알파)·CSS 이름 색 148개·transparent를 읽습니다. 그래서checkContrastPairs가oklch(...)토큰을invalid-color로 거절하지 않습니다. sRGB 밖의 OKLCH 색은 채널을 잘라 sRGB 안으로 옮기는 근사라서 브라우저의 색역 매핑과 조금 다를 수 있습니다.lab()·lch()·color()·currentcolor는 읽지 않고null입니다.- 대비는 반투명 색을 배경 위에 합성해서 셉니다.
rgba(0,0,0,.5)를 불투명 검정으로 보지 않습니다.#767676은 4.54 로 AA 통과,#777777은 4.48 로 탈락입니다. - 반투명 배경의 바탕은
backdrop으로 정합니다. 어두운 테마의oklch(1 0 0 / 15%)같은 토큰은 바탕을 주지 않으면 흰색 위에 합성돼 틀린 비율이 나오므로, 어두운 테마는 그 아래 색을backdrop으로 줍니다. ensureContrast는 HSL 밝기만 최소로 움직입니다. 이미 기준을 넘으면 (알파를 배경에 합성한) 원래 색을 돌려주고, 대비가 커지는 쪽을 먼저 시도한 뒤 반대쪽을 시도합니다. 어느 쪽도 닿지 못하는 목표는 틀린 색을 주지 않고 던집니다.checkContrastPairs는__proto__·constructor같은 키를 토큰으로 읽지 않습니다. 없는 토큰은missing-token, 해석할 수 없는 색은invalid-color, 기준 미달은low-contrast(비율 포함) 실패로 보고합니다. 빈 배열이면 통과입니다.- 팔레트는 항상 sRGB 안의
#rrggbb이고 밝기는 단계마다 엄격히 줄어듭니다. 색역 밖 색은 채도를 줄여 맞추고, 경계에 놓인#0000ff같은 색은 바꾸지 않습니다. 색 정확도는 16진수 반올림 오차(채널당 ±0.5/255) 안에서 보장합니다.
그 밖
- 내장 대체 글꼴 지표는 Arial 과 Times New Roman 둘입니다. 출처는 Next.js 16.4.2 의
capsize-font-metrics.json(capsize@capsizecss/metrics, Regular)입니다. 다른 글꼴은FontMetrics객체를 직접 넘깁니다. googleFontsUrl의 굵기 범위는{ min, max }객체이고, 배열([400, 700])은 «400 과 700 두 가지» 목록입니다.- 길이 토큰 용도: fast 는 호버·눌림, base 는 버튼·토글·툴팁, slow 는 패널·모달, slower 는 화면 전환·큰 이동입니다.
- View Transition 시험은 가짜
document로 합니다. 실제 브라우저 동작은 시험 범위 밖입니다.