Tailwind & styles

Themes use Tailwind v4 standalone (no PostCSS, no Node toolchain). The bin/tailwindcss binary lives in cms-public/bin/ and is the same binary used at theme deploy time on the cms-public-worker pod.

styles.src.css — the Tailwind source

This is the only CSS file a theme directly authors. Compiled output (styles.css) is produced at deploy time and lives next to the source in the deployed version dir on NFS.

A typical opening:

@import "tailwindcss";

/* Tell Tailwind which template files to scan for class names. Adjust
   the depth so it matches this theme's directory layout. The path is
   resolved at compile time, relative to this file. */
@source "../templates/**/*.twig";

/* Custom theme tokens — these become CSS variables AND Tailwind
   utilities (bg-cream, text-gold-deep, etc.). */
@theme {
    --color-bg:        #f8f4ee;
    --color-cream:     #f1ead9;
    --color-paper:     #ffffff;
    --color-ink:       #2a2117;
    --color-ink-soft:  #5e5440;
    --color-ink-deep:  #1a1611;
    --color-gold:      #d2b07a;
    --color-gold-deep: #b89968;
    --color-divider:   #e1d6c2;

    --font-display: "Playfair Display", serif;
    --font-script:  "Allura", cursive;
    --font-sans:    "Inter", system-ui, sans-serif;
}

/* Theme-private utilities and base styles. Anything that's not
   reachable through Tailwind classes lives here. */
@layer base {
    body { color: var(--color-ink); background: var(--color-bg); }
}

Arbitrary CSS is fine after the @import "tailwindcss" line: Tailwind v4 just preserves it. Keep the theme's own base styles and helpers inside @layer base / @layer utilities so their order against Tailwind's utilities is predictable. Overrides of the CSS cms-public ships are the exception: those go outside any @layer, see Overriding cms-public's CSS.

How @source works

@source "<glob>" tells the Tailwind v4 engine where to look for class names. The path is relative to styles.src.css, resolved at compile time. For a theme repo laid out like this:

my-theme/
├── styles.src.css
└── templates/
    ├── base.html.twig
    └── pages/
        └── home.html.twig

…use @source "../templates/**/*.twig"; (the source file is at the repo root, templates one level down). Get this path wrong and Tailwind ships no utility classes — pages render unstyled.

What the deploy compile does

When the worker pod runs ThemeCompiler::compileVersion():

  1. Locates the deployed version dir on NFS: /data/cms/themes/<themeCode>/<version>/.
  2. Runs bin/tailwindcss -i styles.src.css -o styles.css --minify.
  3. Writes styles.css next to the source — that's what theme_asset('styles.css') resolves to.

There's a 60s timebox on the compile to catch runaway @source globs. If the binary returns non-zero, the deploy is recorded as failed in the theme_deployment_status table and the active version doesn't change.

Local Tailwind workflow

Two ways:

  • Watch mode against your theme repo while editing templates:

    cd ~/path-to-my-theme
    ~/path-to-cms-public/bin/tailwindcss -i styles.src.css -o styles.css --watch
    
  • Render against the local pod's NFS mount — same binary, same inputs, same output as production.

    kubectl --context local exec -n cms cms-public-worker-0 -- \
        php bin/console app:theme:compile <themeCode> <version>
    

    (Command name + flags may differ — check php bin/console list app inside the pod.)

Shared block defaults

cms-public/public/assets/blocks.css ships with the site (loaded by every page from _shared/base.html.twig). It defines the four image-field knobs ([data-width], [data-align], [data-radius], [data-fit]) so every theme inherits sensible defaults without re-implementing them. Themes can override any of these in their own CSS, outside any @layer. Load order: blocks.css, then the theme's styles.css, then the per-page module CSS below.

Per-page module CSS

The cms_module() system links its CSS with <link rel="stylesheet"> tags that _shared/base.html.twig puts at the end of <head>, after the theme's own stylesheet; see per-page-modules.md. Module CSS uses --cms-<name>-* custom properties as tuning knobs, and the stylesheets that have them declare the defaults on the module's own root element:

/* slideshow.css */
.cms-slideshow {
    --cms-slideshow-cta-bg: #111;
    --cms-slideshow-heading-weight: 500;
    /* … */
}

That decides where a theme can set them. A value on an ancestor (a wrapping <section style="…">) never reaches the module, because the root element's own declaration beats an inherited value. A theme rule on .cms-slideshow alone loses too: same specificity, and the module's sheet loads later. Set them on the root element with a more specific selector, outside any @layer:

/* end of styles.src.css, outside any @layer */
body .cms-slideshow {
    --cms-slideshow-cta-bg: var(--color-gold-deep);
    --cms-slideshow-heading-weight: 700;
}

For different tuning per page, key the selector on a class you put on a wrapper:

<section class="hero-dark">
    {{ field('hero', 'slideshow') }}
</section>
.hero-dark .cms-slideshow { --cms-slideshow-overlay-bg: rgba(0, 0, 0, 0.5); }

Overriding cms-public's CSS

blocks.css and every per-page module stylesheet (slideshow.css, reviews.css, faq.css, …) are unlayered: none of them uses @layer. In the cascade an unlayered rule beats a rule in any layer, whatever the specificity and whatever the load order. So this never applies:

@layer components {
    .page .cms-reviews .cms-reviews__text { font-size: 1rem; }   /* loses to reviews.css */
}

The same goes for a Tailwind utility on module markup. Tailwind v4 emits its utilities inside @layer utilities, so where the module's CSS sets the same property, the module wins.

Write overrides of module classes, and of their custom properties, outside any @layer, at the end of styles.src.css. Out there they compete on ordinary specificity and source order:

Stylesheet Loads A theme rule wins with
blocks.css before the theme's stylesheet equal or higher specificity
per-page module CSS after the theme's stylesheet, end of <head> higher specificity
/* end of styles.src.css, outside any @layer */
.cms-reviews .cms-reviews__text { font-size: 1rem; }   /* beats reviews.css's .cms-reviews__text */

What you don't ship

  • node_modules/ — there's no Node toolchain.
  • package.json — same.
  • A bundler config (Vite, esbuild, webpack) — same.
  • Pre-compiled styles.css in your repo — the deploy compiler produces it. Don't commit the output.
  • Source maps — Tailwind v4 standalone doesn't emit them.