Skip to content

feat: Tailwind v4 plugin, and the shadcn remap as a stylesheet - #177

Closed
abernier wants to merge 3 commits into
mainfrom
feat/tailwind-plugin-shadcn
Closed

feat: Tailwind v4 plugin, and the shadcn remap as a stylesheet#177
abernier wants to merge 3 commits into
mainfrom
feat/tailwind-plugin-shadcn

Conversation

@abernier

Copy link
Copy Markdown
Owner

Supersedes #167 (Tailwind half only, closed) — this one also covers shadcn.

Tailwind

@import "tailwindcss";
@plugin "material-theme-builder/tailwind" {
  custom-colors: myCustomColor1, myCustomColor2;
}

instead of the ~180-line @theme inline { --color-*: var(--md-sys-color-*) } block.

Custom colors are the part a stylesheet could never cover — their names live in the app, not in the package — so they were exactly the manual step the README kept warning about ("Do not forget to manually add your custom colors"). Each one registers its four M3 roles and its shades, under both spellings: bg-myCustomColor1 (what the README documented) and bg-my-custom-color-1 (what toTailwind() emits).

Options: prefix (default md), custom-colors, shades (default true — off leaves Tailwind's own neutral-* alone). Read case- and dash-insensitively, because Prettier lowercases CSS declaration names and would otherwise turn a user's customColors: into customcolors: on save. An unknown option throws rather than silently doing nothing.

shadcn

@import "material-theme-builder/shadcn.css";

instead of the 31-line :root, .dark { … } block. The file already existed in src/, it just wasn't exported.

It stays a stylesheet rather than an option on the plugin, on purpose: a plugin can only reach @layer base, and shadcn's own :root/.dark are unlayered — layered styles lose to unlayered ones whatever their specificity, so a shadcn: true option would have been a silent no-op on a default shadcn globals.css. The "import it after" note stays, for that same cascade reason.

Same reason behind a caveat now documented: for the color names shadcn and M3 share (primary, secondary, background), shadcn's @theme inline beats the plugin's theme whatever the order — Tailwind gives a CSS @theme precedence over a plugin's. Those utilities keep shadcn's semantics, which shadcn.css has already pointed at M3.

Also

  • token names move to src/lib/tokens.ts, so the plugin — which runs inside the CSS build — reads them without pulling Material Color Utilities in (dist/tailwind.js is 7 KB, no runtime dep). Re-exported from builder.ts; public surface unchanged.
  • tailwind.css keeps working and gains the two roles it was missing: --color-surface-tint, --color-surface-variant — found by a new test holding the shipped stylesheets against what the plugin registers.
  • another test holds the plugin against toTailwind(), name for name, so the two spellings of the theme can't drift.
  • tailwindcss added as an optional peer dep.

Verified

pnpm run lgtm green (72 tests). Beyond unit tests, the built dist/ was run through a real @tailwindcss/postcss build: utilities, tonal shades, opacity modifiers (bg-primary/50), custom colors in both spellings, and a shadcn-shaped globals.css where --background ends up resolving to --md-sys-color-surface.

🤖 Generated with Claude Code

…heet

`material-theme-builder/tailwind` registers the whole M3 color system as
Tailwind colors, in place of the `@theme inline` block the README asked
everyone to paste:

    @import "tailwindcss";
    @plugin "material-theme-builder/tailwind" {
      custom-colors: myCustomColor1, myCustomColor2;
    }

Custom colors are the part a stylesheet could never cover -- their names live
in the app, not in the package -- so they were the manual step the README kept
warning about. The plugin takes them as an option and registers their four M3
roles and their shades.

The shadcn mapping moves the same way: `material-theme-builder/shadcn.css` is
one import instead of a 31-line block to copy. It stays a stylesheet rather
than an option on the plugin because it has to win over shadcn's own unlayered
`:root`/`.dark`, and a plugin can only reach `@layer base`.

Token names move to `src/lib/tokens.ts` so the plugin -- which runs inside the
CSS build -- reads the names without pulling Material Color Utilities in.

`tailwind.css` keeps working, tested against the plugin so the two cannot
drift, and gains the two roles it was missing: `--color-surface-tint` and
`--color-surface-variant`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@abernier

Copy link
Copy Markdown
Owner Author

Second commit, after review: the one-liner was hiding what the wall of declarations made obvious. The listings are back in the README — folded, next to the two rules that generate them:

  • scheme role → --color-<role> → utility, kebab-cased
  • the shade → tone table (50→95 … 950→5), which was only ever implicit before

Same for shadcn: the 31-line block is in the README again, plus a paragraph on how to read it (containers back shadcn's tinted fills, outline-variant its borders).

Three tests hold the README against the plugin — a listing that drifts fails the build instead of quietly misleading its reader. Checked by deleting a line from each: both fail as expected.

@abernier
abernier force-pushed the feat/tailwind-plugin-shadcn branch from 7b03d9b to aaabb50 Compare August 17, 2026 06:08
@abernier

Copy link
Copy Markdown
Owner Author

Correction on the previous comment: the README tests are gone, and so is the generator I floated. The doc commit is now README-only — the listings are back, kept by hand.

What still guards the mappings are the two tests on the files that actually ship: src/tailwind.css against the plugin name for name (that is what found the missing --color-surface-tint), and src/shadcn.css against SHADCN_MAPPING line for line. The README points at both files rather than claiming to be checked itself.

@abernier
abernier force-pushed the feat/tailwind-plugin-shadcn branch 2 times, most recently from 2097e64 to 7a40445 Compare August 17, 2026 06:21
The one-line `@plugin` hid what used to be readable at a glance: which M3 role
backs which utility, and which tone backs which shade. So the listings come
back -- folded, next to the two rules that generate them, and with the shade →
tone table that was only ever implicit in the wall of declarations.

Same for shadcn: the 31-line block is in the README again, with a paragraph on
how to read it (containers back shadcn's tinted fills, `outline-variant` its
borders).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@abernier
abernier force-pushed the feat/tailwind-plugin-shadcn branch from 7a40445 to 3d82a89 Compare August 17, 2026 06:32
`:root, .dark` would only have won by coming later in the sheet, and an
`@import` cannot promise that: Vite hoists it to the top of the file it appears
in, which would put the remap *before* the block it is meant to override --
silently, since a no-op mapping looks exactly like a working one until you
compare colors.

`:root:root, .dark.dark` selects the same elements one specificity step up.
Unlayered on both sides, so specificity decides, and it travels with the rule
wherever a bundler moves it. Verified in a browser: a `@layer base` rule loses
to an unlayered `:root` even with four times the specificity, which is also why
this cannot become an option on the plugin.

`toTailwind({ shadcn: true })` keeps the plain selectors -- that block gets
pasted, so its position, and what it outranks, is the reader's call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@abernier
abernier force-pushed the feat/tailwind-plugin-shadcn branch from 6e6fa8b to ed0958a Compare August 17, 2026 07:01
@abernier

Copy link
Copy Markdown
Owner Author

Reverted the breaking part: tailwind.css stays exported, changeset back to minor. The PR is now strictly additive — nothing is removed, no semantics change for anyone already using the pasted block, no migration.

Also corrected a claim I made earlier in this PR and had wrong: @import "material-theme-builder/tailwind.css" after a @theme inline of your own does not win under Vite — the import gets hoisted above it. Verified with a real vite build. The reliable way to take a shared name back is a @theme written (not imported) in globals.css, which is what the README now says. Only secondary needs it.

toTailwind({ shadcn: true }) output is byte-identical to main again; src/styles/globals.css too, so no Chromatic movement.

@abernier abernier closed this Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant