Skip to content

시작하기

이 문서는 빈 프로젝트에서 CI로 검증되는 빌드에 이르기까지, Tabula 위에서 작은 컴포넌트 집합을 만들어가는 과정을 안내한다. 여기 등장하는 모든 명령과 코드 블록은 실제다 — examples/reference-ui에서 가져왔거나 이 저장소의 테스트 스위트에서 실제 CLI를 대상으로 실행한 것이다.

1. 설치

bash
npm install -D @tabula-css/cli @tabula-css/eslint-plugin
npm install @tabula-css/merge @tabula-css/preset
# optional: paved-path primitives (<Text>, <Separator>, <Prose>)
npm install @tabula-css/react

Node 20 이상과 Tailwind CSS v4가 필요하다 (@tabula-css/preset은 v4 엔진을 특정해서 대상으로 한다).

2. 토큰 작성하기

Tabula는 프로젝트 루트에 있는 모든 tokens/*.tokens.json 파일을 읽고(여러 개일 수 있다 — 하나의 네임스페이스는 정확히 하나의 파일에만 존재해야 하며, 그렇지 않으면 빌드가 에러를 낸다) 이를 병합한다. 토큰은 플랫하고 DTCG 형태의 프로파일이다: 깊이는 두 단계이며, 모든 값은 리터럴이거나 전체적으로 선언된 축 맵(axis map)이다 — 결코 $ref도, 별칭 체인(alias chain)도 아니다.

tokens/base.tokens.json:

json
{
  "color": {
    "surface": {
      "$type": "color",
      "$description": "Default page background. The bottom-most layer of the UI.",
      "$value": { "$axis": "theme", "light": "#ffffff", "dark": "#0b0b0c" }
    }
  },
  "spacing": {
    "md": {
      "$type": "dimension",
      "$description": "Default spacing unit. Card padding and gaps use this.",
      "$value": { "value": 1, "unit": "rem" }
    }
  },
  "radius": {
    "md": {
      "$type": "dimension",
      "$description": "Default corner radius for cards and raised surfaces.",
      "$value": { "value": 0.5, "unit": "rem" }
    }
  }
}

모든 토큰은 최소 20자 이상의 $description이 필요하다(TAB-E109) — 이것이 역방향 조회 (find_class_for)를 가능케 하는 요소이므로, 모호한 설명은 사소한 문서화 누락이 아니라 실질적인 결함이다. $axis 값은 반드시 **전체적(total)**이어야 한다: 선언된 모든 축 멤버가 값을 가져야 하며, 폴백은 허용되지 않는다(TAB-E113) — 다크 모드에서 조용히 라이트 모드 값을 그대로 유지하는 토큰이야말로 축 모델(axis model)이 제거하고자 하는 바로 그 보이지 않는 버그다.

3. 축 선언하기

tabula.config.json:

json
{
  "profileId": "my-app@1",
  "profileLevel": "base",
  "axes": {
    "theme": {
      "values": ["light", "dark"],
      "default": "light",
      "attribute": "data-theme",
      "media": { "dark": "(prefers-color-scheme: dark)" }
    }
  },
  "groups": []
}

profileLevel은 타이포그래피의 엄격도를 결정한다(자세한 내용은 concepts.md 참고); base — 필드를 생략했을 때의 기본값 — 는 표준 text-*/font-* 유틸리티를 봉쇄(containment) 규칙 아래에 둔다. axes.theme.attribute는 테마를 전환하는 DOM 속성(data-theme="dark")이고, media.dark는 어떤 속성도 설정되기 전, 첫 페인트를 위한 prefers-color-scheme 폴백이다.

4. 빌드하기

bash
npx tabula build
✓ wrote 12 artifacts to .tabula/ (inputsHash 67a91fcf6338)
  AGENTS.md.snippet
  llms-full.txt
  llms.txt
  manifest.json
  profile.css
  registry.json
  source.css
  tabula.config.json
  theme.css
  tokens.resolved.json
  types.d.ts
  vocabulary.txt

build는 원자적이며 실패 시 닫힌다(fails closed): 토큰이나 설정에 오류가 하나라도 있으면 아무것도 기록되지 않는다(종료 코드 1과 함께 위반 사항의 진단 정보가 출력된다). 모든 아티팩트는 @generated <hash> 헤더를 달고 있으며 읽기 전용(0444)으로 기록된다 — .tabula/ 아래의 무엇이든 결코 손으로 편집하지 말 것; tabula doctor가 이를 감지하며(TAB-E601) 다음번 tabula build에서 그 편집을 폐기된 것으로 처리한다.

각 아티팩트의 역할

파일용도
source.cssTailwind v4 엔트리: source(none) + 테마 + 등록된 클래스마다 하나씩의 @utility + 닫힌 어휘(closed vocabulary)를 정확히 지칭하는 @source inline(...). 번들러가 임포트하는 대상이 바로 이 파일이다.
theme.css@theme 블록과 루트 스코프 축 블록만 담고 있으며, source.css에도 그대로 임베드된다.
profile.css등록된 모든 유틸리티의 CSS를, 랭크 오름차순(SPEC J2)으로 재출력한 것 — 어휘의 출력을 검사하기 위한 것이다. 실제로 배포되는 전부는 아니다: 아래 참고.
registry.json진리의 원천(Ground truth). class → declarations, slots, rank, custom properties, exceptions, bans. 그 외 모든 것은 여기서 파생된다.
tokens.resolved.json축 조합별 모든 토큰의 리터럴 값 — 값을 직접 고르기 전에 읽어야 한다.
vocabulary.txt선언(declaration)과 설명이 딸린, 합법적인 모든 클래스 — 클래스를 작성하기 전에 읽어야 한다.
types.d.ts등록된 모든 클래스 이름의 TypeScript 유니온: 에디터 자동완성이자 컴파일 타임 폐쇄성 게이트.
llms.txt / llms-full.txt / AGENTS.md.snippet에이전트를 위한 서피스 — agents.md 참고.
manifest.json무결성의 루트: inputsHash, profileVersion, 아티팩트별 sha256. doctorbuild --check가 비교하는 대상.
tabula.config.json이 디렉터리를 빌드한 설정의 정준(canonical) 형태 사본 — 동결된 .tabula/가 자신을 무엇이 빌드했는지 진술한다.

profile.css가 보여주지 않는 것

profile.css는 컴파일된 출력에서 캡처된 것이 아니라 registry.json에서 다시 생성된 것이다 (emitProfileCss, packages/registry/src/generate/assemble.ts). 그 전체 본문은 하나의 @layer utilities { … } 블록이다. 따라서 등록된 어휘만을 정확히 보여줄 뿐 그 외에는 아무것도 보여주지 않는다 — 특히 Tailwind의 base 레이어는 보여주지 않는다.

그 레이어는 여전히 배포된다. @import "tailwindcss" source(none)소스 스캐닝만 끄는 것이지 base를 제거하지 않는다. 따라서 .tabula/source.css를 임포트하면 preflight도 함께 전달된다 — box-sizing: border-box, margin: 0, border: 0 solid, 대체 엘리먼트(replaced elements)의 display: block, 폼 컨트롤 리셋 등이 모든 엘리먼트에 적용되며, 레지스트리에도 vocabulary.txt에도 나타나지 않는다. grep box-sizing .tabula/profile.css는 아무것도 반환하지 않지만, 브라우저는 여전히 그것을 받는다.

실무적으로 말하면: 어떤 엘리먼트의 계산된 스타일에서 자기 자신의 클래스가 설정하지 않은 프로퍼티가 보인다면, 가장 먼저 살펴봐야 할 곳은 preflight이며 profile.css는 그것을 찾는 데 도움이 되지 않는다. 전체 목록은 node_modules/tailwindcss/preflight.css를 읽어보라.

5. 앱에 연결하기

생성된 스타일시트를 Tailwind 엔트리 포인트로 임포트한다(경로는 자신의 CSS 엔트리 파일에 맞게 조정한다):

css
@import "../.tabula/source.css";

이 툴체인이 여러분의 CSS에서 무엇을 검사하고, 여전히 무엇을 검사하지 않는지

프로젝트 스타일시트는 예전에는 이 툴체인의 어떤 것도 읽지 않았다. 지금은 tabula check:css (--no-css를 넘기지 않는 한 tabula build --check가 실행한다)와 에디터 안의 @tabula-css/stylelint-plugin에 의해 읽힌다. 프로젝트의 모든 .css 파일에 다섯 가지 금지 사항이 적용된다: @apply(TAB-E221), 이 엔트리 파일 밖에 손으로 작성된 규칙(TAB-E222), :root/html 이외에 정의된 --tb-*/--d-*(TAB-E223), @theme inline(TAB-E224), !important(TAB-E225). 방금 @import를 추가한 그 파일은 **승인된 엔트리(sanctioned entry)**로, 루트 레벨의 애플리케이션 CSS를 담을 수 있으며 TAB-E222에서만 예외이고 그 외에는 어떤 것도 면제되지 않는다. 전체 그림과, --css-entry로 관례에서 벗어난 엔트리에 이름을 붙이는 방법은 CSS governance를 참고하라.

여러분이 여전히 스스로 감시해야 할 한 가지가 있다: 폐쇄성은 딱 한 겹 깊이만 적용된다. source(none).tabula/source.css의 어휘만을 닫는다. 스타일시트의 나머지 부분에 대해서는 아무 것도 말해주지 않으며, 어느 게이트도 다음 둘을 걸러내지 못한다. @source@import는 프로젝트 CSS가 담고 있어도 되는 at-rule이기 때문이다:

css
@import "../.tabula/source.css";
@source "./src";                 /* ← scanning back on: every Tailwind class now emits */
@import "tailwindcss";           /* ← same, without source(none) */

그런 클래스들이 동작하게 만드는 테마 네임스페이스는 어느 경우든 여전히 정의되어 있다. profileLevel: "base" 아래에서는 프리셋이 프로파일 고유의 폰트 패밀리와 충돌하는 타이포그래피 스케일만 리셋한다(--text-*, --leading-*, --font-weight-*, --font-*, --tracking-*, packages/preset/src/index.ts에 있음); strict 아래에서는 아무 리셋도 내보내지 않는다. --color-*, --spacing, --radius-*, --shadow-*는 두 경우 모두 살아있으므로, bg-red-500p-4는 여전히 완전히 *구성 가능(constructible)*하다 — 이들은 무언가가 Tailwind에게 자신들을 스캔하라고 알려주지 않는 한에서만 비활성일 뿐이다. 폐쇄성은 생성된 엔트리 파일의 속성이지, 프로젝트의 속성이 아니다.

그러므로: .tabula/source.css@import를 프로젝트 안의 유일한 Tailwind 엔트리로 유지하고, @source 줄은 추가하지 말고, 배포 전에 나머지를 grep해서 확인하라 —

bash
grep -rn '@source\|@import "tailwindcss"' src/**/*.css

그런 다음 vocabulary.txt에서 곧바로 클래스를 작성한다:

tsx
import { cn, type ClassName } from '@tabula-css/merge';

export function Card({ className }: { className?: ClassName }) {
  return <div className={cn('bg-surface p-md rounded-md', className)} />;
}

classNameClassName으로 타입이 지정된다 — @tabula-css/merge가 내보내는 브랜디드(branded) 문자열 타입이다 — 이는 이를 승인된 통과 값(pass-through)으로 표시하므로, tabula/no-runtime-class-construction은 이를 구조 분해(destructuring)하여 곧바로 cn()에 전달하는 것을 허용한다. 소비자(consumer)의 오버라이드는 항상 마지막에 오므로, 그것이 건드리는 슬롯은 결정론적으로 항상 이긴다:

tsx
<Card className="p-lg" />   // → "bg-surface rounded-md p-lg"  (p-md fully shadowed)

Variants

이름 있는 변형(variant)을 가진 컴포넌트라면, @tabula-css/merge가 제공하는 CVA에 상응하는 variants()가 정적이고 리터럴인 설정을 받는다 — 그 안의 모든 문자열은 일반 클래스 문자열과 동일하게 검사되는데, 이는 그 맵도 소스 텍스트만큼이나 스캔 가능하기 때문이다. (이 예제는 위에서 소개한 세 개의 토큰이 아니라, examples/reference-ui의 더 풍부한 토큰 집합에서 클래스 이름을 재사용한다.)

tsx
import { variants, type ClassName } from '@tabula-css/merge';

const button = variants({
  base: 'rounded-md gap-sm inline-flex items-center justify-center',
  variants: {
    variant: {
      solid: 'bg-accent',
      outline: 'border-border border-thin',
    },
    size: {
      sm: 'px-sm h-control-sm',
      md: 'px-md h-control-md',
    },
  },
  defaultVariants: { variant: 'solid', size: 'md' },
});

<button className={button({ variant: 'outline', size: 'sm', className })} />

전체적인, 린트를 통과하는 버전(포커스 링, disabled 상태, 트랜지션 포함)은 examples/reference-ui/src/button.tsx를 참고하라.

6. ESLint 설정하기

eslint.config.js:

js
import tabula from '@tabula-css/eslint-plugin';

export default [
  {
    ...tabula.configs.strict,
    files: ['**/*.{ts,tsx}'],
    settings: {
      tabula: { registry: '.tabula/registry.json' },
    },
  },
];

strict는 15개의 tabula/* 규칙 하나하나를 모두 에러로 전환한다 — 알 수 없는 클래스, 금지된 메커니즘(space-*, divide-*, dark:, arbitrary values, 이름 없는 group), 클래스 순서, 동일 문자열 내 슬롯 충돌, className 마지막 배치 등등. 기존 코드베이스에 도입하는 중인가? 대신 tabula.configs.migration을 사용하라: 금지 규칙들(no-runtime-class-construction, no-unregistered-arbitrary-value, no-theme-variant, no-important)은 여전히 하드 에러로 남고, 나머지는 경고로 완화되어 점진적으로 전환할 수 있다. migration.md를 참고하라.

7. 예외 워크플로

때로는 등록된 토큰이 필요한 값을 정말로 커버하지 못하는 경우가 있다. tabula except add는 실제 토큰 문서에 대해 제안을 검증하고 패치를 출력한다 — --apply를 넘기지 않는 한 아무것도 기록하지 않는다:

bash
npx tabula except add \
  --name hero-legacy-width --type dimension --value 347px \
  --families w --reason "Legacy marketing hero matches a fixed CMS image at exactly 347px; no rem token this specific exists and none should." \
  --owner @growth-team --expires 2027-01-15 --allowed-in "src/marketing/hero.tsx"
✓ valid. Add this to tokens/exceptions.tokens.json:

{
  "exception": {
    "hero-legacy-width": { "$type": "dimension", "$value": { "value": 347, "unit": "px" }, ... }
  }
}

✓ will mint class: w-hero-legacy-width

  Nothing was written. Apply the patch, then run `tabula build`.

reason은 최소 40자 이상이어야 한다(보일러플레이트 방지 검사 — "needed it" 같은 문구는 통과하지 못한다); expires는 이름 있는 예외의 경우 최대 12개월 후까지, --literal 압력 밸브 (pressure-valve) 레인의 경우 90일까지로 제한된다. 기존 토큰이 근사치(약 5% 이내)로 존재하면 조용히 다시 만드는 대신 경고가 발생한다. 패치를 곧바로 기록하려면 --apply를 넘겨라 — 그러면 tabula build를 다시 실행할 때까지 .tabula/는 오래된 상태(stale)가 되고, doctor가 그렇게 알려준다.

8. CI에서 검사하기

두 개의 명령이 CI 게이트를 구성한다:

bash
npx tabula build --check   # exit 1 if the committed .tabula/ doesn't byte-match a fresh build — then runs the scan gate
npx tabula doctor          # staleness, hand-edited artifacts, expiring exceptions, version skew, budgets

build --check는 이미 스캔 게이트를 포함하므로, 저 둘이 게이트의 전부다. 드리프트 검사 없이 스캔만 원한다면 — 프리커밋 훅이나 빠른 PR별 잡을 위해 — 별도로 scan을 실행하라:

bash
npx tabula scan --strict   # exit 1 on any class outside the vocabulary, in any source file type

tabula scan — 여러분의 소스를 읽는 게이트

ESLint는 .ts/.tsx만 본다. scanTailwind 자체의 스캐너(@tailwindcss/oxide)를 소스 글롭(glob) 위에서 실행하여 — CSS를 만들어낼 바로 그 추출 과정을 그대로 이용해 — 찾아낸 후보들을 레지스트리, 여러분의 예외 목록, foreignClasses와 비교한다. 발견 사항은 file:line:col과 함께 TAB-E201로 보고되며 종료 코드는 1이다.

기본 글롭(tabula.config.jsonscan.sources가 이를 오버라이드한다; @tabula-css/coreDEFAULT_SCAN_SOURCES)은 src/, app/, pages/, components/를 재귀적으로 커버하며, 대상 확장자는 다음과 같다:

js jsx mjs cjs ts tsx mts cts md mdx html vue svelte astro

node_modules/, dist/, .tabula/는 결코 스캔되지 않는다; 더 추가하려면 scan.ignore를 사용하라.

Tailwind의 스캐너는 의도적으로 관대하다 — 파일 안에서 단어처럼 생긴 모든 런(run)을 추출한다 — 따라서 등록되지 않은 후보를 모두 보고한다면 평범한 산문이나 식별자까지 걸리게 된다. 그래서 scan은 후보가 등록되지 않은 것이면서 동시에 유틸리티처럼 생긴 경우에만 보고하며, 이는 네 가지 독립적인 신호 중 하나로 판단한다: 금지 목록 히트(레지스트리와 무관하게 동작 — .tabula/가 전혀 없어도 발동한다), arbitrary-value 또는 arbitrary-property […] 구문, 모든 변형(variant)이 해석되는 변형 체인(md:whatever), 또는 등록된 패밀리 접두사에 등록되지 않은 값이 붙은 경우 (p-md는 존재하지만 p-7인 경우). 이것이 scan의도에 대한 강력한 게이트로 만들지만 린터의 완전한 상위집합은 아니게 만든다: 린터는 문자열이 className 안에 있다는 것을 알지만, 문맥 없는(context-free) 스캐너는 그럴 수 없다.

--strict는 억제 예산(suppression budget, TAB-W900)을 추가한다: 이는 tabula/ 규칙을 지칭하는 eslint-disable 주석의 개수를 센다 — 규칙 목록이 없는 포괄적 disable도 포함되는데, 이는 Tabula 규칙과 다른 모든 규칙을 함께 침묵시키기 때문이다 — 그리고 그 개수가 설정의 budgets.maxSuppressions를 초과하면 실패한다. 그 예산의 기본값은 0이므로, --strict 아래에서는 첫 번째 억제가 곧 실패다. 예산이 강제되든 아니든, 그 개수는 매 실행마다 출력된다.

build --check는 바이트 diff가 통과한 뒤에 스캔 게이트 자체를 --strict 모드로 실행한다 — 드리프트를 먼저 검사하므로, 스캔에서 발견된 사항은 언제나 "이 소스가 잘못됐다"는 뜻이지 "레지스트리가 오래됐다"는 뜻이 결코 아니다. 이를 건너뛰고 드리프트만 검사하려면(이미 스캔을 마친 릴리스 잡을 위해) --no-scan을 넘겨라.

--check는 결코 기록하지 않는다 — 메모리 안에서 새로 생성한 바이트를 디스크와 비교할 뿐이다. doctor는 순서대로 다음을 실행한다: 오래됨(staleness)(토큰/설정 해시 대 매니페스트), 손편집(hand-edits)(각 아티팩트의 sha256 대 매니페스트 — 드리프트가 있으면 TAB-E601), 예외(exceptions)(만료된 것은 에러, 30일 이내 만료 예정인 것은 경고), 버전 스큐(version skew)(뒤섞인 @tabula-css/* 버전, 또는 .tabula/가 빌드될 때와 다른 Tailwind가 설치된 경우), 그리고 예산(budgets)(@tabula-css/core의 기본값에 대한 budgets.maxEscapes 리터럴-이스케이프 개수).

종료 코드는 모든 명령에서 동일한 형태를 따른다: 0은 정상, 1은 계약 위반, 2는 도구 자체가 고장난 경우(잘못된 입력, 내부 오류). 어떤 명령에든 --format=json을 추가하면 { tabula, ok, inputsHash, diagnostics } 형태의 기계용 봉투(envelope)를 얻는다 — 에이전트나 CI 단계가 파싱해야 할 대상은 이것이지, 결코 사람이 읽기 위한 텍스트가 아니다.

다음

  • 병합의 이론과 금지된 메커니즘 목록은 concepts.md를 참고하라.
  • AI 코딩 에이전트를 이 프로젝트에 연결하는 중이라면 agents.md를 참고하라.
  • 기존 Tailwind나 shadcn/ui 코드베이스를 전환하는 중이라면 migration.md를 참고하라.

Released under the MIT License.