Skip to content

Eject — the one-way freeze

EXPERIMENTAL

tabula eject (both the one-way freeze and the --report reverse-map analysis) ships experimental in v0.3.0: the command works and is tested, but its surface — flags, report format, and tier table — may change in a future minor release. Feedback is welcome via GitHub issues.

tabula eject copies a verified .tabula/ into a project-owned directory and stops the token pipeline flowing to it. Research (and the probes recorded in .orchestrator/plan.md) established that .tabula/source.css compiles on stock @tailwindcss/cli with zero class changes — so ejecting is not a rewrite, it is a freeze: you keep the exact CSS you shipped, you keep cn(), and you give up the generator.

It exists for the day you want off the profile without a migration: a project going into maintenance, a hand-off to a team that will not adopt Tabula, or an archive that must build years from now with nothing but Tailwind on the path.

This is a one-way door. After eject there are no rebuilds, no tabula build --check drift gate, and no tabula scan closure gate over the frozen copy. Token changes in tokens/ no longer reach it. To go back you delete the frozen directory and rebuild from source — nothing flows the other way. Eject is dry-run by default precisely so that crossing the door is a deliberate second step (--yes), never an accident.

The command

tabula eject [--to <dir>] [--yes] [--force] [--write-imports]
  • --to <dir> — the freeze target. Defaults to tabula-frozen/ under the project root.
  • --yes — execute. Without it, eject is a dry run: it prints the full plan — every file it would copy, the permission changes, the @import rewrites it found, the expiry warnings, the one-way-door banner, and the cn() policy note — and touches nothing.
  • --force — allow a target directory that already contains files (otherwise a non-empty target is refused).
  • --write-imports — rewrite project CSS @import lines that point at .tabula/source.css so they point at the frozen copy. Without it, eject only prints the files it found and the exact new import line, so you can apply the change yourself.

Preconditions (fail closed, in order)

  1. A loadable projecttabula.config.json + tokens/*.tokens.json at the root (TAB-E901 otherwise). Run eject from the project root.
  2. An existing, verified .tabula/ — the manifest must exist, the inputs must still hash to it (not stale), every artifact's sha256 must match the manifest, and nothing may hide in the directory the manifest does not cover. This is the same manifest-hash drift gate tabula doctor uses, and it reuses the same codes: TAB-E303 (missing/stale) and TAB-E601 (hand-edited or uncovered). Eject freezes only a verified state — a drifted or stale .tabula/ is refused, because freezing it would freeze something that no longer matches your tokens.
  3. A usable target — the target directory must not exist, or must be empty, unless --force is given (TAB-E240). A target it cannot write is also TAB-E240.

The four hazards, and how eject handles them

Ejecting is easy to get subtly wrong. Four traps were pinned up front, and each is handled explicitly rather than left to chance.

1. cn() still needs @tabula-css/merge — do not swap in tailwind-merge

The frozen CSS is stock Tailwind, but the runtime is not. cn() resolves class conflicts from your registry.json; tailwind-merge does not know your vocabulary, so replacing cn() changes rendered output. The counterexample, from concepts.md:

cn("pt-sm", "p-md")   // Tabula → "p-md"        (p-md is later and covers padding-top)
cn("pt-sm", "p-md")   // tailwind-merge → "pt-sm p-md"  (keeps pt-sm — different CSS)

So the ejected directory keeps registry.json, and EJECTED.md tells you in writing to keep @tabula-css/merge. Nothing suggests replacing the merge runtime silently.

2. The ejected copies are yours (0644), not read-only artifacts (0444)

Artifacts under .tabula/ are written read-only (0444) and gated by the manifest so an editor cannot silently save over a generated file. The ejected copies are the opposite: they are your files now. Eject writes every copy at 0644 in a project-owned directory, so you can edit them without fighting the permission bit or a drift check that no longer runs.

3. Expired exceptions warn, they do not block

Normally an expired exception is a hard build error (TAB-E141) — the mechanism that stops a closed vocabulary from quietly becoming open. But after eject there are no rebuilds, so that error can never fire again. Blocking the freeze on it would be blocking on a future eject has already cancelled. Instead, eject warns per exception with TAB-W402 for anything expired or expiring within 90 days, and EJECTED.md lists every exception with its status, so freezing that debt is a choice you see rather than a surprise you inherit.

4. The one-way door is unmissable

Every run — dry run and execute — prints a banner stating that token changes stop flowing and there are no rebuilds. The dry-run default means you always see the full plan before anything is written.

What keeps working, and what stops

Keeps working:

  • cn() at runtime — with @tabula-css/merge and the copied registry.json (hazard 1).
  • types.d.ts — the class-name types are frozen alongside the CSS; your editor autocomplete and type-checking of class strings are unchanged.
  • llms.txt / llms-full.txt / AGENTS.md.snippet — the agent surface is copied too, so an assistant reading the frozen directory still gets the vocabulary and the rules.
  • Compiling on stock Tailwindsource.css builds with @tailwindcss/cli directly.

Stops:

  • Rebuildstabula build no longer targets the frozen directory; token edits do not reach it.
  • Token changes flowing — the pipeline is severed; the freeze is a point-in-time snapshot.
  • The scan and drift gatestabula scan closure and build --check drift no longer govern the frozen copy. It is ordinary project CSS now.

After ejecting

EJECTED.md, written into the frozen directory, records the provenance (profile id, version, inputs hash), the one-way-door statement, the cn() policy note, the full exception list, and the verification command. To confirm the freeze compiles on stock Tailwind:

npx @tailwindcss/cli -i tabula-frozen/source.css -o out.css

If you used --write-imports, your app stylesheet's @import already points at tabula-frozen/source.css; otherwise eject printed the exact line to paste.

Reverse-map report (--report)

Eject freezes Tabula's CSS and keeps it rendering exactly. A different question is: how much of my class usage could move to vanilla Tailwind v4 instead? tabula eject --report answers it honestly. It is an analysis mode — no freeze, no writes to .tabula/, always exit 0 — that classifies the classes actually used in your scanned source against a frozen reverse-map table into four tiers:

tiermeaning--write?
ASame spelling exists in vanilla and the emitted declaration is equivalent — swapping toolchains does not change rendering.not needed
BA provably-1:1, rendering-preserving rename (e.g. opacity-disabledopacity-50).yes
CA mapping exists but rendering or semantics change — report-only, with the exact caveat.never
DNo vanilla equivalent (the type-* typographic bundle) — keep the frozen CSS or redesign by hand.never

A variant chain classifies by its weakest part: every self-state variant Tabula lowers with :where() (hover, focus, active, …) is tier C — its specificity is (0,1,0) where vanilla's is (0,2,0), and hover additionally loses vanilla's @media (hover:hover) gate — so every real chain lands at C regardless of how portable its base is.

tabula eject --report [--theme-port] [--format=json] [--write]
  • --report — write tabula-eject-report.md beside the project (and echo it to stdout): the header verdict, per-tier counts, a per-file work list (file:line, class, tier, target-or-caveat), the tier-D list, and the top caveats. --format=json emits the same data as a JSON document.
  • --theme-port — report the with-theme-port scenario and add the namespace-port instructions. See the two scenarios below.
  • --write — apply only the tier-B renames, through the same codemod engine as tabula migrate (a real unified diff, class-sinks only). Everything else is report-only by construction. --write with nothing applicable prints 0 rewrites and exits 0.

The two scenarios

Tabula's theme.css defines only --tb-* custom properties, and its source.css runs on vanilla's default theme. So a named token utility like bg-accent or p-lg is a valid vanilla utility shape, but vanilla reads --color-accent / --spacing-lg, which Tabula never populates:

  • as-is — you eject, keep the spellings, and port no theme variables. Token utilities emit nothing (colour/spacing/size) or resolve to vanilla's default value (rounded-md becomes 0.375rem, not Tabula's 0.5rem). These families are tier C as-is.
  • with-theme-port (--theme-port) — you copy Tabula's token values into the vanilla namespaces (--tb-color-*--color-*, --tb-spacing-*--spacing-*, --tb-radius-*--radius-*). Those same families become tier A.

A port does not rescue every family. The physical-vs-logical spacing families (px, mx, inset-x, scroll-px, border-x — logical inline in Tabula, physical left/right in vanilla, so identical in LTR but mirrored in RTL) and the ring-* / shadow-* colour utilities (an inert --tb-ring-color in Tabula becomes an active --tw-ring-color in vanilla — an element that renders no ring can suddenly render one) stay tier C in both scenarios.

The honest numbers (reference-ui)

Measured on the examples/reference-ui project (478 base classes + 1131 variant chains = 1609 tokens, jq-verified against the registry — .orchestrator/R2.4-reverse-map.md):

scenarioABCD
as-is831115114
with-theme-port4051111894

Only 11 tokens (0.7%) — the scalar-token renames opacity-disabled, duration-fast, ease-standard, border-{t,b,y,s,e}-thin, align-start, align-end, not-prose — are safe for --write, and that set is identical in both scenarios (a theme port does not grow it). Every variant chain is C in both scenarios. The conclusion the command embodies is deliberate: a full vanilla migration is not the product — the honest report is, and tabula eject (freezing the CSS) remains the way to keep rendering exact.

Ejecting vs. restoring

Ejecting freezes the output; restoring a profile from .tabula/ reconstructs the source. They are opposite directions: eject when you want to stop using the generator and keep the CSS; restore when you want the tokens back to keep generating.

Released under the MIT License.