---
title: "Frontend Tooling"
section: "Frontend"
framework: WebFluid
package: webfluid
version: "1.0.0b3"
stage: "beta"
released: "August 17, 2026"
canonical: "https://docs.webfluid.dev/latest/surface/tooling.md"
html: "https://docs.webfluid.dev/latest/surface/tooling"
audience: ai-agent
format: markdown
---

# Frontend Tooling


The *surface* is the framework's frontend layer: a bundled Node runtime and Tailwind CLI, the shared
template context, the base layout, the injected script sources and the theme system. It is gated by
the `WF_*` feature switches, and `WF_PROCESSING` in particular is what makes templates work at all.


## The feature switches

```ini
[features]
WF_PROCESSING = 1
WF_TAILWIND = 1
WF_THEMES = 1
WF_CHECK_FRONTEND = 0
WF_BUILD_FRONTEND = 1
WF_ADDITIVES = 1
```

### `WF_PROCESSING`

The one you almost always want. `setup_processing(fluid)` installs:

| Component                   | Effect                                                                                                                                                                   |
|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Context processor           | Adds `LANG`, `YEAR`, `id`, `theme`, `src`, `url_for` to **every** render                                                                                                 |
| `before_request` logger     | `[Request] GET /path from 1.2.3.4 (user-agent)`                                                                                                                          |
| `after_request` error pages | Renders `errors/<status>.html` for 400, 401, 403, 404, 405, 429, 500, 502, 503 — **only** when the client's `Accept` contains `text/html`                                |
| Exception handler           | Unhandled exceptions → `errors/debug/500.html` in debug, `errors/500.html` otherwise; JSON clients get the status text only (message, type and traceback are debug-only) |
| `GET /wf-identity`          | `{"id", "version", "timestamp"}`                                                                                                                                         |
| `POST /url-for`             | `{endpoint, path_params, external}` → `{"url": ...}`; unknown endpoint → `404 UNKNOWN_ENDPOINT`                                                                          |

> **RULE** — Without WF_PROCESSING there is no url_for, no theme, no src and no _() fallback in your templates, so fluid_base.html renders a broken document and every error is a bare JSON body. Turn it on for anything that serves HTML.

### The shared context variables

These come from the processing context processor, which is why you never pass them:

| Variable        | Type      | Value                                                                                              |
|-----------------|-----------|------------------------------------------------------------------------------------------------------|
| `url_for`       | callable  | `url_for(name, **path_params)` → path; `external=True` → absolute URL. Works off-request too       |
| `src`           | callable  | `src()` → the injected `<script>`/`<link>` sources as `Markup`, timestamped in debug               |
| `theme`         | `Markup`  | The active theme's stylesheet link (`Markup("")` without `WF_THEMES`)                              |
| `LANG`          | string    | The active locale (from Babel, or `BABEL_DEFAULT_LOCALE`)                                          |
| `YEAR`          | int       | Current UTC year                                                                                   |
| `id`            | string    | `"fluid"` — the framework id                                                                       |
| `_`, `ngettext` | callables | Babel's, or a no-op fallback when `EXT_BABEL` is off                                               |

`url_for` prefers the request in the current context and falls back to `fluid.url_path_for` when
there is none, so a template rendered from a startup hook, a scheduled job or a mail routine resolves
its links. Off-request, `external=True` prefixes `BASE_URL`.

> **RULE** — BASE_URL defaults to http://localhost:8000. Set it before anything renders an external URL — a mail link, a canonical tag — because nothing warns you that the default is wrong.

> **KNOWN BUG** — The no-op ngettext fallback installed when EXT_BABEL is off interpolates with `n`, while the real implementation interpolates with `num`. So ngettext('%(num)d item', '%(num)d items', n) renders with the battery on and raises KeyError: 'num' with it off. Write the count into the string if a template must work in both configurations.

### `WF_TAILWIND`

On startup, `generate_tailwind_css(fluid)` compiles every `tailwind_raw.css` it finds in the app's
(and the framework's) `static/css` directories into a minified `tailwind.css` next to it. It also
publishes the `wf_tailwind` Jinja global — a ready `<link>` tag that `fluid_base.html` includes.

```css
/* fluid/static/css/tailwind_raw.css */
@import "tailwindcss" source("../../");

@theme {
    /* your design tokens */
}
```

The `source(...)` argument tells Tailwind where to scan for class names. `../../` from
`fluid/static/css` is `fluid/` — templates included, which is what you want.

> **RULE** — Edit tailwind_raw.css, never tailwind.css. The compiled file is overwritten on every boot and is gitignored by the generated .gitignore.

### `WF_THEMES`

Enables the theme API and decides **which raw stylesheet name is compiled**:

| `WF_THEMES` | Raw file the surface looks for |
|-------------|--------------------------------|
| on          | `tailwind_raw.css`             |
| off         | `tailwind_no_themes.css`       |

```python
fluid.add_theme(name, link)         # link is HTML: a <link rel="stylesheet"> tag
fluid.get_theme()                   # the active theme's markup
fluid.set_theme(request, name)      # store the choice in the session
```

Resolution order: `request.session["theme"]` → `GLOBAL_THEME` → the framework theme.

`add_theme` raises if the name exists; `set_theme` raises if it does not — so a typo is an error,
not a page that silently keeps the old style. Both raise `FrameworkException` when `WF_THEMES` is
off.

The shipped theme derives its whole surface from five CSS custom properties on `:root`:
`--wf-blend` (structural hue), `--wf-deep`, `--wf-veil`, `--wf-sunk` and `--wf-page` (the page
gradient). Page and error backgrounds, nav, footer, dropdowns, cards and traceback frames all read
from those with a neutral fallback — so a theme that only redefines the colour scale already renders
coherently.

On the client, the injected `base.js` provides `window.wf.switchTheme()` for the light/dark
preference. That is independent of the server-side theme registry.

### `WF_CHECK_FRONTEND` / `WF_BUILD_FRONTEND`

Production only — in debug the Vite dev server takes over and neither is read.

| Switch              | Runs                         | On failure                                                     |
|---------------------|------------------------------|----------------------------------------------------------------|
| `WF_CHECK_FRONTEND` | `npm run check --workspaces` | Aborts the boot (unless the message is "No workspaces found!") |
| `WF_BUILD_FRONTEND` | `npm run build --workspaces` | Raises `FrontendException` with the compiler output            |

> **RULE** — Turn both off in a container image that already built its assets at image-build time. There is no reason to compile the same bundle on every restart, and an image without a Node toolchain cannot anyway. The dist folders are mounted either way.

## `fluid_base.html`

The framework's base layout. It expects the processing context and provides these blocks:

| Block     | Purpose                                                     |
|-----------|-------------------------------------------------------------|
| `title`   | Document title. Default `WebFluid App`                      |
| `head`    | Extra `<head>` content — this is where `frontend()` goes    |
| `nav`     | The whole `<nav>` body. Override to replace the demo navbar |
| `content` | The page. Almost always the one you fill                    |
| `footer`  | The whole footer body                                       |
| `scripts` | The trailing script block                                   |


```html
{% extends "fluid_base.html" %}

{% block title %}{{ _('HOME_TITLE') }}{% endblock %}

{% block head %}
    {{ frontend() if frontend else "" }}
{% endblock %}

{% block content %}
    <section>
        <h1>{{ _('GREETING', name=name) }}</h1>
    </section>
{% endblock %}
```


The head it renders for you: charset, viewport, favicon, `<title>`, the theme link, the Tailwind
link, and `src()`.

> **RULE** — Override the nav and footer blocks in your own layout rather than editing fluid_base.html conceptually — it is inside the installed package. If you want to replace it wholesale, create fluid/templates/fluid_base.html; your app's templates are searched first. See surface/jinja.md.

## Page sources

`fluid.add_source(html, priority=1)` queues a `<script>` or `<link>` for injection into every
rendered head through `src()`.

```python
app.add_source('<link rel="preconnect" href="https://fonts.googleapis.com">', 10)
app.add_source('<script src="/static/js/analytics.js" type="module"></script>', 5)
```

- **Priority 1–10**, higher first. The framework's own scripts (`base.js`, `i18n.js`, `events.js`)
  use priority 5.
- Sources are **deduplicated** by exact string; a repeat logs a warning and is skipped.
- The HTML is parsed and rejected with `ValueError("Invalid HTML source.")` if it is not a node.
- The list is **frozen** in the `_prepare` startup hook — add sources during app assembly or from
  an Additive's `before_enable`, never later.
- In debug mode every source gets a `?t=<timestamp>` on its `src`/`href`.
- `fluid.rendered_sources` is a `Markup` (`Markup("")` before the freeze), and the debug source
  callable joins its fragments with `Markup("\n\t")` — a plain `str` separator would return a plain
  `str` and the tags would be escaped on the page.

## Static files

| Mount                   | Serves                                        | Route name                                |
|-------------------------|-----------------------------------------------|-------------------------------------------|
| `/static`               | `fluid/static` (only if the directory exists) | `static`                                  |
| `/fluid/static`         | the framework's own static                    | `fluid_static` (Jinja global `wf_static`) |
| `/<additive_id>/static` | that Additive's `static/`                     | `<id>_static`                             |
| `/<prefix>/frontend`    | a Vite `dist`                                 | `<name>_frontend`                         |


```html
<script src="{{ url_for('static', path='js/models.js') }}"></script>
<img src="{{ url_for(wf_static, path='img/logo.png') }}" alt="Logo">
```


Everything under a static mount is served with `Cache-Control: public, max-age=STATIC_MAX_AGE`
(a year in production, 0 in debug).

> **RULE** — Keep page behaviour in fluid/static/js, one small file per page, and load it with url_for. Inline <script> blocks in templates cannot be cached, cannot be linted and cannot be reused — and the browser only downloads the behaviour a page actually uses when it is a separate file.

## The bundled toolchain

Node and the Tailwind CLI are **not** shipped inside the package — they are downloaded on first use
and cached under `webfluid/surface/dist`. A system Node is used when present. Both are reachable
through the CLI:

```bash
wf node node --version
wf node npm --version
wf node npm install
wf tailwind -- --help
```

Output is forwarded verbatim. `node_cmd` raises `NodeError` on a non-zero exit.

## Next

- [`surface/frontend.md`](/latest/surface/frontend.md) — `APP_FRONTEND`, htmx and Vite.
- [`surface/jinja.md`](/latest/surface/jinja.md) — which file a template name resolves to.
- [`utils/runtime.md`](/latest/utils/runtime.md) — themes at request time.


---

## Navigation


- [Overview](/latest/.md) — what WebFluid is, how to navigate these docs, release state and known bugs
- [Getting Started](/latest/get-started.md) — install, minimal app, the layout the framework expects

**Configuration**
- [App configs](/latest/config/app-config.md) — `app_configs/<app>.ini`, switches, `*_FILE` secrets, `[dev]`
- [Config classes](/latest/config/config-class.md) — `register_config`, `DefaultConfig`, every framework key

**Extensions**
- [Introduction](/latest/ext/base.md) — `FluidExtension`, entry points, `expand_fluid`
- [Scheduling](/latest/ext/scheduling.md) — APScheduler `AsyncIOScheduler`
- [SQLAlchemy](/latest/ext/sqlalchemy.md) — `Model`, executors, binds, sessions
- [Migrate](/latest/ext/migrate.md) — Alembic wrapper, `prepare_fluid`, single/multi-db
- [Mail](/latest/ext/mailman.md) — SMTP, sync/async, batching, attachments
- [Babel](/latest/ext/babel.md) — gettext, domains, locale selection, formatters
- [Security](/latest/ext/security.md) — users, gates, CSRF, tokens, OAuth, bearer grants
- [Events](/latest/ext/events.md) — signals, events, queries, browser socket
- [Cache](/latest/ext/cache.md) — redis / legacy backends
- [JWTManager](/latest/ext/jwt.md) — encode/decode, rotation, revocation

**Frontend (surface)**
- [Introduction](/latest/surface/tooling.md) — feature switches, `fluid_base.html`, Tailwind, themes
- [Integration](/latest/surface/frontend.md) — `APP_FRONTEND`, htmx, Vite workspaces
- [Template resolution](/latest/surface/jinja.md) — loader order, namespaces, overriding

**Additives**
- [Introduction](/latest/additives/intro.md) — manifest, routers, enabling
- [Base Additives](/latest/additives/base.md) — `type: base`, `import_base`, extension semantics
- [Interaction between](/latest/additives/contract.md) — events/queries as contracts, requirements, packaging

**Framework utility**
- [Lifecycle](/latest/utils/lifecycle.md) — `mix()`, startup/shutdown hooks, graceful stop
- [Runtime](/latest/utils/runtime.md) — `FluidContext`, request hooks, themes, proxy, rate limits
- [Logging](/latest/utils/logging.md) — the log factory, levels, files, additive attribution

**CLI**
- [Introduction](/latest/cli/wf.md) — command tree, extension CLIs
- [Create](/latest/cli/create.md) — `wf create project/app/additive`
- [Run](/latest/cli/run.md) — `wf run`, flags, debug and interactive mode
- [Ocean](/latest/cli/ocean.md) — search, install, publish

**Reference**
- [Overview](/latest/ref.md) — package layout, stability contract
- [Core](/latest/ref/core.md) — `Fluid`, config, context, `core.ext`, constants
- [Extensions](/latest/ref/extensions.md) — the battery API surface
- [Surface](/latest/ref/surface.md) — `Frontend`, Node and Tailwind tooling
- [Additives](/latest/ref/additives.md) — `Additive`, `Router`, `Manifest`, registry helpers
- [Utils](/latest/ref/utils.md) — helpers, logging, exceptions