Skip to content

마이그레이션

먼저 읽어야 할 범위 노트. 이 마이그레이션은 두 개의 도구가 이끌며, 이 둘은 함께 쓰이도록 만들어졌다. tabula migrate는 기계적인 부분을 담당한다: 오직 1:1로 증명 가능한 것만 다시 작성하고 그 외 모든 곳에는 위치가 표시된 TODO 주석과 진단 정보를 남기는 네 개의 codemod다. ESLint migration 프리셋은 판단이 필요한 부분을 담당한다: 남아 있는 모든 금지 패턴을 눈에 보이는, 위치가 표시된 경고로 바꾸어 여러분이 직접 해결하도록 한다. 여기에는 일부러 원커맨드 변환이 없다 — 디자이너의 의도를 추측하는 codemod는 아무도 리뷰하지 않은 변경을, 그 작성자가 마이그레이션되었다고 믿는 파일 안에 만들어낼 것이다. SPEC(.orchestrator/SPEC.md, J6)이 설명하는, 의도적으로 자동화되지 않은 유일한 변환은 dark: 호이스팅이다; §4가 이유를 설명한다.

Tabula v0.1.0 → v0.2.0 업그레이드

v0.2.0은 변형-폐쇄성 결함을 닫는다: v0.1.0에서는 변형이 접두된 모든 클래스(hover:bg-accent-hover, sm:p-md, 어떤 체인이든)가 아무 CSS도 내지 않았는데, 프리셋이 @source inline에 오직 기본 클래스만 화이트리스트에 올렸기 때문이다. v0.2.0은 선언된 변형 프로덕트(concepts.md § 변형)를 통해 변형 체인을 등록된 집합의 일급 구성원으로 만든다. 기존 프로젝트에 이것이 의미하는 바:

  • 다시 빌드해야 한다 — 레지스트리 스키마 v3는 호환성을 깨는 변경이다. 레지스트리 스키마 버전이 3으로 올라가고(레지스트리는 variantProducts, chainCount, chainExceptions, 그리고 리졸브된 미디어 조건을 얻는다), 그래서 sourceHash/cssHash가 바뀌고 커밋된 .tabula/는 업그레이드 즉시 오래된 상태가 된다. v0.1.0 레지스트리(스키마 v2)는 tabula build가 다시 만들어낼 때까지 v0.2 병합 런타임과 모든 레지스트리 로더에 의해 거부된다(TAB-E302) — 다른 무엇보다 먼저 이를 실행하라; 그렇게 하기 전까지는 tabula doctorbuild --check가 실패한다. 이는 수동 마이그레이션이 아니라 통상적인 드리프트/재빌드 경로다.
  • 변형이 이제 CSS를 낸다. 코드가 이미 쓰고 있던 체인(예: hover:bg-accent-hover)은 그 프로덕트가 선언되는 즉시 규칙을 만들어내기 시작한다 — 전에는 조용히 아무 일도 하지 않던 클래스가 이제 작동한다.
  • interaction 프리셋이 기본값이다. tabula.config.jsonvariants 섹션이 없으면, 프로파일은 모든 자기-상태 변형(hover, focus, focus-visible, focus-within, active, disabled)을 상호작용 패밀리 위에, 그리고 placeholderink/caret/accent 위에 선언한다. 이는 흔한 경우에 대해 출시된 결함을 별도 설정 없이 바로 고친다.
  • 더 선언하려면, variants.products 맵(체인 접두사 → 패밀리, 모든 비원자 패밀리는 "*")을 추가하거나 variants.presetall-len1로 바꾸라; 단일 일회성 체인에는 tabula except add --chain <chain>을 쓰라. 미디어 변형(sm: 등)은 토큰에 breakpoint 축을 요구하며(그렇지 않으면 TAB-E170), 프로덕트 전개는 variants.maxChainCandidates(기본값 20,000, 예산 초과 시 TAB-E172)로 상한이 걸린다.

tabula migrate

tabula migrate logical    # pl-* → ps-*, and the rest of the physical → logical axis
tabula migrate spacing    # space-x/y-* → gap-x/y-*, only where the axis is provable
tabula migrate merge      # clsx / classnames / tailwind-merge imports → @tabula-css/merge's cn
tabula migrate dark       # report-only: every dark:/light:/[data-theme=…] usage

모든 서브커맨드는 기본적으로 **드라이 런(dry run)**이며, git apply나 어떤 리뷰 도구에도 파이프할 수 있는 실제 유니파이드 diff(--- a/…, @@ 헝크 헤더)를 출력한다. 이를 적용하려면 --write를 추가하라. 클래스 싱크(sink) — className/class 속성이나 cn, clsx, cva, tv 등에 대한 인자 — 밖에서는 아무것도 다시 쓰이지 않으며, 오직 정적 문자열 영역 안에서만 다시 쓰인다. 그래서 우연히 pl-4를 담고 있는 주석, URL 상수, idalt는 잊힐 수 있는 검사가 아니라 구조적으로 건드려지지 않는다. 파싱되지 않는 파일은 보고되고 건너뛰어진다; 정규식 폴백은 없다.

종료 코드는 에이전트를 향한 계약이며, 1과 2는 결코 흐릿하게 섞이지 않는다:

종료 코드의미
0할 일이 없거나, --write가 모든 것을 적용해서 남은 것이 없다.
1마이그레이션 작업이 남아 있다: 대기 중인 재작성이 있는 드라이 런, 또는 TODO가 표시되었거나 report-only인 발견 사항.
2도구 자체가 고장났거나 잘못 실행되었다: 알 수 없는 서브커맨드, 로드할 수 없는 프로젝트, 또는 서브커맨드가 필요로 하는 레지스트리가 없는 경우.

두 채널 모두 실패 경로를 포함해 항상 응답한다. 사람용 채널은 diff와 요약을 얻는다; 기계용 채널은 모든 발견 사항이 file, line, col, subject, 그리고 최소 하나의 실행 가능한 fix를 담은 진단(diagnostic)인 봉투(envelope)를 얻는다. diff 안에만 존재하는 것은 아무것도 없으므로, --format=json을 읽는 에이전트는 결코 diff를 파싱할 필요가 없다.

순정(vanilla) Tailwind v4로부터

1. 설치하고 점진적으로 도입하기

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

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

migration은 네 개의 규칙 — no-runtime-class-construction, no-unregistered-arbitrary-value, no-theme-variant, no-important — 을 하드 에러로 유지하고 나머지(no-unknown-class, class-order, no-conflicting-classes 등)는 경고로 완화하므로, 한 번에 모두가 아니라 파일 단위로 전환을 진행할 수 있다. tokens/*.tokens.json 파일을 작성하기도 전에 실행해도 된다 — 네 개의 하드 에러 규칙은 레지스트리와 무관하기 때문이다.

2. 물리 → 논리 inline axis

pl-* pr-* ml-* mr-* left-* right-* border-l-* border-r-* text-lefttext-right는 단순히 등록되지 않는다 — Tabula는 논리적 형태(ps-* pe-* ms-* me-*start-* end-* border-s-* border-e-* align-start align-end)만을 등록한다. 코드베이스 안의 모든 물리적 축(physical-axis) 클래스는 tabula/no-unknown-class로 드러날 것이며, 논리적 이름과 물리적 이름이 정확히 한두 글자만 다르기 때문에(pl-4ps-4) 이 규칙에 내장된 레벤슈타인(Levenshtein) 기반 "혹시 이거였나요" 제안이 거의 항상 린트 출력에서 올바른 대체값을 직접 알려준다. 이 규칙에는 ESLint 자동 수정이 없다(의도적으로 — 헤더 주석 참고: 알 수 없는 클래스는 맹목적인 재작성이 아니라 사람이나 에이전트의 판단이 필요하다).

이것은 codemod가 실제로 1:1로 증명 가능한 유일한 축이므로, 하나가 마련되어 있다:

tabula migrate logical            # preview the diff
tabula migrate logical --write    # apply it

이 매핑은 물리 → 논리 에 대한 진술이며, 프로젝트의 토큰이 무엇이든 참이므로, 이 서브커맨드는 빌드된 레지스트리를 필요로 하지 않고 무조건 재작성한다. 마음에 새겨둘 만한 두 가지 결과:

  • 축을 고칠 뿐, 값을 고치지는 않는다. pl-4를 썼는데 4라는 spacing 토큰이 없다면, 결과인 ps-4도 여전히 등록되지 않은 채로 여전히 아무 CSS도 내지 않는다. tabula scan이 그것을 잡아내는 게이트다; 명령은 같은 알림을 출력한다.
  • text-lefttext-start가 아니라 align-start가 된다. align-start/align-end@tabula-css/core의 정적 유틸리티 테이블이 실제로 등록하는 것이며, text-start를 내보낸다면 코드베이스의 모든 text-left를 아무것도 컴파일되지 않는 클래스로 마이그레이션하는 셈이 된다.

매칭은 variant와 음수 부호가 벗겨진 뒤, 전체 클래스 토큰에 대해 이루어진다 — 그래서 place-content-center, border-large, xpl-4는 건드려지지 않고, variant 체인은 그대로 이어지며(md:hover:border-l-2md:hover:border-s-2), 음수는 negatable한 논리적 패밀리로 매핑된다(-ml-4-ms-4). 템플릿 리터럴 안에서는 정적 quasis만 재작성되지만, 보간(interpolation)에 인접한 fragment는 재작성되지 않는다: `pl-${n}` 안의 텍스트 pl-은 실제 값을 codemod가 알 수 없는 클래스 fragment이므로, 추측하는 것은 거부된다.

3. space-* / divide-*gap-*와 자식별 테두리

이것들 역시 레지스트리에 없으므로 no-unknown-class로 드러나지만 — 금지된 메커니즘 설명이 아니라 일반적인 "알 수 없는 클래스"로. 무엇인지가 아니라 왜인지를 알 수 있는 더 나은 두 가지 방법이 있다:

  • tabula-mcpexplain_ban 도구(또는 tabula explain)에 그 특정 클래스에 대해 물어보라 — 결코 벌거벗은 "찾을 수 없음"이 아니라 메커니즘의 이유와 대체안을 반환한다.
  • 이미 eslint-plugin-better-tailwindcss를 사용하고 있다면, @tabula-css/eslint-plugin/banlist에서 내보내는 betterTailwindcssBanlist()를 그 도구의 no-restricted-classes 옵션에 스프레드해 넣으면 여러분의 에디터에서도 같은 메시지를 인라인으로 볼 수 있다.

부모 위의 space-y-4gap-y-md(또는 가장 가까운 spacing 토큰)와 flex flex-col로 대체하고; divide-y는 자식 사이의 <Separator />(@tabula-css/react가 하나를 제공한다)나 각 자식에 직접 테두리 유틸리티를 다는 것으로 대체하라. 두 변경 모두 스타일링을 부모의 마크업에서 자식의 마크업으로 옮기는데 — 그것이 바로 요점이다.

tabula migrate spacing은 그중 증명 가능한 부분을 처리하고 나머지는 표시한다:

tabula migrate spacing --write

이는 그 엘리먼트 자신의 클래스 문자열에서 다음이 모두 성립할 때만 하나의 엘리먼트에 한해space-x-*gap-x-*, space-y-*gap-y-*로 재작성한다: flex(또는 inline-flex)를 지니고 있다; 축에 맞는 명시적인 flex-row/flex-col을 지니고 있다; 어떤 breakpoint에서도 variant가 붙은 display나 방향 클래스가 축을 바꾸지 않는다; 그리고 목표 gap-x-*/gap-y-* 클래스가 실제로 레지스트리에 있다. 그 외의 경우는 정확한 이유를 명시한 TODO(tabula migrate spacing) 주석과 진단을 얻을 뿐 — 결코 재작성되지 않는다.

네 가지 거부는 미구현이 아니라 의도적이다:

  • 명시적 방향 없는 순수 flex는 거부된다. flex-direction: row는 CSS 초기값이므로 flex 단독은 오늘은 row다 — 하지만 반응형 variant, 부모 스타일시트, 또는 style prop이 이를 바꿀 수 있고, 그중 어느 것도 클래스 문자열만으로는 보이지 않는다. 거부하는 비용은 단어 하나 (flex-row)이고, 받아들이는 비용은 조용히 잘못된 레이아웃이다.
  • grid는 결코 자동 재작성되지 않는다. space-x-*의 margin은 DOM 순서상 첫 번째 이후의 모든 자식에 적용되는데, 아이템이 두 번째 줄로 감싸이는 순간 이는 더 이상 column-gap에 대응하지 않는다.
  • 결코 순수한 gap-*는 아니다. gap, gap-x, gap-y는 세 개의 별개 패밀리다; gap-*로 뭉뚱그리면 조용히 다른 축에도 간격을 더하게 된다.
  • 결코 값을 지어내지 않는다. gap-y-4가 등록되어 있지 않다면, space-y-4는 가장 비슷해 보이는 토큰으로 재작성되는 대신 표시(flag)될 뿐이다.

migrate logical과 달리 이 서브커맨드는 빌드된 레지스트리를 필요로 한다 — 목표가 축이 아니라 값이며, md가 실제 spacing 토큰인지는 오직 레지스트리만이 안다. 레지스트리가 없으면 종료 코드 2를 내고 tabula build를 실행하라고 알려준다. "이 재작성이 안전한지 판단할 수 없다"는 것은 위반하는 프로젝트가 아니라 고장난 도구이기 때문이다.

4. dark: → 테마 축 토큰

no-theme-variant는 모든 dark:/light:/[data-theme=…]: variant를 두 프리셋 모두에서 에러로 표시한다(레지스트리와 무관한 — 순수한 구문 검사다). 자동 수정은 없는데, 고치려면 오직 여러분만이 갖고 있는 값 — 토큰의 다른 테마의 리터럴 — 이 필요하기 때문이다. 표시된 각 컴포넌트에 대해:

  1. tokens/*.tokens.json에서 두 리터럴을 모두 담은 $axis: "theme" 값을 가진 색상 토큰을 찾거나 만든다(concepts.md § Theming 참고).
  2. bg-white dark:bg-gray-900을 하나의 토큰 유틸리티로 대체한다, 예를 들어 bg-surface.
  3. dark: 클래스를 완전히 삭제한다 — 토큰 유틸리티가 이미 두 값 모두를 담고 있다.

tabula migrate dark는 그 작업 목록을, 모든 dark:/light:/[data-theme=…] 사용에 대해 file:line:col과 토큰-축 설명을 붙여서 준다. 이는 report-only이며, --write는 아무것도 바꾸지 않는다 — 이는 빠진 기능이 아니라 서브커맨드의 요점이다. 이 변환을 자동화할 수 없게 만드는 것은 두 가지다. 목적지는 토큰 파일이지 클래스 문자열이 아니다: 클래스는 이름 하나로 줄어들고 정보는 tokens/*.tokens.json으로 옮겨간다. 그리고 그 토큰을 만들려면 토큰의 다른 테마 리터럴이 필요한데, dark: 클래스 하나만 있을 때는 소스 어디에도 존재하지 않고 어떤 규칙으로도 라이트 값에서 유도할 수 없다. codemod라면 그것을 지어내야 할 것이다 — 바로 이 프로파일이 막고자 설계된 조작(fabrication)이며, 그것도 코드가 없는 것보다 더 나쁘다. 그 출력은 리뷰된 것처럼 보이기 때문이다. 그래서 명령은 보고만 하고, 그 빠진 값을 실제로 가진 경로들을 가리킨다: 여러분, tabula except add, 그리고 MCP의 propose_token / get_tokens 도구. 사용례가 하나라도 있으면 종료 코드는 1이며, 이것이 의도된 신호다: 어떤 도구도 할 수 없는 테마 작업이 여기 있다는 뜻이다.

5. Arbitrary values → 등록된 토큰이나 exception

no-unregistered-arbitrary-value는 두 프리셋 모두에서 하드 에러다. 각 [...] 값에 대해: 충분히 가까운 기존 토큰이 있는지 tokens.resolved.json / find_class_for로 확인하고; 맞는 것이 없다면 대괄호 구문을 그대로 두는 대신 이름 있고, 소유자가 있고, 만료되는 클래스를 만들기 위해 tabula except add를 실행하라(getting-started.md § 7 참고).

shadcn/ui로부터

shadcn/ui와 Tabula는 겹치는 문제(작고 소유된 컴포넌트 집합, Tailwind 기반, 설계상 에이전트 친화적)를 서로 다른 메커니즘으로 해결한다. 무엇이 바뀌는가:

cn()@tabula-css/mergecn()

shadcn의 cn = (...inputs) => twMerge(clsx(inputs))이름 형태 휴리스틱으로 병합한다 — tailwind-merge는 접두사로부터 어떤 유틸리티가 충돌하는지 추측하며, arbitrary-value 대 utility 충돌을 명시적으로 해결하지 않는다(twMerge('p-4 [padding:1rem]')는 둘 다 유지하고, 스타일시트 순서가 조용히 승자를 결정하게 둔다). @tabula-css/mergecn()은 같은 호출 형태 — cn(...classValues) — 를 가지지만 레지스트리에 선언된 슬롯 소유권으로 병합한다: 휴리스틱이 아니라 수학적으로 건전하며(T2), 진짜로 조합 가능한 모든 쌍(예: shadow-md + ring-2)은 우연한 명명이 아니라 테스트된 골든 케이스다. import만 바꾸면 된다; 호출 지점은 형태를 바꿀 필요가 없지만, 이제 모든 클래스는 레지스트리가 실제로 담고 있는 것이어야 한다.

tabula migrate merge는 import를 바꾸고 모든 호출 지점은 그대로 둔다, 이것이 별칭 (aliasing)을 강제하는 이유다: import clsx from 'clsx'와 마흔 개의 clsx(...) 호출을 가진 파일은 import { cn as clsx } from '@tabula-css/merge'가 된다. 바인딩 이름은 그대로 여러분의 것으로 남고, 그 출처만 바뀐다. 알아둘 만한 세 가지 동작:

  • twMerge는 재작성되고 동시에 표시된다. 목적지는 건전한 쪽이지만, 병합 의미론이 실제로 바뀐다 — 휴리스틱에서 슬롯 소유권으로 — 그래서 모든 호출 지점은 리뷰가 필요하다. 명령은 TODO 주석과 그렇다는 경고를 내며, --write 이후에도 실행은 종료 코드 1로 남아, 결코 기계적인 no-op으로 오인될 수 없다.
  • 오직 단독 specifier만 재작성된다. import clsx, { type ClassValue } from 'clsx'는 표시와 함께 그대로 남는다: ClassValue@tabula-css/merge 아래 같은 이름으로 존재할 수도 아닐 수도 있으며, 선언을 재작성하는 것은 바인딩을 잃어버리거나 명령이 검증하지 않은 export를 단정짓는 것 중 하나가 될 것이다. 선언을 분리하고 다시 실행하라.
  • 결코 중복 바인딩을 만들지 않는다. 로컬 이름이 그 파일에서 이미 @tabula-css/merge로부터 임포트되어 있다면, 오래된 import는 재작성되는 대신 삭제된다 — 중복된 로컬 바인딩은 구문 오류이기 때문이다.

cvavariants()

같은 아이디어(base 문자열 더하기 이름 있는 variant 그룹 더하기 defaultVariants)를, @tabula-css/merge 안에서 정적이고 리터럴인 설정으로 재구현하여, 순수 클래스 문자열을 커버하는 것과 같은 ESLint 어휘 검사가 그 안의 모든 문자열도 커버하도록 한다. 형태는 getting-started.md § Variants를 참고하라; examples/reference-uibutton.tsx는 shadcn Button 패턴으로부터의 완전한 변환 예제다.

:root / .dark CSS 변수 쌍 → 축 토큰

shadcn의 테마 파일은 CSS 커스텀 프로퍼티를 두 번 정의한다 — 한 번은 :root 아래, 한 번은 .dark 아래 — 그리고 컴포넌트는 Tailwind의 @theme inline 브리지를 통해 이를 읽는다. Tabula의 답은 concepts.md의 축 모델이다: 하나의 토큰, 두 리터럴을 모두 가진 하나의 $axis: "theme" 값이, 빌드 시점에 :root[data-theme="dark"]로 해석된다. 두 가지 구체적인 변경: .dark { --variable: ... } 블록을 삭제하고 그 값을 토큰의 axis map으로 접어 넣는다; 그리고 여러분 자신의 CSS에서는 결코 @theme inline을 쓰지 않는다 — 여기서는 금지된 메커니즘이다(테마 전환이 실제로 동작하게 만드는 축 재지정(re-pointing)을 우회하기 때문이다).

className 통과(pass-through) → 타입이 지정된 ClassName + 마지막에 오는 cn(..., className)

두 생태계 모두 이미 관례상 className을 마지막에 둔다; Tabula는 이를 린트 규칙으로 만들고 (tabula/classname-last, 자동 수정 가능) prop을 ClassName(@tabula-css/merge가 내보내는 브랜디드 문자열)으로 타입 지정하여, tabula/no-runtime-class-construction이 구조 분해된 prop을 검사되지 않은 런타임 구성으로 표시하는 대신 승인된 통과 값으로 인식하게 한다.

그대로 남는 것

컴포넌트의 형태 — 전달되는 ref, 타입이 지정된 props 인터페이스, 설정보다 조합(composition over configuration) — 은 바뀌지 않는다; Tabula의 어떤 것도 Radix 프리미티브나 shadcn의 copy-in 파일 레이아웃을 제거하라고 요구하지 않는다. @tabula-css/react는 Radix를 대체하려 하지 않는다 — 이 프로파일이 제거하는 세 가지 메커니즘(부분 타이포그래피 클래스, divide-*, prose 플러그인의 자손 셀렉터)에 대해 정확히 포장된 경로(paved-path)의 대체품을 주기 위해 존재하는 세 개의 작은 프리미티브(<Text>, <Separator>, <Prose>)를 배포할 뿐이다. shadcn 스타일 컴포넌트 안의 그 외 모든 것 — 접근성 있는 상호작용 로직, compound-component 구조 — 은 스타일링 레이어와 직교하며 변경이 필요 없다.

.tabula/에서 프로파일 복원하기

커밋된 .tabula/만으로도 그것이 빌드된 소스 프로파일을 거의 손실 없이 재구성하기에 충분하다. 정방향 빌드는 구조상 출처(provenance)를 보존한다: tokens.resolved.json은 각 토큰의 $deprecated$extensions를 그대로 담고 있어서 — $extensions.tabula.contrastWith 접근성 계약과 어떤 외부 벤더 네임스페이스(예를 들어 com.example.figma 참조)든 온전히 되돌아온다 — 그리고 소스 $value{color.surface} 같은 단일 최상위 별칭 참조였을 때는 $alias 마커가 그 점(dot)-경로를 기록하므로, 평탄화된 리터럴이 아니라 참조 자체가 복원된다. 그 곁에서 tabula.config.json은 그 디렉터리를 빌드한 설정의 바이트 단위 동일 복사본이므로, 축, 프로파일 레벨, 변형 프로덕트를 추측할 필요가 없다. 복원하려면 tokens.resolved.json을 읽어, 각 $alias를 그 {path} 참조로 되돌리고, $extensions/$deprecated를 그대로 옮기고, 토큰을 복사된 설정과 짝지으면 된다.

남는 유일한 손실 종류: 복합 값 안의 중첩되거나 부분적인 별칭 — type 복합 값의 한 필드, shadow의 한 레이어, 또는 축 맵의 한 멤버로 쓰인 별칭 — 은 표시되지 않고 그 리졸브된 리터럴로 되돌아오는데, 오직 단일 최상위 {path} $value만 기록되기 때문이다. 그 외 나머지는 모두 같은 어휘로 왕복한다.

Tabula에서 완전히 동결(eject)하기

복원은 소스를 다시 빌드하고; 동결(ejecting)(실험적)은 반대 방향으로 간다 — 출력을 얼린다. tabula eject는 검증된 .tabula/를 프로젝트 소유의 디렉터리로 복사하여 클래스 변경 없이 순정 @tailwindcss/cli로 컴파일되게 만들고, 그 디렉터리로 흘러들어가는 토큰 파이프라인을 멈춘다. 이는 돌아올 수 없는 문이다: eject 이후에는 다시 빌드하는 일도, scan 게이트도, 드리프트 검사도 없으며, 토큰 변경은 더 이상 동결된 복사본에 닿지 않는다. 런타임 cn()은 여전히 @tabula-css/merge와 복사된 registry.json을 필요로 한다 — 이를 tailwind-merge로 바꾸면 렌더링된 출력이 달라진다. 전체 흐름, 이 명령이 다루는 네 가지 위험 요소, 정확한 명령 서피스는 동결(Eject)을 참고하라.

Released under the MIT License.