개념
지역성과 열 개의 법칙
Tabula의 핵심 주장은 이렇다: 엘리먼트의 스타일은 그 자신의 클래스 문자열이다 — 조상이나 형제, 스타일시트 순서로부터 상속되거나 캐스케이드되는 것은 없다. 단, 엘리먼트 자신의 클래스 문자열이 비지역성(non-locality)을 선언하는 경우(group/name, type-inherit, prose)는 예외다. 자식이 어떻게 렌더링되는지 알기 위해 부모를 읽어야 한다는 것은 스타일 선택의 문제가 아니라 버그 클래스로 취급된다.
이 주장은 열 개의 규칙으로 부호화되어, 생성되는 모든 .tabula/llms.txt에 그대로(그리고 강제되어) 출력된다:
vocabulary.txt에 있는 클래스만 존재한다; 그 외의 것은 조용히 아무 CSS도 내지 않는다. 런타임에 만들어진 클래스(`p-${n}`)는 결코 동작하지 않는다 — Tailwind는 소스 텍스트를 스캔한다.- 클래스는 기억이 아니라 의도로 역방향 조회한다(MCP
find_class_for). - arbitrary value(
w-[347px])는 절대 쓰지 않는다; 대신tabula except add가 이름 있는 클래스를 만든다. dark:는 절대 쓰지 않는다 — 테마는 토큰 축이며,bg-surface가 이미 모든 테마를 커버한다.- 더 높은
rank가 이긴다. 그것이 캐스케이드의 전부다 — 속성 순서는 아무것도 바꾸지 않는다. - 두 클래스가 하나의 정적 문자열 안에서 한 프로퍼티를 쓰는 것은 린트 에러이지, 병합이 아니다.
cn(base, …, className)으로 조합하라: 호출자의className이 마지막에 오며 이긴다.- 텍스트를 담는 모든 엘리먼트는 하나의
type-*와 하나의ink-*를 갖거나,type-inherit을 명시한다. - 어떤 부모도 자식을 스타일링하지 않는다:
space-*,divide-*,*:,**:,[&>*]:,in-*는 존재하지 않는다.gap-*를 쓰거나, 유틸리티를 자식에 놓아라. - 엘리먼트 간 의존성은 반드시 이름 있는 것이어야 한다(
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):
"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)를 실행한다:
- 모든 클래스 문자열을 공백으로 분리한다; 각 토큰을
(variants, utility)로 파싱한다. - 각 클래스의 각 declaration에 대해 슬롯 키를 계산한다:
(pseudoElement, condition, slot). - 가장 높은 rank의 declaration이 각 키를 이긴다. 순회 순서는 무관하다.
cn()은 추가로 fragment를 먼저 기준으로 정렬한다: 나중 fragment가 이전 fragment를 이긴다, 그리고 오직 하나의 fragment 안에서만 rank가 결정한다. 이것이cn(base, className)을 건전한 오버라이드 메커니즘으로 만드는 이유다 — 호출자의 fragment는 자신의 내부 rank와 무관하게 이긴다.- 생존자들은 출력을 위해 rank 오름차순으로 정렬되므로, 출력된 클래스 문자열은 왼쪽에서 오른쪽으로 "나중 것이 이긴다"(읽기 규칙, Reading Rule)로 읽히며, 원자적(atomic)이거나 알 수 없는 클래스는 작성자 순서 그대로 뒤에 덧붙는다.
풀어본 예제(packages/merge/test와 examples/reference-ui/.tabula/llms-full.txt에 대해 검증됨):
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)**이다:
"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에 선언된다:
"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 inline | T20 — 모든 조건부 토큰을 컴파일해서 없애 버린다; shadcn 생태계의 함정 |
TAB-E225 | !important | declaration을 rank 모델 밖으로 내보낸다 |
그래서 예전에는 모두 통과했지만, 이제 이 셋은 모두 실패한다:
@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 거버넌스**를 참고하라.