WebProCMS combines two CSS strategies so editors get instant feedback on every class change while visitors get a minimal CSS payload that contains only the classes the live site actually uses. The page editor preview iframe uses the Tailwind v4 browser CDN (JIT, scans the live DOM), while the public site is served by a compiled bundle that gets rebuilt automatically every time a classes field is saved.
The problem
A CMS that lets editors type Tailwind classes into fields runs into a fundamental tension: Tailwind only generates utility CSS for classes its scanner can find at build time. If the editor types a class that has never appeared on the site before, the public CSS bundle won't contain it — the class lands in the saved blade file but renders unstyled in the browser until someone runs a build.
Two naive fixes both fail:
- Ship the entire Tailwind palette unconditionally. Defeats the point of utility CSS — visitors download a multi-megabyte stylesheet of classes the site never uses.
- Ship the Tailwind browser CDN to visitors. The CDN works, but it adds a runtime JS dependency, scans the DOM on every page load, and balloons the page weight even more than option 1.
The editor needs JIT-on-demand behaviour during editing (so any class typed appears instantly). Visitors need a tree-shaken compiled bundle (so the wire payload stays small). These are two different targets — and WebProCMS uses two different toolchains for each.
The fix
Two surfaces, two pipelines:
- Editor preview loads the Tailwind v4 browser CDN inside the preview iframe. The CDN scans the iframe DOM and generates utilities on demand. Type any class into a classes field — even one the site has never used — and the preview reflects it instantly with no build step.
- Live site is served by a compiled bundle plus a per-install supplement. When a classes field is saved,
BladeClassSyncerwrites the new value back into the blade file's default attribute, andRebuildAssetsregenerates the supplement in pure PHP — no Node/npm. The rebuild fires after the response is sent (~1s) in every environment, with no queue worker and no Node required.
The two pipelines never cross. Visitors never download the CDN; editors never wait for a build to see a class they just typed.
Editor preview — Tailwind Play CDN (JIT)
The preview iframe injects the CDN via partials/tailwind-cdn-preview.blade.php. The partial reads resources/css/public.css from disk, strips directives the browser CDN can't honour (@import, @plugin, file-path @source), and re-injects the rest as a <style type="text/tailwindcss"> block. Inline @source inline("...") lists are kept; @theme, @layer, @utility, and @custom-variant blocks are preserved.
That means every theme token from the site's compiled bundle — the primary/secondary/tone palettes, section spacing tokens, container widths, custom utilities — is available in the preview. Then <script src="/vendor/tailwindcss-browser-4.js"></script> loads, scans the iframe's live DOM, and generates the matching utilities at runtime.
The CDN is injected only on the design library preview route. Every other route — including the public site — always uses the compiled bundle.
The branding :root style block is rendered in a regular <style> tag (not type="text/tailwindcss") immediately after the CDN block. The CDN compiles @theme declarations into @layer theme; an unlayered :root {} rule unconditionally beats layered rules regardless of source order, so the runtime brand colors win over the static defaults.
Live site — compiled CSS rebuilt on save
When an editor saves a classes-type field, two things happen sequentially.
1. Sync the class to the blade file. BladeClassSyncer::sync() takes the row slug, field key, and new value, finds the corresponding <x-dl.*> tag in the blade file, and updates its field-classes (or field-wrapper-classes, field-image-classes, etc.) attribute in place. The syncer is quote-aware — findTagEnd() walks the tag character-by-character honouring single/double quotes, so a > inside a data-x="…" value won't terminate the match early. Only the blade file changes; the saved override stays in the database.
This is the load-bearing step: Tailwind's scanner reads default attributes literally, so writing the class back into the source file is what makes it discoverable at build time.
2. Regenerate the CSS supplement — pure PHP, no Node. RebuildAssets::handle() refreshes the cascade class collector (CascadeClassSyncer), then RuntimeCssSupplement emits the runtime utility supplement (public/build/runtime-utilities.css) that extends the committed Vite base bundle, and finally clears the response cache so visitors pick up the change. No npm, no Vite, no shell. Failures are logged and never block the save.
The rebuild fires via defer() — every save path runs defer(fn () => (new RebuildAssets)->handle()), so the save response returns immediately and the supplement regenerates after the response is flushed, in a few ms. No queue worker, no Node, no npm.
This is identical in production: the same deferred pure-PHP rebuild runs on the server, so the live site picks up new classes immediately with no Node, npm, or queue worker installed. That is what lets the CMS run on hosts with no Node (WordPress-style shared hosting). The full Node-free CSS architecture — the committed base bundle, the per-theme files, and the runtime arbitrary-value resolver — is documented in asset-builds.md.
Tree-shaking and bundle separation
At release time (on a build machine with Node), npm run build:public runs against vite.public.config.js, which builds only the three public-facing entries (resources/css/public.css, resources/js/public.js, resources/js/spam-shield.js). The plugin merges the new entries back into manifest.json without touching the dashboard / editor entries — so a class change on a public row never invalidates app.css or its hash, and a dashboard CSS edit never invalidates public.css. This is the committed base bundle; the on-save supplement above extends whatever classes it already contains, so the server itself never runs this build.
There are two parallel CSS bundles:
| Bundle | Source | Purpose |
|---|---|---|
app.css |
resources/css/app.css |
Dashboard / admin UI / page editor. Includes Flux. |
public.css |
resources/css/public.css |
Visitor-facing public site. No Flux, no design library. |
Tailwind v4 auto-detects every sibling directory of the source CSS file. To keep dashboard-only paths out of public.css and public-only paths out of app.css, both files declare an EXCLUSIONS block of @source not '...' directives. Any new top-level folder, layout, or runtime-written path needs an entry in the opposite bundle's exclusions to stay isolated.
Tree-shaking happens at the Tailwind layer: the scanner walks every source file, records every literal class string it finds, and emits CSS only for those classes. A site that uses 200 unique utilities ships 200 utilities — not the 50,000+ in Tailwind's full palette. The compiled public.css for a typical site is tens of kilobytes, not megabytes.
Why two bundles, not one
A single bundle would have to contain every Flux dashboard utility plus every public-site utility — pushing dashboard-only weight onto every visitor. Two bundles means visitors only download what's needed to render the rows they're seeing; dashboard weight stays inside the dashboard.
The vite.public.config.js plugin protects this at release time: a build:public run deletes the previous public.css hashed file but never touches dashboard entries in manifest.json, while npm run build (the full build) regenerates everything. The on-save path doesn't run Vite at all — it appends to the pure-PHP supplement — so neither bundle's hash churns during editing.
Avoiding HMR loops on runtime-written files
The Vite dev server watches resources/css/** by default. Files that runtime processes write to (e.g. resources/css/buttons.css from ButtonStyleSyncer, resources/css/presets.css from SectionPresetSyncer) would otherwise trigger a full page reload every time an editor saves — wiping toast notifications, dashboard quick-edit drawer state, etc. vite.config.js watch.ignored lists every runtime-written CSS file and config path explicitly. CSS rebuilds for those files always go through RebuildAssets, never through Vite HMR.
The same rule applies to runtime-written blade files — routes/web.php, top-level ⚡*.blade.php page files, shared row partials, header/footer partials are all in watch.ignored for the same reason.
What lives where
| Path | Purpose |
|---|---|
resources/views/partials/tailwind-cdn-preview.blade.php |
Editor preview iframe — strips CDN-incompatible directives, injects <style type="text/tailwindcss"> and <script src="/vendor/tailwindcss-browser-4.js">. |
app/Support/BladeClassSyncer.php |
On save, writes the new class string back into the corresponding <x-dl.*> tag's default attribute in the blade file. Quote-aware tag scanner handles > inside attribute values. |
app/Jobs/RebuildAssets.php |
Pure-PHP rebuild chokepoint (no Node): refreshes CascadeClassSyncer, writes the RuntimeCssSupplement, clears the response cache. |
app/Support/Css/RuntimeCssSupplement.php |
Generates public/build/runtime-utilities.css — the editor-added utility classes that extend the committed base bundle, emitted in PHP. |
app/Concerns/EditorDiscardActions.php |
A dispatch site — defer(fn () => (new RebuildAssets)->handle()) so the rebuild runs after the save response is flushed (one of several save paths that do this). |
vite.public.config.js |
Release-time build config for npm run build:public — only the three public entries; merge-manifest plugin preserves dashboard hashes. Not run on the server. |
vite.config.js |
Full build config + dev server watch.ignored list (runtime-written CSS, blade page files, routes, shared rows, header/footer partials). |
resources/css/app.css |
Dashboard bundle source. Sources Flux + dashboard views; excludes public-only paths. |
resources/css/public.css |
Public bundle source. Sources page views + public components; excludes dashboard-only paths. |
Settings reference
There are no env settings for the on-save rebuild — it always runs the pure-PHP supplement after each save, in every environment. The former REBUILD_ASSETS_LOCALLY and NPM_PATH vars were removed with the Node-free migration (the server no longer runs npm). See asset-builds.md for the full Node-free CSS model.