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 --checkdrift gate, and notabula scanclosure gate over the frozen copy. Token changes intokens/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 totabula-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@importrewrites it found, the expiry warnings, the one-way-door banner, and thecn()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@importlines that point at.tabula/source.cssso 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)
- A loadable project —
tabula.config.json+tokens/*.tokens.jsonat the root (TAB-E901otherwise). Run eject from the project root. - An existing, verified
.tabula/— the manifest must exist, the inputs must still hash to it (not stale), every artifact'ssha256must match the manifest, and nothing may hide in the directory the manifest does not cover. This is the same manifest-hash drift gatetabula doctoruses, and it reuses the same codes:TAB-E303(missing/stale) andTAB-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. - A usable target — the target directory must not exist, or must be empty, unless
--forceis given (TAB-E240). A target it cannot write is alsoTAB-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/mergeand the copiedregistry.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 Tailwind —
source.cssbuilds with@tailwindcss/clidirectly.
Stops:
- Rebuilds —
tabula buildno 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 gates —
tabula scanclosure andbuild --checkdrift 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.cssIf 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:
| tier | meaning | --write? |
|---|---|---|
| A | Same spelling exists in vanilla and the emitted declaration is equivalent — swapping toolchains does not change rendering. | not needed |
| B | A provably-1:1, rendering-preserving rename (e.g. opacity-disabled → opacity-50). | yes |
| C | A mapping exists but rendering or semantics change — report-only, with the exact caveat. | never |
| D | No 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— writetabula-eject-report.mdbeside 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=jsonemits 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 astabula migrate(a real unified diff, class-sinks only). Everything else is report-only by construction.--writewith nothing applicable prints0 rewritesand 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-mdbecomes0.375rem, not Tabula's0.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):
| scenario | A | B | C | D |
|---|---|---|---|---|
| as-is | 83 | 11 | 1511 | 4 |
| with-theme-port | 405 | 11 | 1189 | 4 |
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.