# 109. Tailwind and themes

**For app developers.** How a stylesheet that imports Tailwind gets its utilities from the classes your pages render, what the build says about a class it cannot see, how a theme is chosen for the whole document per request and how a card image draws the same classes.

## Tailwind is an import

A stylesheet under `styles/` that imports Tailwind is the opt-in:

```css
/* app/styles/app.css */
@import "tailwindcss";

@theme {
  --color-accent: #1f6feb;
  --color-line: #dde3ea;
}

@utility pill {
  @apply inline-flex items-center rounded-full border border-line px-2.5 py-0.5 text-[13px];
}
```

`fsr new shop --with tailwind` starts an application that way. On one that already has stylesheets, `fsr use shop/app tailwind` adds `styles/tailwind.css` with Tailwind's theme and utilities and leaves its preflight reset out, so the existing pages keep their look while classes are added to them.

Everything Tailwind CSS v4 reads in a stylesheet works: `@theme`, `@utility`, `@variant`, `@custom-variant`, `@apply`, `@source inline(...)`, `theme()` and `--spacing()`. There is no `tailwind.config.js` and no Node: the engine is `snapfire_tailwind`, a Rust port of Tailwind's own compiler. A JavaScript plugin or config is refused by name. So is a stylesheet that points Tailwind at files to scan, `@source "../node_modules/some-ui"` or `source("../src")` on the import, since the build reads classes from what it compiles and never scans; the error says to list such classes with `@source inline(...)`.

## Where the classes come from

Tailwind's CLI scans your files for anything that looks like a class. fsr already knows where every class is, so it lists them instead. `fsr build` reads the lowered plan: every `class` an element renders, both branches of a conditional, the fixed parts of a template, the values of a literal lookup object and the `className` a component is placed with. It writes them to `generated/tailwind.classes`, one per line with the modules that use it. The bundle builds the stylesheet for exactly that set:

```text
tailwind  51 classes in generated/tailwind.classes
```

A component that renders in the browser alone has no lowered tree, so the bundle reads its source instead: `className` and `class` attributes, the arguments of `clsx`, `cn` and Vue's `normalizeClass` and the constants they name, a lookup table among them. In `fsr dev` a save that changes the set rebuilds the stylesheet with it.

## Classes from vendored packages

A component library under `vendor/` names classes too. The bundle reads them from the packages your sources import. A package built with `snapfirec --emit-tailwind-classes` carries its class list as `tailwind.classes` beside its modules, read from its sources before anything was minified:

```text
app/vendor/@acme/ui/
  index.min.js
  index.d.ts
  tailwind.classes
```

A package without one, the way a third-party bundle arrives, is read from its modules, its imports followed. A helper it imports is followed under whatever name the minifier gave it. One the bundler inlined and renamed is not, so a class composed through it is not seen; list it with `@source inline(...)`. A site with no vendor tree of its own reads its shell's.

A package that carries `index.d.ts` beside its entry needs nothing from `fsr types`: the build takes its declarations from the vendor tree.

In the portal, `packages/acme_ui` and `packages/acme_charts` are built into `app/vendor/@acme/`. `/themes` shows their components styled by classes nothing else in the portal names.

## A class the build cannot list

A class built from data at runtime is a class the build never sees:

```tsx
<span className={`rounded-full px-2 status-${system.state}`}>{system.state}</span>
```

The build says so and names the module. The fix is to write the values where the build can read them. A lookup object is read whole:

```tsx
const tone: Record<string, string> = {
  operational: "bg-emerald-100 text-emerald-800",
  degraded: "bg-amber-100 text-amber-800",
  down: "bg-rose-100 text-rose-800",
};

<span className={`rounded-full px-2 ${tone[system.state]}`}>{system.state}</span>
```

A value that is not a class at all is better as an attribute and a variant:

```tsx
<tr data-status={invoice.status} className="group">
  <td className="group-data-[status=overdue]:text-rose-600">{invoice.status}</td>
</tr>
```

When neither fits, list the values in the stylesheet:

```css
@source inline("bg-{red,amber,emerald}-100");
```

A class that is not a utility and that no stylesheet defines is a warning too, so a typo shows up in the build rather than as a missing style:

```text
⚠️  `flx` in routes/page.tsx#default is not a Tailwind utility and no stylesheet defines it
```

## Tailwind in a site

A site's stylesheet is linked after the shell's in the same document, so a site brings its utilities and leaves the base styles to the shell:

```css
/* sites/blog/app/styles/blog.css */
@import "tailwindcss/theme" layer(theme.root);
@import "tailwindcss/utilities" layer(utilities);

@theme reference {
  --color-card: #ffffff;
  --color-accent: #1f6feb;
}
```

`@theme reference` names the shell's tokens without writing them: `bg-card` compiles to `background-color: var(--color-card, #fff)`, so the shell's value and the document's theme decide what the site's card looks like.

## A theme is the whole document

A theme is a file of `@theme` blocks under `styles/themes/`, named by the file, that overrides tokens the root theme defines:

```css
/* app/styles/themes/ocean.css */
@theme {
  --color-accent: #0e7490;
  --color-canvas: #f0f9fb;
}
```

Tailwind compiles the root theme as it always does. fsr writes the named themes beside it as plain CSS, the way Tailwind's documentation switches a theme at runtime: `ocean.css` reaches the browser as `[data-sf-theme="ocean"] { --color-accent: #0e7490; --color-canvas: #f0f9fb; }`, so switching is one attribute on `<html>`. Each one sits in the `theme.named` cascade layer, so the root theme has to sit in `theme.root`, which comes before it. An application with themes imports Tailwind's theme on its own rather than through `@import "tailwindcss"`, which puts it in `theme`:

```css
/* app/styles/app.css */
@layer theme, base, components, utilities;

@import "tailwindcss/theme" layer(theme.root);
@import "tailwindcss/preflight" layer(base);
@import "tailwindcss/utilities" layer(utilities);
```

Themes are not Tailwind's. An application without it declares the root's tokens in `:root` inside `@layer theme.root { ... }`, reads them with `var()` in its own rules and writes a theme file as a `:root` rule or bare custom properties; everything below works the same. A build with themes and the root theme outside `theme.root` fails and names the line to write. So does a theme that sets a token the root does not define (for a site, neither the site's root nor its shell's): add the token to the root's `@theme` with the value it has outside the theme.

The root theme is named `default` once there is a theme file. The build writes the root's value of every token a theme sets under `[data-sf-theme="default"]`, so `theme.ts` can choose it over a route's own; a `styles/themes/default.css` fails the build. The generated types declare `Theme` as the union of the names, a site's own and its shell's.

A layout or a page picks one with a literal export, plain or forced:

```tsx
export const theme = "editorial";
export const theme = { name: "ledger", force: true };
```

Down a route's layouts the deepest one that sets a theme wins, unless a level above it forces its own; of two forced levels the outer one wins. In the portal the blog sets `editorial` and billing forces `ledger`. A name with no file behind it fails the build.

## Choosing per request

`app/theme.ts` decides for each request, after middleware and before the page. It is lowered like a loader, so it reads the session, the query and the identity. `ctx.theme` is the route's own choice:

```ts
import type { Scheme, Theme, ThemeChoice, ThemeCtx } from "@snapfire/fsr";

export function theme({ theme, query, session }: ThemeCtx): ThemeChoice {
  const scheme = session.scheme === "light" || session.scheme === "dark" ? (session.scheme as Scheme) : null;
  if (theme.forced) return { name: theme.name, scheme };
  const picked = query.theme ?? session.theme;
  return { name: picked === "" ? theme.name : (picked as Theme), scheme };
}
```

`force` is a flag the body respects rather than a rule the host enforces, so a site that must keep its brand says so and the body keeps it. A mounted site's own `theme.ts` runs after the shell's and sees the shell's answer as `ctx.theme`. The answer is written on the document as `<html data-sf-theme="ocean" data-sf-scheme="dark">`; a client navigation sets both from the payload's head row, so moving from the portal into billing switches the theme without a reload. A prerendered page is written without one; the host writes the request's theme into its `<html>` before sending it, the same step as for a page rendered live.

A route whose forced theme was kept also carries `data-sf-theme-forced` on `<html>`, so a shell's own theme picker can say so with a variant alone:

```tsx
<span className="hidden [[data-sf-theme-forced]_&]:inline">This site keeps its own theme</span>
```

Light and dark are the scheme, not a theme, so a brand theme and the visitor's scheme combine. A `null` scheme writes no attribute and leaves it to the browser's `prefers-color-scheme`. Key the variant on the attribute and on the preference when it is absent:

```css
@custom-variant dark {
  &:where([data-sf-scheme="dark"], [data-sf-scheme="dark"] *) {
    @slot;
  }
  @media (prefers-color-scheme: dark) {
    &:where(:root:not([data-sf-scheme]), :root:not([data-sf-scheme]) *) {
      @slot;
    }
  }
}
```

The dark values go in the stylesheet's own `@layer theme`. A rule in `theme` itself outranks everything in its sublayers, so the scheme wins over a theme's neutrals whichever stylesheet the document links last:

```css
@layer theme {
  [data-sf-scheme="dark"] {
    --color-canvas: #0d1117;
    --color-ink: #e6edf3;
  }

  @media (prefers-color-scheme: dark) {
    :root:not([data-sf-scheme]) {
      --color-canvas: #0d1117;
      --color-ink: #e6edf3;
    }
  }
}
```

## Previewing a theme inside a page

The theme and scheme selectors match any element, so a part of a page can be drawn in another theme with real utilities:

```tsx
<div data-sf-theme="default">
  <figure data-sf-theme="ocean" data-sf-scheme="dark" className="bg-canvas p-4 font-sans text-ink">
    <a href="/">A link</a> beside <code>code</code>
  </figure>
</div>
```

The `default` wrapper resets the root's values beneath whatever theme the document has. Two things do not carry into a preview by themselves. A value set on `<html>` or `body`, such as the font, is resolved there, so the preview sets `font-sans` and `bg-canvas` again. A token declared as another's `var()`, such as `--color-accent: var(--color-brand)`, resolves where it is declared, so declare it again wherever a theme applies:

```css
@layer theme {
  [data-sf-theme] {
    --color-accent: var(--color-brand);
  }
}
```

A `dark:` variant keys on an ancestor's attribute, so inside a light preview on a dark page it still applies; draw previews from tokens rather than `dark:` classes.

A theme never reaches a body, so it changes no cache key.

## Card images draw the same classes

A card image is drawn by a renderer that matches no selectors, so the build turns each class into plain declarations in `generated/tailwind.cards.json`, with colors in sRGB and variables resolved. The host writes them into each element's style:

```tsx
export default function Image({ post }: PostSlugProps) {
  return (
    <div className="flex h-full w-full flex-col justify-between bg-amber-50 p-[72px] text-gray-800">
      <h1 className="m-0 text-[72px]/[1.1] font-bold">{post.title}</h1>
    </div>
  );
}
```

An inline `style` still wins over a class, as on a page. A breakpoint variant applies when the card is at least that wide; `hover:`, `dark:` and the other variants mean nothing on a still image and are skipped.

## Lab

In the portal example, open `/themes`: each cell is drawn in its own theme and scheme and "Use this" saves both. Open `/?theme=forest`: the whole document turns green and serif for that one request. Sign in, pick Ocean and Dark on `/account` and follow the header into the blog, billing and status. The blog takes your pick over its own `editorial`, billing keeps `ledger` and every site follows the dark scheme. Then change the status page's lookup to `` `status-${system.state}` `` and run `fsr build sites/status/app`: the report names the module and the value it cannot list.
