Skip to content

개념 ​

지역성과 열 개의 법칙 ​

Tabula의 핵심 주장은 이렇다: 엘리먼트의 스타일은 그 자신의 클래스 문자열이다 — 조상이나 형제, 스타일시트 순서로부터 상속되거나 캐스케이드되는 것은 없다. 단, 엘리먼트 자신의 클래스 문자열이 비지역성(non-locality)을 선언하는 경우(group/name, type-inherit, prose)는 예외다. 자식이 어떻게 렌더링되는지 알기 위해 부모를 읽어야 한다는 것은 스타일 선택의 문제가 아니라 버그 클래스로 취급된다.

이 주장은 열 개의 규칙으로 부호화되어, 생성되는 모든 .tabula/llms.txt에 그대로(그리고 강제되어) 출력된다:

  1. vocabulary.txt에 있는 클래스만 존재한다; 그 외의 것은 조용히 아무 CSS도 내지 않는다. 런타임에 만들어진 클래스(`p-${n}`)는 결코 동작하지 않는다 — Tailwind는 소스 텍스트를 스캔한다.
  2. 클래스는 기억이 아니라 의도로 역방향 조회한다(MCP find_class_for).
  3. arbitrary value(w-[347px])는 절대 쓰지 않는다; 대신 tabula except add가 이름 있는 클래스를 만든다.
  4. dark:는 절대 쓰지 않는다 — 테마는 토큰 축이며, bg-surface가 이미 모든 테마를 커버한다.
  5. 더 높은 rank가 이긴다. 그것이 캐스케이드의 전부다 — 속성 순서는 아무것도 바꾸지 않는다.
  6. 두 클래스가 하나의 정적 문자열 안에서 한 프로퍼티를 쓰는 것은 린트 에러이지, 병합이 아니다.
  7. cn(base, …, className)으로 조합하라: 호출자의 className이 마지막에 오며 이긴다.
  8. 텍스트를 담는 모든 엘리먼트는 하나의 type-*와 하나의 ink-*를 갖거나, type-inherit을 명시한다.
  9. 어떤 부모도 자식을 스타일링하지 않는다: space-*, divide-*, *:, **:, [&>*]:, in-*는 존재하지 않는다. gap-*를 쓰거나, 유틸리티를 자식에 놓아라.
  10. 엘리먼트 간 의존성은 반드시 이름 있는 것이어야 한다(group/card이지, 이름 없는 group이 아니다).

레지스트리가 진리의 원천이다 ​

registry.json은 프로파일을 문서화한 것이 아니다 — 그것이 곧 프로파일이다. 다른 모든 아티팩트 (vocabulary.txt, llms.txt, ESLint 플러그인, @tabula-css/merge)는 여기서 파생된 뷰이며, 두 아티팩트가 서로 다르게 말한다면 레지스트리가 옳은 것으로 정의된다. 이는 @tabula-css/registry가 생성하는데, 이는 Tailwind 자체의 유틸리티 문법을 재구현하는 대신 Tailwind가 컴파일한 CSS 출력을 두 개의 독립적인 백엔드(design-system API, 또는 PostCSS 프로브시트 워크 — CI에서 서로 교차 검증됨) 중 하나로 파싱한다.

tokens.resolved.json은 값을 위한 짝이 되는 뷰다: 축별로 완전히 리졸브된 각 토큰의 리터럴, 그리고 — 정방향 빌드가 정보를 보존하도록 — 소스 토큰의 $deprecated, $extensions($extensions.tabula.contrastWith 접근성 계약과 외부 벤더 네임스페이스를 포함), 그리고 최상위 별칭 참조를 기록하는 $alias 마커다. 바이트 단위로 동일한 tabula.config.json이 그 곁에 함께 복사되므로, 동결된 .tabula/는 자신을 무엇이 빌드했는지를 정확히 진술한다.

전체 형태의 클래스 항목(examples/reference-ui의 bg-surface):

json
"bg-surface": {
  "family": "bg",
  "token": "color.surface",
  "declarations": [{ "property": "background-color", "slot": 9, "emitted": "var(--tb-color-surface)", "value": "#ffffff" }],
  "slots": [9],
  "rank": 30091300001,
  "spec": [0, 1, 0],
  "locality": "L0",
  "inherits": false
}
  • slots — 클래스가 소유하는 정규(canonical) longhand CSS 프로퍼티(그리고 프로파일 내부 커스텀 프로퍼티)의 숫자 id들. 레지스트리 최상위의 slots 맵은 id를 프로퍼티 이름으로 다시 해석한다(예: 9 → background-color); slotAliases는 논리적 프로퍼티를 동일한 물리적 슬롯에 매핑한다, 둘이 동등한 경우(padding-block-start ≡ padding-top).
  • rank — 등록된 모든 클래스에 대한 단일 정수 전체 순서(아래 참고).
  • spec — 셀렉터의 CSS 특정성(specificity)으로, 모든 일반 유틸리티에 대해 (0, 1, 0)으로 정규화된다(가상 엘리먼트는 (0, 1, 1)), 그래서 특정성은 아무 정보도 전달하지 않는다 — 오직 rank만이 결정한다.
  • locality — 자신이 붙은 엘리먼트만을 스타일링하는 클래스라면 L0이다. 어떤 유틸리티든 문서화된 격리(quarantine) 밖에서 L1/L2(형제나 자손에 닿는 셀렉터)로 분류되면 빌드가 실패한다.
  • composed(위에는 나타나지 않은, declaration 위의 속성) — declaration의 값이 리터럴이 아니라 var(--tb-*) 참조일 때 true다. declaration이 오직 composed인 것들뿐인 클래스는 그 프로퍼티에 대한 슬롯을 소유하지 않는다(SPEC J1 amendment) — 이것이 shadow-md와 ring-2가 충돌 없이 조합될 수 있는 방식이다: 이들은 같은 box-shadow 슬롯이 아니라 서로 다른 커스텀 프로퍼티를 쓴다.

병합: 캐스케이드 시뮬레이션이 아니라 전순서(total order) ​

등록된 모든 클래스는 고유한 rank를 갖는다(lex(−breadth, familyIndex, valueIndex) — 대략: 좁은 유틸리티가 넓은 것보다 먼저, 그다음 패밀리, 그다음 값 순). cn()과 resolve()는 브라우저의 캐스케이드(셀렉터 특정성, 소스 순서, !important)를 결코 시뮬레이션하지 않는다; 이들은 순수한 폴드(fold)를 실행한다:

  1. 모든 클래스 문자열을 공백으로 분리한다; 각 토큰을 (variants, utility)로 파싱한다.
  2. 각 클래스의 각 declaration에 대해 슬롯 키를 계산한다: (pseudoElement, condition, slot).
  3. 가장 높은 rank의 declaration이 각 키를 이긴다. 순회 순서는 무관하다.
  4. cn()은 추가로 fragment를 먼저 기준으로 정렬한다: 나중 fragment가 이전 fragment를 이긴다, 그리고 오직 하나의 fragment 안에서만 rank가 결정한다. 이것이 cn(base, className)을 건전한 오버라이드 메커니즘으로 만드는 이유다 — 호출자의 fragment는 자신의 내부 rank와 무관하게 이긴다.
  5. 생존자들은 출력을 위해 rank 오름차순으로 정렬되므로, 출력된 클래스 문자열은 왼쪽에서 오른쪽으로 "나중 것이 이긴다"(읽기 규칙, Reading Rule)로 읽히며, 원자적(atomic)이거나 알 수 없는 클래스는 작성자 순서 그대로 뒤에 덧붙는다.

풀어본 예제(packages/merge/test와 examples/reference-ui/.tabula/llms-full.txt에 대해 검증됨):

js
cn("p-md", "pt-sm")   // → "p-md pt-sm"
//   padding-top ← pt-sm (later fragment); the other 3 sides stay owned by p-md.
cn("pt-sm", "p-md")   // → "p-md"
//   p-md is later AND covers padding-top → pt-sm owns nothing → dropped entirely.
//   (tailwind-merge cannot express this: it keeps pt-sm, and stylesheet order then
//    silently makes it win — the opposite of the stated composition order.)

cn("bg-surface", "bg-surface-raised")        // → "bg-surface-raised"  (same slot, later wins)
cn("bg-surface", "hover:bg-surface-raised")  // → both survive (different condition ⇒ different slot key)
cn("shadow-md", "shadow-brand")              // → both survive (composed utilities write different custom properties)

개발 환경에서는 cn()이 호출될 때마다 스스로의 건전성을 검증한다(T2): 랭크로 각 슬롯의 승자를 독립적으로 다시 계산하고, 레지스트리에 선언된 rank 순서가 방금 고른 승자와 어긋나면 TAB-E301을 던진다 — 손상되거나 손편집된 레지스트리는 조용히 잘못 렌더링되는 대신 시끄럽게 실패한다.

프로파일 레벨: base 대 strict ​

타이포그래피는 두 가지 엄격도 레벨이 함께 제공되는 유일한 영역으로, tabula.config.json의 profileLevel로 설정한다(기본값: base):

  • **base**는 일반적인 text-* / font-* / leading-* / tracking-* 패밀리를 등록하며, 세 가지 방식으로 봉쇄(containment)한다: 상속 가능한, 프로파일이 소유하는 모든 CSS 프로퍼티는 정확히 하나의 :root 기본값을 갖는다(검사됨 — 없으면 TAB-E220); 상속 가능한 프로퍼티를 쓰는 유틸리티는 텍스트-리프(text-leaf) 태그이거나 scope-text 마커를 가진 엘리먼트에서만 합법이다 (tabula/inherited-property-boundary, 자동 수정 가능); 그리고 상속될 수 있는 값의 집합은 등록된 토큰으로 닫혀 있다.
  • **strict**는 부분적인 타이포그래피 대신 type-*로 대체한다 — font-family, size, weight, style, line-height, letter-spacing, text-transform, font-variant-numeric의 8개 프로퍼티 묶음을 하나의 클래스로 적용한다 — 그리고 색상을 위한 별도의 ink-*. 부분 클래스(text-sm 단독)는 아예 등록되지 않는다: 텍스트 엘리먼트는 하나의 declaration에 자신의 완전한 타이포그래피 정체성을 명시하거나, type-inherit을 명시적으로 선언한다.

examples/reference-ui는 strict를 사용한다(해당 tabula.config.json 참고); docs/benchmark.md는 어느 레벨이 실제로 에이전트에 더 도움이 되는지 측정하기 위해 설계된 연구를 설명한다.

테마: dark:가 아니라 축(axis) ​

테마는 CSS variant가 아니다 — tabula.config.json에 한 번 선언되고 빌드 시점에 모든 토큰의 값으로 해석되는 **축(axis)**이다:

json
"color.surface": { "$value": { "$axis": "theme", "light": "#ffffff", "dark": "#0b0b0c" } }

이는 반드시 전체적이어야 한다: 선언된 모든 축 멤버(light, dark, …)는 리터럴 값이 필요하며, 폴백은 없다(TAB-E113) — 다크 모드에서 조용히 라이트 모드 값을 유지하는 토큰이야말로 전체성 규칙 (totality rule)이 제거하고자 하는 전형적인 보이지 않는 버그다. 빌드는 커스텀 프로퍼티마다 하나의 @property를 생성하고(inherits: false이므로 값이 조상에 의해 재정의될 수 없다), 루트 스코프 축 블록을 더한다(:root[data-theme="dark"] { --tb-color-surface: #0b0b0c; }, 첫 페인트를 위한 prefers-color-scheme 미디어 폴백 포함). bg-surface 같은 클래스는 이미 모든 테마를 동시에 담고 있다 — dark:bg-surface-dark라고 쓰는 것은 축 모델이 제거하고자 하는 바로 그 셀렉터-조건부 분기를 다시 들여오는 것이며, 이것이 dark:(그리고 어떤 [data-theme=…]: variant든)가 금지된 메커니즘인 이유이자, 프로젝트 CSS에서 @theme inline이 금지된 이유다(값을 사용 지점에 인라인하므로 루트 축 블록이 더 이상 그것을 재지정(re-point)할 수 없다).

변형(Variants) ​

변형 체인(hover:bg-accent-hover, sm:p-md, sm:hover:bg-surface-raised)은 기본 유틸리티 앞에 하나 이상의 변형을 접두시킨다. 프리셋이 @import "tailwindcss" source(none)과 명시적인 @source inline 목록으로 컴파일되기 때문에, 체인은 등록되어 있을 때만 CSS를 낸다 — 기본 클래스와 정확히 똑같다. 폐쇄성(T3)은 바뀌지 않는다; 등록된 집합이 그저 선언된 변형 프로덕트를 포함하도록 넓어질 뿐이며, 이는 arbitrary value가 오직 등록된 예외를 통해서만 합법이 되는 것과 같은 방식이다. CSS는 스캔된 소스가 아니라 여전히 프로파일(레지스트리)의 순수 함수로 남는다 — 그래서 새로운 체인 사용은 조용히 빠진 규칙이 아니라 시끄러운 tabula scan 오류다.

선언된 프로덕트 ​

프로덕트는 체인 접두사와 그것이 접두할 수 있는 패밀리의 짝으로, tabula.config.json에 선언된다:

jsonc
"variants": {
  "products": {
    "hover": ["bg", "border", "ink", "(static)"],  // family names as in the registry
    "sm":    "*",                                   // "*" = every non-atomic family
    "sm:hover": ["bg"]                              // a length-2 product, in canonical order
  },
  "preset": "interaction"                           // "interaction" | "all-len1" | "none"
}

빌드는 선언된 모든 프로덕트를 정확한 체인-클래스 목록으로 구체화하여 레지스트리(variantProducts — 간결한 prefix → families 맵 — 와 chainCount), source.css에 이어 붙는 체인 레이어 (effectiveRank 순으로 정렬된, 레지스트리-템플릿 규칙의 리터럴 @layer utilities 블록 하나 — 체인은 의도적으로 @source inline에 나열되지 않으며, 이는 계속 기본 클래스 전용으로 남는다), types.d.ts, 그리고 llms-full.txt에 기록한다. 프로덕트 전체를 둘 만한 가치가 없는 일회성 체인은 기존 예외 메커니즘을 탄다: tabula except add --chain hover:bg-accent-hover …는 값 예외와 같은 reason/owner/expiry 서류로 그 단일 체인을 등록한다.

프리셋 ​

variants 섹션이 없으면 interaction 프리셋이 기본값이다 — 프로젝트가 상호작용 상태를 별도 설정 없이 바로 스타일링할 수 있도록 선택된 것이다:

  • 모든 자기-상태 변형 {hover, focus, focus-visible, focus-within, active, disabled} × 패밀리 {bg, border, ink, outline, ring, shadow, decoration, accent, caret, fill, stroke, opacity, (static)};
  • 그리고 {placeholder} × {ink, caret, accent}.

first/last/odd/even과 미디어 변형은 프리셋에 포함되지 않는다 — 명시적으로 선언하라. all-len1은 활성화된 모든 이름 있는 변형 × 모든 비원자 패밀리다(크며, 예산 검사를 받는다 — 아래 참고); none은 아무것도 선언하지 않으며 오직 명시적인 products만 적용된다.

정규 순서 ​

체인은 정확히 하나의 표기로만 등록된다: 엄격하게 오름차순인 rank 순서의 변형들, 최대 하나의 미디어 변형, 그다음 유틸리티(sm:hover:bg-surface이며, 결코 hover:sm:bg-surface가 아니다). cn()은 이미 정규 문자열을 낸다; 런타임 파서는 순서에 관대하며 멤버십을 검사하기 전에 정규화하지만, 소스는 정규 표기를 지켜야 diff가 최소로 유지되고 "나중 것이 이긴다"가 왼쪽에서 오른쪽으로 읽힌다.

실패 모드 ​

tabula scan은 런타임과 같은 문법으로 소스의 모든 클래스 토큰을 파싱하며, 등록된 집합 밖의 체인을 사례별 fix-it과 함께 **TAB-E230**으로 거부한다:

소스 안에서Fix-it
정규 순서가 아님(hover:sm:x)정규 재작성(sm:hover:x)을 알려준다
선언되지 않은 프로덕트(기본 프리셋 아래의 hover:p-md)tabula.config의 variants.products에 hover × p 프로덕트를 선언하고 다시 빌드하라
파라메트릭 변형(aria-*, data-*, group-*, peer-*)파라메트릭 변형은 v0.1에서 지원되지 않는다
원자적 클래스 위의 변형(hover:not-prose)원자적 클래스는 변형을 받지 않는다

설정과 빌드를 지키는 코드가 두 개 더 있다: breakpoint 축이 없는 프로파일에 선언된 미디어 프로덕트는 설정 오류(TAB-E170)다; 잘못된 프로덕트 선언 — 파라메트릭/알 수 없는 변형이나 패밀리, 정규가 아니거나 너무 긴 접두사 — 은 **TAB-E171**이다.

예산 ​

완전한 폐쇄성은 규모가 커지지 않는다: 체인 길이 ≤ 2에서도 순진한 곱은 메가바이트 단위의 CSS다. 그래서 방출되는 집합은 선언된 프로덕트뿐이며, 그 전개 자체는 variants.maxChainCandidates(기본값 20,000)로 상한이 걸려 있다. 프로덕트가 그 상한을 넘어 전개되는 프로파일은 개수, 가장 큰 다섯 개의 프로덕트, 그리고 그 손잡이(knob)를 명시하며 **TAB-E172**로 빌드에 실패한다 — 대략 그 크기를 넘으면 아티팩트는 더 이상 배포 가능하지 않다.

런타임과 남은 틈 ​

런타임에서 cn()은 선언되지 않은 체인을 다른 어떤 알려지지 않은 토큰과 똑같이 취급한다: 개발 환경에서는 (레벤슈타인 "혹시 이거였나요"와 함께) 던지고, 프로덕션에서는 불투명하게 통과시킨다 — 아무 CSS도 렌더링하지 않는 체인이 더 이상 cn()을 눈에 띄지 않게 빠져나갈 수 없다. 멤버십은 정규 형태로 검사되므로, 작성된 순서는 런타임에서 결코 중요하지 않다.

구성상 한 통로는 열려 있다. tabula scan은 소스 글롭을 읽는다; 스캔된 파일에 결코 나타나지 않는 클래스 문자열 — 글롭 밖의 생성된 파일에서 조립되거나 런타임에 만들어지는 경우 — 은 그것에게 보이지 않으며, cn()에 도달해 거기서 개발 환경에 한해 던진다. 상류의 방벽은 ESLint의 no-runtime-class-construction(리터럴 클래스 문자열만 작성하고, 결코 `hover:${x}` 같은 건 쓰지 않는다)이며, 스캔 글롭이 두 번째 방벽이다. 어느 쪽도 폐쇄성 자체는 아니다; 이들은 그저 문자열이 그것을 강제하는 게이트를 피해 가지 못하게 지킬 뿐이다. 이는 기본 어휘가 가진 것과 같은 남은 틈이다 — 변형이 그것을 넓히지는 않는다.

금지된 메커니즘 ​

아래 각각은 클래스를 입은 엘리먼트를 넘어서까지 닿는 셀렉터로 컴파일된다. 빌드는 유틸리티 레이어 안의 이런 규칙이 하나라도 있으면 실패한다(위의 locality: "L0" 검사); 이 표가 존재하는 이유는 에이전트가 그것이 왜 거부되는지 이해하도록 하기 위해서다 — 단지 거부된다는 사실만이 아니라, 이유를 아는 것이 그 메커니즘이 다른 위장을 쓰고 재발명되는 것을 막는다는 목표다.

메커니즘금지 이유대신 쓸 것
space-x-* / space-y-*& > * + *를 방출한다 — 부모가 손을 뻗어 자식을 스타일링하는 것flex/grid 부모에 gap-*
divide-x-* / divide-y-*space-*와 같은 형태, 테두리를 위한 것각 자식에 테두리 유틸리티
*: (자식 variant)부모로부터 모든 직계 자식을 스타일링한다각 자식에 유틸리티를 놓는다
**: (자손 variant)무한정 자손까지 닿는다필요한 각 엘리먼트에 유틸리티를 놓는다
[&>*]: / [&~*]: / [&+*]:결합자(combinator)를 담은 arbitrary variant — 같은 도달 범위를 다르게 표현한 것대상 엘리먼트를 직접 스타일링한다
in-*조상의 상태와 매치한다; 엘리먼트가 관계를 선언한 적 없는 부모에 의존하게 된다이름 있는 group/card + group-hover/card:
rtl: / ltr:방향은 variant가 아니라 토큰 축이다논리적 유틸리티: ps-*/pe-*, ms-*/me-*, start-*/end-*
pl-* pr-* ml-* mr-* left-* right-* border-l-* border-r-* text-left text-right물리적 inline-axis 유틸리티는 등록되지 않는다 — 아예 CSS를 내지 않는다ps-* pe-* ms-* me-* start-* end-* border-s-* border-e-* align-start align-end
dark: (또는 어떤 theme/[data-theme=…] variant든)테마는 빌드 시점에 해석되는 토큰 축이다; 유틸리티가 이미 모든 테마의 값을 담고 있다그냥 토큰 유틸리티(bg-surface); :root에서 축 속성을 설정해 전환한다
프로젝트 CSS의 @theme inline토큰 값을 사용 지점에 인라인하여, 루트 축 블록이 더 이상 그것을 재지정할 수 없게 만든다토큰 파일에 토큰을 선언하라; 빌드가 @theme를 내보내게 하라
임의 값(Arbitrary values) (w-[347px])이름도, 소유자도, 만료일도 없다 — 게다가 source(none)이므로 어차피 CSS를 내지도 않는다tabula except add
이름 없는 group / peer / @container"이건 어느 조상의 것인가?"는 트리 전체를 읽지 않고는 답할 수 없다이름을 붙여라: group/card + group-hover/card:
런타임에 만들어진 클래스 이름Tailwind는 소스 텍스트를 스캔한다; 런타임에 조립된 이름은 결코 스캔되지 않는다*.classmap.ts 파일 안의 리터럴 조회 맵

이 중 두 가지는 빌드 상태와 무관하게 레지스트리 독립적인 최저선으로 강제된다 (packages/eslint-plugin/src/banlist.ts: space-*, divide-*, *:, **:, arbitrary combinators, in-*, rtl:/ltr:) 그래서 생성된 레지스트리가 없어도 발동한다; 나머지는 어휘가 단순히 그것들을 등록하지 않는다는 폐쇄성의 귀결이다.

위의 모든 항목은 소스 파일에서 ESLint(.ts/.tsx)에 의해, tabula scan(여기에 .js, .mdx, .html, .vue, .svelte, .astro 등이 추가된다)에 의해, 또는 단순히 어휘가 그것을 내지 않는 것에 의해 검사된다 — 그리고 CSS 거버넌스 레이어 이후로는 스타일시트에서도 마찬가지다. 아래를 참고하라.

프로젝트 CSS도 통제된다 ​

위의 표는 프로파일이 금지하는 것을 말한다. 이 절은 .css 파일에서 실제로 검사되는 것을 말하는데, 왜냐하면 거버넌스 레이어가 존재하기 전까지 그 답은 아무것도 없음이었기 때문이다: 이 시스템의 어떤 명령도 여러분이 작성한 스타일시트를 열어보지 않았으므로, 위의 모든 메커니즘 금지는 .tsx에만 적용되고 그 외에는 아무 데도 적용되지 않았다. 임포트된 스타일시트 안의 .card .title { color: red } 하나가 모든 게이트가 초록불인 채로 지역성을 깨뜨릴 수 있었다.

이제 다섯 가지 규칙이 모든 프로젝트 .css 파일과 생성된 스타일시트 위에서 실행된다 — 에디터에서는 @tabula-css/stylelint-plugin을 통해, CI에서는 tabula check:css(--no-css를 넘기지 않는 한 tabula build --check가 실행한다)를 통해. 둘 다 같은 규칙 커널을 사용하므로 서로 어긋날 수 없다.

코드금지 대상규칙
TAB-E221@apply클래스 목록을 손으로 작성한 셀렉터로 조합한다 — 프로파일이 제거하는 바로 그 간접성
TAB-E222승인된 엔트리 스타일시트 밖에서 손으로 작성된 규칙클래스가 아니라 셀렉터로 스타일링하는 것
TAB-E223:root/html 이외에 정의된 --tb-* / --d-*T17, 시스템 전체에서 가장 중요한 검사
TAB-E224@theme inlineT20 — 모든 조건부 토큰을 컴파일해서 없애 버린다; shadcn 생태계의 함정
TAB-E225!importantdeclaration을 rank 모델 밖으로 내보낸다

그래서 예전에는 모두 통과했지만, 이제 이 셋은 모두 실패한다:

css
@theme inline { --tb-color-accent: red; }   /* TAB-E224 */
.card p { color: red; }                     /* TAB-E222 */
.panel { --tb-color-accent: red; }          /* TAB-E223 */

이 영역의 SPEC 규칙 하나는 아직 구현되지 않았다: T18, 루트가 아닌 엘리먼트 위의 축 캐리어 속성(data-theme)이다. CSS에서 .panel[data-theme="dark"] 셀렉터는 축 위반이 아니라 손으로 작성된 규칙으로 걸린다 — 잘못된 이름이지만 올바른 결과이며 — 방출되는 셀렉터는 :root[data-theme=…]이므로 그런 속성은 애초에 아무 효과가 없다.

tabula/no-theme-variant가 JS/TSX의 dark:를 커버한다(T19).

전체 detail, 즉 게이트가 여전히 커버하지 않는 것과 두 게이트가 어긋날 수 있는 지점까지 포함해서: **CSS 거버넌스**를 참고하라.

Released under the MIT License.