# JWeb 3.0.3 — complete documentation

JWeb is a Java web framework for building full-stack web applications entirely
in Java: no HTML templates, no JSP, no frontend toolchain. Pages are Java code
that returns typed element trees, rendered server-side by Spring Boot.

This document is the whole documentation set for version 3.0.3, concatenated for
use as a source. If you are writing JWeb code, read the rules and the breaking
changes below before anything else — they are where generated code usually goes
wrong.

## Install

Maven — the JitPack repository plus the dependency:

```xml
<repositories>
  <repository><id>jitpack.io</id><url>https://jitpack.io</url></repository>
</repositories>

<dependency>
  <groupId>com.github.oscar-osmig</groupId>
  <artifactId>Jweb</artifactId>
  <version>v3.0.3</version>
</dependency>
```

Gradle:

```groovy
repositories { maven { url 'https://jitpack.io' } }
dependencies { implementation 'com.github.oscar-osmig:Jweb:v3.0.3' }
```

Requires Java 21+. Spring Boot's web starter arrives transitively. Annotate the
application class with `@JWebApplication` (not `@SpringBootApplication`).

## The seven rules the DSL follows

Everything in the HTML, CSS, JavaScript and Three DSLs is a consequence of one
of these:

1. **A String argument is text, wherever it appears.** `a("Home")` renders
   `<a>Home</a>`; `a(href("/"), "Home")` renders `<a href="/">Home</a>`. Only
   void elements take positional Strings: `img(src, alt)`, `meta(name, content)`.
2. **Every element is `name(Object...)`.** Attributes, event handlers, a bare
   `style()` builder and children mix in any order, in one call:
   `button(id("save"), onClick(e -> save()), style().padding(px(8)), "Save")`.
   `attrs()` is only for the long tail.
3. **Every CSS property takes a plain String as well as a typed value.**
   `cursor("copy")` and `cursor(pointer)` both work.
4. **A Java keyword gets a trailing underscore, and nothing else does.**
   `if_`, `return_`, `class_`, `for_` — but `then`, `elif`, `let`, `await`.
5. **A block takes its body inline.** `if_(cond, a).elif(c2, b).else_(c)`; no
   `end()`, `endFor()`, `endWhile()`, `endTry()`, `endSwitch()`.
6. **Platform names win.** `writeText`, `getRandomValues`, `getItem`,
   `fillStyle`, `pushState(state, url)`.
7. **The four DSL imports coexist, and anything that emits JavaScript is an
   `Action`, never a String.** `Ref.focus()`, `Toast.success(...)`,
   `UI.Modal.open(id)`, a Template's `onMount()` — all Actions.
8. **A class list is `cls`/`classes`, a choice is `when`.** `cls("card")` is
   the everyday class attribute; `classes("chip", when(active, "chip-on"))`
   joins parts and skips the ones that did not match;
   `attrs().classIf(cond, "on")` reads condition-first.
   `when(cond, element)` renders one branch, `when(cond, ifTrue, ifFalse)`
   chooses, and `when(cond).then(a).otherwise(b)` says the same thing down the
   page. Branches take an Element, a lambda, or a String.
9. **A form is a record.** `form(Contact.class)` renders a labelled control per
   record component (typed by the Java type), adds the CSRF hidden field from
   the current request, and `Form.bind(Contact.class, req)` validates the
   submission and hands back the record. Rules live on the record:
   `@Form.Required`, `@Form.Email`, `@Form.Password`, `@Form.Multiline(rows)`,
   `@Form.Label`, `@Form.Length`. `form(X.class).errors(bound)` re-renders with
   per-field messages, `aria-invalid`, and the values as typed.

## Do not emit 1.x or 2.x syntax

Version 3.0.3 adds the backend set, all additive: `jweb.Sql` and `jweb.Migrations`
(`Sql.query(...).list(User.class)`, `Sql.table("todos", Todo.class)`, `V<n>__name.sql`
files under `db/migration`) — prefer them to JPA or Spring Data in a JWeb app;
`jweb.PubSub` (`publish(topic, msg)` re-renders every subscribed page, across
instances with Redis); durable `jweb.Jobs` (`Jobs.handle`, `Jobs.enqueue`) and
`jweb.Mail` (`Mail.to(...).subject(...).html(div(...)).send()`); `jweb.Storage` for
uploaded files; the `jweb.Auth` flows (`Auth.token`/`verify`, `rememberCookie`,
`logoutEverywhere`, `Auth.Totp`); `Cache.shared`, `Cache.once`, `Cache.lock`;
`req.id()` and `jweb.Trace`; `Admin.mount(app, "/admin", todos)`; `jweb.Browser` for
a test in a real Chrome; and `jweb.Var` (`local("q")`) for a JavaScript local named
once — prefer it to `v("q")` for a name a script repeats. MongoDB is opt-in: the
driver on the classpath and `jweb.data.enabled: true`.

Version 3.0.2 adds handles and typed selectors on top of 3.0.1: `cls("card")` and
`id("x")` return `Cls`/`Id`, which are element arguments, selectors
(`rule(CARD.hover(), ...)`) and JS targets (`delegate(LAYOUT, "click", COPY)`);
every `rule(...)` takes a `Selector`; `shadow()`, `font()`, `stop()` and
`vh(100).minus(px(50))` replace string values; `Template.scripts()` is collected
like `styles()`; `app.pages("/x", req -> new XPage(req.query("q")))` keeps a page
with constructor arguments in the page table; `Middlewares.recommended()` includes
CSRF, so a hand-written POST sends the `_csrf` field from `Csrf.tokenMeta()`.
Prefer these forms; never emit `.rule("…")`, `.prop("…")` or `inlineScript("…")`.

Version 3.0.1 is source- and binary-incompatible with 2.x, and these changes do
not produce a compile error:

- `a("/home", "Home")` now renders the text `/homeHome`. Emit
  `a(href("/home"), "Home")`. Likewise `label(for_("id"), "Text")` and
  `option(value("v"), "Text")` — never a leading String that means an attribute.
- Under `import static jweb.Js.*`, `call("fn")`, `fetch("/url")` and
  `sleep(ms)` are page-level Actions; the expression forms are `JS.call(...)`,
  `fetch(str("/url"))`, `delay(ms)`.
- `span("x")` is the `<span>` element even with `jweb.Css.*` imported.

Also gone: `attrs().onClick(...)` as the only way to attach a handler (write
`onClick(...)` as an argument), the `text(…)` wrapper as a requirement (a bare
String is text), `new Nav().render()` (a Template is an Element),
`match/cond/otherwise` and the `.elif(...)` chain (use `when(cond, x)`,
`when(cond, x, y)`, `when(cond).then(x).otherwise(y)`, or a `switch`
expression), the `jweb.Actions` import as a separate layer (it is `jweb.Js`),
`Css.raw` (`CSSValue.of`), the Selector starters in `Css`
(`jweb.css.Selectors`), and the `*Js()` String helpers (Actions).

Also gone, from the second HTML pass: the no-argument `attrs().style` starter and
the `done` call that ended it (a `style()` builder is its own element
argument, or `attrs().style(s -> ...)`); `jweb.Input`, `jweb.Button`, `jweb.el.DialogHelper`,
`jweb.el.DetailsHelper`, `jweb.el.FormEnhancements`; the 23 typed-input statics
(`textInput`, `emailInput`, `field(label, input)`, ...); and both old form
builders (`Form.create()` and `FormModel`). Full detail is in the
`migrating-to-3` topic below.

## Imports

Elements, styles, JS and 3D all come from single static imports that coexist:

```java
import static jweb.El.*;    // div, h1, p, a, form, onClick, swap, bind, ...
import static jweb.Css.*;   // style(), px(), rem(), hex(), media(), ...
import static jweb.Js.*;    // toggle, show, fetch, onSubmit("id"), actions(), ...
import static jweb.Three.*; // scene, box, camera, ... (Three.patch stays qualified)
```

`div`, `h1`, `p` and `text` must all resolve to `jweb.El`. An IDE will happily
offer `javax.management.Query.div` or `com.mongodb.client.model.Indexes.text` —
both compile and neither is JWeb.


## What is in this document

- `overview` — Overview and quick start
- `tutorial` — Tutorial: a first app — page and layout, live state, a validated form, a JSON endpoint, tests
- `migrating-to-3` — Migrating to 3.x — handlers as arguments, a String is text, one JS import, and what breaks silently
- `dsl-3-0-2` — DSL 3.0.2 — class and id handles shared by HTML, CSS and JS, typed selectors in every rule, value builders, collected scripts(), request-aware pages, CSRF in recommended()
- `migrating-to-2` — Migrating to 2.x — what changed, and what breaks silently
- `why-jweb` — Why JWeb, and the honest trade-offs
- `architecture` — Architecture: rendering pipeline, request flow, routing, middleware
- `html-dsl` — HTML DSL: elements, attributes, forms, conditionals, builders
- `css-dsl` — CSS DSL: inline styles, rules, units, colors, at-rules, themes
- `javascript-dsl` — JavaScript DSL: actions, handlers, fetch, events, browser APIs
- `state-and-realtime` — State, hydration, WebSocket, SSE, transitions, async rendering
- `three-dsl` — Three DSL: declarative 3D scenes — shapes, lights, shadows, models, click events
- `backend` — Backend: REST, OpenAPI, SQL and migrations, MongoDB, security, validation, uploads, mail, jobs
- `configuration` — Configuration, environment, dev tools, CLI, project structure
- `known-issues` — Known issues and sharp edges — the API pitfalls
- `ref-templates` — Reference: templates
- `ref-elements` — Reference: elements
- `ref-css` — Reference: css
- `ref-javascript` — Reference: javascript
- `ref-routing` — Reference: routing
- `ref-state` — Reference: state
- `ref-http` — Reference: http
- `ref-middleware` — Reference: middleware
- `ref-validation` — Reference: validation
- `ref-security` — Reference: security
- `ref-file-upload` — Reference: file upload
- `ref-storage` — Reference: file storage — local, S3-compatible, served and signed URLs
- `ref-sql` — Reference: SQL and migrations
- `ref-sse` — Reference: server-sent events
- `ref-jobs` — Reference: background jobs, durable jobs
- `ref-mail` — Reference: mail
- `ref-i18n` — Reference: i18n
- `ref-observability` — Reference: observability — request ids, logs, metrics, traces
- `ref-testing` — Reference: testing

Fetch one on its own with `/docs/tell?topic=<id>`.


<!-- ==================== BEGIN overview ==================== -->

# Overview and quick start

> Source: `readme/README.md` · topic id: `overview`

# JWeb Framework

**Version 3.0.3** | **Last Updated: 2026-10-15** | [![CI](https://github.com/oscar-osmig/Jweb/actions/workflows/ci.yml/badge.svg)](https://github.com/oscar-osmig/Jweb/actions/workflows/ci.yml) | [Changelog](./CHANGELOG.md)

A pure Java web framework that lets you build full-stack web applications entirely in Java. No HTML templates, no JSP, no Thymeleaf — just type-safe Java code with compile-time safety and full IDE support.

JWeb brings modern frontend concepts (component model, reactive state, virtual DOM) to server-side Java development, giving you the productivity of React/Vue with the power and safety of Java.

---

## Philosophy

JWeb is built on these core principles:

1. **Pure Java** — Your entire frontend is Java code. No context switching between languages.
2. **Type Safety** — Catch errors at compile time, not runtime. Your IDE becomes your best friend.
3. **Fluent DSL** — Expressive, readable APIs that feel natural to write.
4. **Component-Based** — Build UIs from composable, reusable pieces.
5. **Reactive** — State changes propagate to the UI (see [State & Realtime](./readme/state-and-realtime.md) for current wiring status).
6. **Minimal Dependencies** — Built on Spring Boot, no JavaScript toolchain required.
7. **Modular Architecture** — Clean separation with focused files. Facade entry points (`jweb.El.*` for HTML, `jweb.Css.*` for CSS, `jweb.Js.*` for JavaScript) hide complexity while keeping internals maintainable.

---

## Features

- **Type-safe HTML** — 24 element modules; build HTML with Java methods, no string templates
- **Type-safe CSS** — 29 style modules; CSS properties as methods, units, colors, animations, `@media`/`@container`/`@supports`/`@layer`/`@scope`
- **Type-safe JavaScript** — 43 JS modules; generate client-side JS from Java, from form handlers to IndexedDB and WebRTC
- **Component-based** — Reusable components via the `Template` interface
- **Virtual DOM** — `VNode` tree rendering with XSS-safe text escaping by default
- **Routing & Middleware** — Fluent route definitions with `:param` path parameters and a request pipeline
- **REST API** — Annotation-based REST controllers (`@REST`, `@GET`, `@POST`, `@UPDATE`, `@PATCH`, `@DEL`) dispatched by Spring MVC
- **Databases, both opt-in** — SQL over any JDBC `DataSource` (`Sql`: queries to records, named parameters, generated keys, transactions, a table helper; `Migrations`: versioned `V*__*.sql` files applied at startup; `Admin.mount(app, "/admin", todos)` for a back office over any table) and MongoDB (`Mongo`, `Doc`, `MongoQuery`); each loads only when its driver is on the classpath
- **Security** — JWT auth, session auth (`Auth`/`Principal`), CSRF protection, rate limiting, BCrypt hashing, OAuth2 providers with PKCE and session-held state; the flows an app needs — signed single-use tokens for password reset and email verification, remember-me, log out everywhere, TOTP two-factor and recovery codes
- **Validation** — Composable `Validator<T>` plus `FormValidator`/`FieldValidator` fluent APIs
- **OpenAPI** — Generates OpenAPI 3.0.3 spec + Swagger UI, Redoc, and Scalar doc pages
- **Realtime** — Reactive `State<T>` with a wired browser↔server loop (WebSocket events, DOM morphing), SSE from any route, `live(state, s -> ...)` reactive regions and `bind`/`bindAttr`/`bindClass` element bindings; `PubSub.publish(topic, msg)` re-renders every subscribed page, across instances through Redis with no client library
- **Fragments (server-driven UI)** — `attrs().swap(url, target)` / `swapForm(...)` / `swapMorph(...)` fetch-and-swap HTML fragments with View Transitions, DOM morphing (focus/input state preserved), and history — the HTMX pattern, built in, zero JS written
- **Streaming SSR** — `Streamed.of(() -> page)` flushes the shell instantly; `Suspense` blocks stream in as their data resolves, in parallel, no JS written
- **Typed routes** — `TypedRoute.path("/users/:id", Long.class)`: handler params parsed and URLs compile-time checked
- **Built-in AI** — `AI.ask/chat/agent` with tool-calling loops against any OpenAI-compatible API (OpenAI, Ollama, Groq...), plus a drop-in chat widget — zero dependencies
- **SEO & performance** — `Seo` builder (OG/Twitter/canonical), on-the-fly image optimization (`/jweb/img`), immutably-cached runtime assets, gzip, startup warmup
- **Background work** — Durable `Jobs.enqueue` (stored, retried with backoff, dead-lettered; shared by every instance on the SQL store), virtual-thread `Jobs.run`, cron `Scheduler` with `Cache.once` so one instance runs it, `Suspense` for async rendering, TTL `Cache` in memory and `Cache.shared` across instances through a Redis the framework speaks itself
- **Mail** — `Mail.to(...).subject(...).html(div(...)).send()` with the HTML DSL as the body; the framework speaks SMTP itself (STARTTLS, AUTH), an outbox for development and tests, `.later()` through the durable queue
- **File storage** — `Storage.upload("avatars", file)` and `Storage.url(key)`: a directory in development, any S3-compatible bucket in production (the framework signs its own requests), served with ETags and ranges, signed URLs that expire
- **Observability** — a request id on every response, every log line and every error page (`req.id()`); `jweb.Trace` spans around routes, SQL, jobs, PubSub, mail, Fetch and browser events, bridged into Micrometer Observation and OpenTelemetry when the app has them
- **Developer Experience** — Hot reload dev server, testing utilities (`JWebTest`, `MockRequest`, `TestClient`, and `Browser` — a headless Chrome the framework drives itself, no WebDriver), CLI scaffolding (`jweb new` with a database, `jweb generate resource` for a record, its migration, pages and routes), accessibility auditor, `Middlewares.recommended()` secure baseline

---

## Install

JWeb is published via [JitPack](https://jitpack.io/#oscar-osmig/Jweb). Add the repository
and the dependency:

```xml
<repositories>
    <repository>
        <id>jitpack.io</id>
        <url>https://jitpack.io</url>
    </repository>
</repositories>

<dependency>
    <groupId>com.github.oscar-osmig</groupId>
    <artifactId>Jweb</artifactId>
    <version>v3.0.3</version>
</dependency>
```

Gradle:

```groovy
repositories { maven { url 'https://jitpack.io' } }
dependencies { implementation 'com.github.oscar-osmig:Jweb:v3.0.3' }
```

Then annotate your application class — the framework's beans arrive through Spring Boot
auto-configuration, so you only component-scan your own package:

```java
import org.springframework.boot.SpringApplication;

@JWebApplication
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}
```

Requires **Java 21+**. Use `main-SNAPSHOT` as the version to track the latest commit.

Databases are opt-in. For SQL add `spring-boot-starter-jdbc` and a driver and set
`spring.datasource.url`; `jweb.Sql` binds to the `DataSource` and runs the migrations under
`db/migration`. For MongoDB add `org.mongodb:mongodb-driver-sync` and set
`jweb.data.enabled: true`. See [Backend](./readme/backend.md#database-sql-sql-migrations).

### What's new in 3.0.3

3.0.3 is additive — nothing in a 3.0.2 app changes. It adds the backend an application needs
around the DSL: `Sql` and `Migrations` (MongoDB becomes opt-in: add its driver), `PubSub`
across instances, durable `Jobs` and `Mail`, `Storage`, the `Auth` flows (reset and
verification tokens, remember-me, log out everywhere, TOTP), `Cache.shared` with locks and
fleet-wide rate limits, request ids and `Trace`, `Admin.mount` and `jweb generate resource`,
`Browser` for a test in a real Chrome, `Var` handles for JavaScript locals, and a tutorial.
Every protocol is spoken by the framework itself — Redis, SMTP, S3, DevTools — so the
dependency list does not grow. The full list: **[CHANGELOG.md](./CHANGELOG.md#303--2026-10-15)**.

### Upgrading to 3.0.2

3.0.2 removes the last reasons to write CSS or JavaScript as a string: `cls("card")` and
`id("x")` return handles that are at once an element argument, a selector
(`rule(CARD.hover(), …)`) and a JavaScript target (`delegate(LAYOUT, "click", COPY)`);
every `rule(…)` takes a `Selector`; shadows, font stacks, `calc` and keyframes have
builders; `Template.scripts()` is collected like `styles()`; pages with constructor
arguments go in the page table (`app.pages("/x", req -> new XPage(req.query("q")))`);
`Middlewares.recommended()` includes CSRF. Two declared types change (`Attr x = cls(…)`
becomes `Cls x`). Everything else is additive — **[dsl-3.0.2.md](dsl-3.0.2.md)**.

### Upgrading to 3.0

3.0.1 is a syntax release: handlers, swaps and state bindings are plain element arguments,
a String argument is always text, the four DSL imports (`El`, `Css`, `Js`, `Three`) coexist
without ambiguity, and everything that emits JavaScript is an `Action` rather than a String.
Recompile against the new jar.

Three call sites still compile but now mean something different — check them by hand:

- `a("/home", "Home")` renders the text `/homeHome`; the first String is no longer the
  href. Write `a(href("/home"), "Home")`.
- `label("email", "Email:")` and `option("us", "United States")` likewise: use
  `label(for_("email"), ...)` and `option(value("us"), ...)`.
- `call("fn")`, `fetch("/url")` and `sleep(ms)` under `import static jweb.Js.*` are the
  page-level `Action` forms; the expression forms are `JS.call(...)`, `fetch(str("/url"))`
  and `delay(ms)`.

Everything else either still compiles with a deprecation warning or fails loudly. Full
details and the before/after for each change:
**[dsl-simplification-3.md](dsl-simplification-3.md)** (and
[dsl-simplification.md](dsl-simplification.md) for the 2.0 pass).

---

## Quick Start

New here? [The tutorial](./readme/tutorial.md) builds a todo app in about fifteen minutes —
a page and layout, live state, a validated form, a JSON endpoint, and the tests for them.
`jweb new myapp` generates the same shape of project.

```java
import jweb.Element;
import jweb.Template;
import static jweb.El.*;

public class HelloWorld implements Template {
    @Override
    public Element render() {
        return div(class_("container"),
            h1("Hello, JWeb!"),
            p("Built with pure Java")
        );
    }
}
```

### Define Routes

```java
@Component
public class Routes implements JWebRoutes {
    @Override
    public void configure(JWeb app) {
        // Middleware (applies to router-handled routes; see architecture doc for caveats)
        app.use(Middlewares.logging());

        // Pages with a shared layout — call layout() BEFORE pages()
        app.layout(Layout.class)
           .pages(
               "/", HomePage.class,
               "/about", AboutPage.class,
               "/contact", ContactPage.class
           );

        // Dynamic routes with request access (`:param` syntax)
        app.get("/profile/:id", req -> new ProfilePage(req.param("id")));

        // API-style routes on the JWeb router (note: paths starting with /api/v
        // are reserved for Spring MVC @REST controllers — see architecture doc)
        app.get("/data/users", req -> Response.json(userService.findAll()));
    }

    // Your own pages and services — stubbed here so the snippet stands on its own.
    static class Layout implements Template { public Element render() { return div(); } }
    static class HomePage implements Template { public Element render() { return div("Home"); } }
    static class AboutPage implements Template { public Element render() { return div("About"); } }
    static class ContactPage implements Template { public Element render() { return div("Contact"); } }
    record ProfilePage(String id) implements Template {
        public Element render() { return div("Profile " + id); }
    }
    interface UserService { List<String> findAll(); }
    UserService userService = () -> List.of("Ada", "Grace");
}
```

### Form with Server Submission — zero JavaScript written

A form is a record: it declares the fields and their rules, renders the markup, and comes
back typed. The CSRF hidden field is taken from the current request.

```java
public record Contact(
    @Form.Required String name,
    @Form.Required @Form.Email String email,
    @Form.Required @Form.Multiline(rows = 4) String message) {}

// The form POSTs and swaps the returned fragment into #form-status.
// Without JS it still submits natively to the same route.
form(Contact.class)
    .action("/contact/submit")
    .swapForm("/contact/submit", "#form-status")
    .submit("Send Message")

// The route validates against the same record and returns a fragment:
app.post("/contact/submit", ctx -> {
    Form.Bound<Contact> submitted = Form.bind(Contact.class, ctx);
    if (!submitted.ok()) return p(cls("form-error"), submitted.errors().getAllMessages().get(0));
    save(submitted.value());
    return p(cls("form-ok"), "Message sent!");
});

void save(Contact contact) { /* your own persistence */ }
```

For richer client behavior the JS DSL is still there (`actions().add(onSubmit(...)...)`) —
see [JavaScript DSL](./readme/javascript-dsl.md).

---

## Imports — one rule

Everything you import lives in the `jweb` package:

```java
// Static DSLs
import static jweb.El.*;      // HTML: elements, attributes, conditionals, record forms
import static jweb.Css.*;     // CSS: style(), units, colors, grid, media(), keyframes()
import static jweb.Js.*;      // JS: handlers, actions, expressions, events, async — one import
// (jweb.Actions is the same surface under its old name)
import static jweb.State.*;   // server-driven state hooks
import static jweb.UI.*;      // prebuilt components
import static jweb.Mongo.*;   // MongoDB access (also jweb.Schema, jweb.Doc)

// Specialty modules keep their class names under jweb.css.* / jweb.js.*
import static jweb.css.Selectors.*;   // and MediaQuery, Keyframes, Stylesheet, ...
import static jweb.js.JSClipboard.*;  // and JSCanvas, JSCrypto, JSStorage, ...

// App infrastructure
import jweb.JWebApplication;  // the @JWebApplication annotation
import jweb.api.*;            // @REST, @GET, @POST, @UPDATE, @PATCH, @DEL
import jweb.Middlewares;      // recommended(), logging(), rateLimit(), ...
import jweb.Response;         // Response.html/json/redirect
import jweb.Csrf;             // CSRF tokens (and jweb.CsrfToken, jweb.Auth)
import jweb.Suspense;         // async blocks (and jweb.Streamed, jweb.Jobs)
import jweb.SseEmitter;       // server-sent events
import jweb.TypedRoute;       // compile-time-checked routes
import jweb.state.State;      // the State<T> type useState() returns

// Types
import jweb.Element;          // what components return
import jweb.Template;         // what pages implement
import jweb.Style;            // what style helpers return
import jweb.CSSValue;         // what color/unit helpers return
import jweb.JWeb;             // the app builder
import jweb.JWebRoutes;       // where you configure routes
```

**Migrating from the long imports:** the old
`com.osmig.Jweb.framework.*` entry points (`El`, `Elements`, `CSS`, `CSSUnits`,
`CSSColors`, `JS`, `Events`, `Actions`, `StateHooks`, `UI`, `Layout`, `Style`, ...)
still compile — they are `@Deprecated` aliases of the same code, so existing apps
keep working unchanged. New code should use the `jweb.*` forms; in most files
`jweb.El` + `jweb.Css` replace what used to take four or five imports.

---

## Documentation

| Topic | Description |
|-------|-------------|
| [Tutorial](./readme/tutorial.md) | Your first app in fifteen minutes: page and layout, live state, a validated form, a JSON endpoint, tests — every block compiled by the suite |
| [Why JWeb?](./readme/why-jweb.md) | Feature comparison with React/Next, HTMX, Astro & co — and the honest trade-offs |
| [Architecture](./readme/architecture.md) | Rendering pipeline, request flow, routing systems, middleware, Request/Response APIs |
| [HTML DSL](./readme/html-dsl.md) | Elements, attributes, modern HTML5, popover, forms, conditionals, fluent builders |
| [CSS DSL](./readme/css-dsl.md) | Inline styles, rules, units, colors, animations, at-rules, themes, utilities |
| [JavaScript DSL](./readme/javascript-dsl.md) | Actions, form handlers, fetch, events, and the full 43-module browser API surface |
| [State & Realtime](./readme/state-and-realtime.md) | Reactive state, events, hydration, WebSocket, SSE, transitions, portals, async rendering |
| [Three DSL](./readme/three-dsl.md) | Declarative 3D scenes — shapes, curves, terrain, lights, one-call shadows, glTF models; a first-person walk mode with feet, solids, float and click-to-move; portals, zones and proximity; sound; live patches over the socket |
| [Backend](./readme/backend.md) | REST API, OpenAPI, SQL and migrations, MongoDB, security, validation, forms, uploads, mail, jobs, testing |
| [Configuration](./readme/configuration.md) | Setup, config files, environment variables, dev tools, CLI, project structure |
| [Known Issues](./readme/known-issues.md) | Verified gaps, unwired features, and API pitfalls — read before extending the framework |
| [Changelog](./CHANGELOG.md) | Every tagged release, newest first; each section is that tag's GitHub Release |
| [The 4.0 plan](./readme/roadmap-4.0.md) | What is left after 3.0.2, measured: JS identifiers as strings, the deprecated surface, `Val` as a statement, the component form — with a recommendation on each |
| [DSL 3.0.2](./dsl-3.0.2.md) | The 3.0.2 pass: class and id handles shared by HTML, CSS and JS, typed selectors in every rule, value builders for shadows, fonts, calc and keyframes, collected `scripts()`, request-aware pages, CSRF in the recommended middleware |
| [Migrating to 3.0](./dsl-simplification-3.md) | The 3.0 syntax pass: handlers as arguments, a String is always text, one JS import, and the call sites that silently change meaning |
| [Migrating to 2.0](./dsl-simplification.md) | The 2.0 pass: the six rules the DSL follows and the breaking changes to check by hand |
| [Design Tooling](./readme/design-tooling.md) | impeccable setup, and how to run its detector against JWeb's rendered HTML |

Additional internal reference docs ship with the framework source at
`src/main/java/com/osmig/Jweb/framework/docs/` (19 topic files) and are copied into the jar
under `framework-src/`.

### For AI assistants: `/docs/tell`

`GET /docs/tell` returns this entire documentation set — the guides above plus all 19
reference topics — as one markdown document, version-stamped and ordered so the DSL rules
and the 2.x breaking changes come first. Point an assistant at it before asking it to write
JWeb code.

Open it in a browser and it downloads as `jweb-<version>-docs.md`. Over HTTP you just get
the body:

```
curl https://jweb.build/docs/tell                  # everything, ~300KB
curl https://jweb.build/docs/tell?topic=css-dsl    # one topic, with the header
curl -OJ https://jweb.build/docs/tell              # save with the server's filename
```

An unknown `topic` returns 404 listing the valid ids. Each document is fenced by
`BEGIN <id>` / `END <id>` comments so the response can be split back into files.

---

## Running the Application

```bash
# Development (with hot reload)
./mvnw spring-boot:run

# Compile only
./mvnw compile

# Run all tests
./mvnw test

# Run specific test class
./mvnw test -Dtest=ClassName

# Library build (default — sample app excluded, no executable jar)
./mvnw clean package

# Runnable showcase build (includes the sample app, produces an exec jar)
./mvnw clean package -Pdemo
java -jar target/Jweb-2.0.0-exec.jar

# With specific profile
java -jar target/Jweb-2.0.0-exec.jar --spring.profiles.active=prod
```

Then open `http://localhost:8085` in your browser (port is `${PORT:8085}` in `application.yaml`;
the Dockerfile `EXPOSE`s the same 8085).

The sample app serves:

- `/`, `/about`, `/contact` — public pages
- `/docs` — the interactive documentation site (21 sections)
- `/sandbox` — live DSL playground; `/demo/streaming` — streaming SSR demo
- `/only-admin/log/in` — admin dashboard (token-based; configure `JWEB_ADMIN_TOKEN`/`JWEB_ADMIN_EMAIL`)
- `/api/docs`, `/api/redoc`, `/api/scalar`, `/api/openapi.json` — generated API documentation

---

## Requirements

- **Java 21+** (uses records, sealed interfaces, pattern matching, virtual threads)
- **Maven 3.6+**
- **Spring Boot 4.0.8** (parent POM)
- **MongoDB** (optional — set `jweb.data.enabled: false` to run without it)

---

## Project Status

The HTML/CSS/JS DSLs, routing, MongoDB integration, security, and validation layers are
functional and used by the bundled sample app. The reactive state loop is fully wired: the
client runtime (`/jweb/runtime.js`) is auto-injected into every rendered page (disable with
`jweb.runtime.enabled: false`), and state changes patch the DOM over the WebSocket/event
channel. AI integration ships built-in (`AI.ask/chat/agent` against any OpenAI-compatible
API, configured via `jweb.ai.*`) — only the *optional* Spring AI starters remain commented
out in `pom.xml`. [Known Issues](./readme/known-issues.md) tracks exactly what works, what
doesn't, and where the sharp edges are.

---

## License

MIT

---

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Email: the.jweb.team@gmail.com

---

Built with Java by developers who love Java!

<!-- ==================== END overview ==================== -->


<!-- ==================== BEGIN tutorial ==================== -->

# Tutorial: a first app — page and layout, live state, a validated form, a JSON endpoint, tests

> Source: `readme/guides/tutorial.md` · topic id: `tutorial`

[← Back to README](./../README.md)

# Your first JWeb app

A todo app in about fifteen minutes: a page with a layout, a list that updates without a
reload, a form the server validates, a JSON endpoint, and a test for each. Everything is
Java. Nothing here writes HTML, CSS or JavaScript, and every code block on this page is
compiled by the test suite, so it is current.

If you only read one guide first, read this one; the others are references.

## 1. Create the project

The CLI ships inside the framework jar. With the jar on your path:

```bash
alias jweb='java -cp Jweb.jar com.osmig.Jweb.framework.cli.JWebCli'
jweb new todos          # pom.xml, Application, Routes, HomePage, MainLayout, a database
cd todos
mvn spring-boot:run     # http://localhost:8085
```

The project comes with a database: H2 as a file under `./data`, `spring.datasource.url`
pointing at it, and `src/main/resources/db/migration/` for the SQL files `jweb.Migrations`
applies at startup. Nothing to install; swap the driver and the URL for PostgreSQL when the
app leaves your machine.

Or by hand. The dependency comes from JitPack (`vX.Y.Z` is a tag from the
[changelog](../CHANGELOG.md)); JWeb brings Spring Boot's web and websocket starters with it:

```xml
<repositories>
  <repository><id>jitpack.io</id><url>https://jitpack.io</url></repository>
</repositories>
<dependency>
  <groupId>com.github.oscar-osmig</groupId>
  <artifactId>Jweb</artifactId>
  <version>v3.0.3</version>
</dependency>
```

The entry point is a Spring Boot application with the framework's wiring folded in:

```java
import jweb.JWebApplication;
import org.springframework.boot.SpringApplication;

@JWebApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
```

## 2. A page and its layout

A page is a `Template`: `render()` is the markup, `styles()` the CSS rules it needs, and
`pageTitle()` the title. Class names are handles — declared once, used by the markup and
the rules, so a typo is a compile error, not a silent miss.

```java
import jweb.Cls;
import jweb.Element;
import jweb.Template;
import jweb.css.Stylesheet;

public class HomePage implements Template {

    static final Cls HERO = cls("hero");
    static final Cls LEAD = cls("hero-lead");

    @Override
    public String pageTitle() {
        return "Todos";
    }

    @Override
    public Element render() {
        return section(HERO,
            h1("Todos"),
            p(LEAD, "A list, a form, and an API — all of it Java."),
            a(href("/todos"), "Open the list"));
    }

    @Override
    public Stylesheet styles() {
        return stylesheet()
            .rule(HERO, style().textAlign(center).padding(rem(4), rem(1)))
            .rule(LEAD, style().fontSize(rem(1.25)).color(hex("#6b7280")));
    }
}
```

A layout is a `Template` whose constructor takes the title and the page: the framework
looks for `(String title, Element content)`. The page's `pageTitle()` arrives as the title;
its `styles()` and `scripts()` are collected into the document along with the layout's own.

```java
import jweb.Cls;
import jweb.Element;
import jweb.Template;
import jweb.css.Selector;
import jweb.css.Stylesheet;

public class MainLayout implements Template {

    static final Cls HEADER = cls("site-header");
    static final Cls MAIN = cls("site-main");

    private final String title;
    private final Element content;

    public MainLayout(String title, Element content) {
        this.title = title != null ? title : "Todos";
        this.content = content;
    }

    @Override
    public Element render() {
        return html(attrs().lang("en"),
            head(metaCharset(), meta("viewport", "width=device-width, initial-scale=1.0"), title(title)),
            body(
                header(HEADER, a(href("/"), "Todos")),
                main(MAIN, content)));
    }

    @Override
    public Stylesheet styles() {
        return stylesheet()
            .rule(Selector.type("body"), style().margin(zero).fontFamily(systemUi, sansSerif))
            .rule(HEADER, style().padding(rem(1), rem(2)).borderBottom(px(1), solid, hex("#e5e7eb")))
            .rule(MAIN, style().maxWidth(px(720)).margin(zero, auto).padding(rem(2)));
    }
}
```

Routes live in one `JWebRoutes` bean. Pages go in the page table: they get the layout, the
title hooks and the client runtime without any further wiring. Call `layout()` before
`pages()` — the registry captures the default layout as pages register.

```java
import jweb.JWeb;
import jweb.JWebRoutes;
import jweb.Middlewares;

@Component
public class Routes implements JWebRoutes {

    @Override
    public void configure(JWeb app) {
        app.use(Middlewares.recommended());          // security headers, request ids, compression, CSRF

        app.layout(MainLayout.class)
           .pages("/", HomePage.class,
                  "/todos", TodosPage.class);
    }

    // Stubs so this block stands on its own; the real classes are above and below.
    static class MainLayout implements Template {
        MainLayout(String title, Element content) {}
        public Element render() { return div(); }
    }
    static class HomePage implements Template { public Element render() { return div(); } }
    static class TodosPage implements Template { public Element render() { return div(); } }
}
```

Run it and open `http://localhost:8085`. The page is server-rendered HTML; the one script
on it is the framework's runtime, which the next step needs.

## 3. A list that updates without a reload

State is declared in `render()` with `useState`. A `live(...)` region re-renders on the
server whenever a state it reads changes, and the runtime morphs the new markup into the
page over the WebSocket. Handlers such as `onClick(e -> …)` run on the server too. No
JavaScript is written for any of it.

```java
import jweb.Cls;
import jweb.Element;
import jweb.Template;
import jweb.css.Stylesheet;
import jweb.state.State;
import static jweb.State.*;

public class TodosPage implements Template {

    record Todo(String text, boolean done) {
        Todo toggled() { return new Todo(text, !done); }
    }

    static final Cls LIST = cls("todo-list");
    static final Cls DONE = cls("done");

    private State<List<Todo>> todos;

    @Override
    public String pageTitle() {
        return "Your todos";
    }

    @Override
    public Element render() {
        todos = useState(new ArrayList<>(List.of(new Todo("Read the tutorial", false))));

        return div(
            h1("Your todos"),
            form(onSubmit(e -> {
                    e.preventDefault();
                    String text = e.formData().getOrDefault("text", "").trim();
                    if (!text.isEmpty()) todos.mutate(list -> list.add(new Todo(text, false)));
                }),
                input(id("new-todo"), type("text"), name("text"),
                      attrs().placeholder("What needs doing?").autocomplete("off")),
                button(type("submit"), "Add")),
            live(todos, list -> ul(LIST, each(list, todo ->
                li(when(todo.done(), DONE),
                   onClick(e -> todos.mutate(l -> l.replaceAll(t -> t == todo ? t.toggled() : t))),
                   todo.text())))));
    }

    @Override
    public Stylesheet styles() {
        return stylesheet()
            .rule(LIST, style().listStyle(none).padding(zero))
            .rule(LIST.descendant(Selector.type("li")), style().padding(rem(0.5), zero).cursor(pointer))
            .rule(DONE, style().textDecoration(lineThrough).color(hex("#9ca3af")));
    }
}
```

What happens on a click: the browser sends the handler's id over the socket, the server
runs the lambda in this page's render context, sees that `todos` changed, re-renders the
`live` region and sends it back as a patch. The region's root carries `data-live`; the
runtime replaces only that subtree, so focus and scroll position survive.

The state lives for the visit — a render context the server keeps for five idle minutes.
The next step keeps the todos in the database, so they survive the visit and are the same
for everyone. When a context expires, the page tells the user and offers a reload
([State & Realtime](./state-and-realtime.md#when-the-connection-drops-and-when-the-page-expires)).

## 4. A form the server validates, a table that keeps it

For data that must be checked and stored, a form is a record: the record declares the
fields and their rules, renders the form, and comes back typed. It also works with JavaScript
off. The same record is a row: `Sql.table("todos", Todo.class)` reads and writes it by
column name, and `id` is null until the row is inserted.

```java
import jweb.Form;

public record Todo(
    Long id,
    @Form.Required @Form.Length(min = 3, max = 80) String text,
    boolean done) {}
```

The table comes from a migration — a file under `src/main/resources/db/migration`, applied
once, at startup, in version order:

```sql
-- src/main/resources/db/migration/V1__create_todos.sql
create table todos (
    id bigint generated by default as identity primary key,
    text varchar(80) not null,
    done boolean not null default false
);
```

Render the form in a page and handle the POST in `Routes`. `Middlewares.recommended()`
includes CSRF protection, and the record form carries the token for you.

```java
import jweb.Form;
import jweb.Sql;

record Todo(Long id, @Form.Required @Form.Length(min = 3, max = 80) String text, boolean done) {}
Sql.Table<Todo> todos = Sql.table("todos", Todo.class);

// In a page: the rows, and the form — the id stays hidden
Element list = ul(each(todos.where("done = ? order by id", false), t -> li(t.text())));
Form<Todo> addForm = form(Todo.class)
    .action("/todos/add")
    .field("id", f -> f.type("hidden"))
    .field("text", f -> f.label("New todo").placeholder("What needs doing?"))
    .submit("Add");

// In Routes.configure(app):
app.post("/todos/add", req -> {
    Form.Bound<Todo> submitted = Form.bind(Todo.class, req);
    if (!submitted.ok()) {
        return form(Todo.class).action("/todos/add").field("id", f -> f.type("hidden")).errors(submitted).submit("Add");
    }
    todos.insert(submitted.value());                   // the copy that comes back carries the id
    return Response.redirect("/todos");
});
```

A submission that fails validation re-renders the form with the message beside the field
and the value the user typed; a good one is a row. The browser's own validation and the
server's come from the same annotations, so they cannot disagree.

All of this is what `jweb generate resource Todo text:string! done:boolean` writes: the
record, the migration, a list page, a form page and their routes. And
`Admin.mount(app, "/admin", todos).guard(Auth.requireRole("admin"))` is a back office for
the table — list, search, add, edit, delete — in one line.

## 5. A JSON endpoint

REST controllers are plain classes with the `jweb.api` annotations, under `/api/v…` (those
paths are reserved for them). Return values are serialized as JSON; a record parameter
annotated `@Body` is read from the request body.

```java
import jweb.api.REST;
import jweb.api.GET;
import jweb.api.POST;
import jweb.api.Body;

@REST("/api/v1/todos")
public class TodosApi {

    record Todo(String text, boolean done) {}
    record NewTodo(String text) {}

    private final List<Todo> todos = Collections.synchronizedList(new ArrayList<>());

    @GET
    public List<Todo> list() {
        return List.copyOf(todos);
    }

    @POST
    public Todo add(@Body NewTodo body) {
        Todo todo = new Todo(body.text(), false);
        todos.add(todo);
        return todo;
    }
}
```

`curl localhost:8085/api/v1/todos` answers `[]`; `OpenApi.scan(...)` can document the whole
API ([Backend](./backend.md#openapi-documentation)).

## 6. Test it without a browser

Page routes and handlers are tested in-process. `JWebTest.test` dispatches a request
through the middleware stack and the page table; `JWebTest.render` renders a page with its
event loop attached, so a test can click and submit and read the patched markup back.

```java
import jweb.JWebTest;
import jweb.MockRequest;
import jweb.RenderedPage;
import jweb.state.State;
import static jweb.State.*;

class TodosPage implements Template {
    State<List<String>> todos;
    public Element render() {
        todos = useState(new ArrayList<>());
        return div(
            form(id("add"), onSubmit(e -> { e.preventDefault(); todos.mutate(l -> l.add(e.formData().get("text"))); })),
            live(todos, list -> ul(each(list, t -> li(t)))));
    }
}

// The route answers
JWeb todoApp = JWeb.create().pages("/todos", TodosPage.class);
JWebTest.TestResult result = JWebTest.test(todoApp, MockRequest.get("/todos"));
JWebTest.assertContains(result.getBody(), "<ul");

// The handler runs and the region re-renders
try (RenderedPage page = JWebTest.render(new TodosPage())) {
    RenderedPage.Result r = page.submit("add", Map.of("text", "Write tests"));
    JWebTest.assertContains(page.html(), "<li>Write tests</li>");
}
```

A test that touches the table gets an in-memory database: connect `Sql` to H2 and run the
migrations, the same files the app runs at startup.

```java
import jweb.Migrations;
import jweb.Sql;

record Todo(Long id, String text, boolean done) {}

org.h2.jdbcx.JdbcDataSource h2 = new org.h2.jdbcx.JdbcDataSource();
h2.setURL("jdbc:h2:mem:todos;DB_CLOSE_DELAY=-1");
Sql.connect(h2);
Migrations.migrate(h2);                                   // classpath:db/migration

Sql.Table<Todo> todos = Sql.table("todos", Todo.class);
Todo saved = todos.insert(new Todo(null, "Write tests", false));
JWeb todoApp = JWeb.create();
JWebTest.TestResult posted = JWebTest.test(todoApp, MockRequest.post("/todos/add").formData(Map.of("text", "Ship it")));
```

The whole suite runs with `mvn test`, no browser required; H2 is already a test dependency
of a generated project.

## 7. Run it for real

```bash
jweb build              # packages the jar and writes a multi-stage Dockerfile
docker build -t todos . && docker run -p 8085:8085 todos
```

Three things to know before a deployment: render contexts (the state behind `useState` and
the handlers behind `onClick`) live in memory on the instance that rendered the page, so run
one instance or route `/jweb` with sticky sessions; set `SESSION_COOKIE_SECURE=true` once
the app is behind HTTPS; and point `spring.datasource.url` at a real database (the H2 file
under `./data` is for your machine — add the PostgreSQL driver and the migrations run there
unchanged). The rest of the production checklist is in
[Configuration](./configuration.md).

## Where to go next

- [HTML DSL](./html-dsl.md) — every element and attribute, forms, conditionals
- [CSS DSL](./css-dsl.md) — rules, units, colors, at-rules, themes
- [State & Realtime](./state-and-realtime.md) — `useState`, `live`, bindings, streaming SSR
- [Backend](./backend.md) — REST, OpenAPI, SQL with migrations, the admin, MongoDB, security, validation, uploads and file storage, mail
- [JavaScript DSL](./javascript-dsl.md) — for the client-side behavior that does need a script
- [Known issues](./known-issues.md) — the sharp edges, before you hit them

<!-- ==================== END tutorial ==================== -->


<!-- ==================== BEGIN migrating-to-3 ==================== -->

# Migrating to 3.x — handlers as arguments, a String is text, one JS import, and what breaks silently

> Source: `readme/dsl-simplification-3.md` · topic id: `migrating-to-3`

# DSL Simplification 3.0 — what changed

The 2026-09-04 pass over the HTML, CSS, JavaScript and Three DSLs, and the 2026-09-07
follow-up that finished the HTML side (class names, conditionals, one form system). The 2.0 pass
([dsl-simplification.md](dsl-simplification.md)) set six rules; this pass fixes the places
where the framework's own pages could not follow them. The goals are unchanged: builder
style, names that are simple to remember, no raw JS/HTML/CSS, pure Java.

Every claim below about what compiles was checked with a scratch file that imports all four
DSL wildcards (`jweb.El.*`, `jweb.Css.*`, `jweb.Js.*`, `jweb.Three.*`) at once. That file
is the check to re-run when adding a static to any facade.

---

## What was in the way

- `id("main")` and `raw("...")` **did not compile** under the standard `El.*` + `Css.*`
  dual import, because the CSS Selector-builder starters (`select() all() tag() cls() id()`)
  lived in the `Css` facade. `select()` and `tag("x")` silently returned a `Selector`.
  This is why the sample pages wrote `attrs().id(...)` everywhere.
- No handler, swap, ref or state binding had a top-level factory, so every interactive
  element became `attrs().id().onClick().style()....done()`.
- Seven non-void elements read their first String as an attribute (`a`, `label`, `option`,
  `blockquote`, `datalist`, `optgroup`, `abbr`), so `a("Hello ", strong("world"))` compiled
  as a link to "Hello ". Nobody trusted "a String is text", and `text("…")` wrapped
  everything.
- `then_ elif_ let_ await_ in_ delete_ data_ var_ title_ style_ template_` carried an
  underscore without being Java keywords.
- `Ref`, `Toast`, `UI.Modal`, the popover helpers and the Template lifecycle hooks
  returned raw JavaScript Strings.
- `Actions` and `Js` shared `fetch(String)`, `call(String)`, `sleep(int)` with identical
  signatures, so pages qualified `jweb.Actions.toggle(...)` by hand.
- `Suspense.of` had `Callable` and `Supplier` twins, so every lambda needed a cast.
- `Template` already extended `Element`, but every sample called `.render()` on it.

## The rules now

1. **A String argument is text**, wherever it appears. Void elements keep positional
   Strings (`img(src, alt)`, `meta(name, content)`, `input(type, name)`), and the
   code-bearing `inlineScript(js)`/`style(css)` emit theirs verbatim.
2. **Every element is `name(Object...)`** — attributes, handlers, a bare `style()` and
   children in any order. `attrs()` is the long tail.
3. **Every CSS property takes a String as well as a typed value.**
4. **A trailing underscore marks a Java keyword, and nothing else.** When an element and
   an attribute (or another DSL) share a name, the common one keeps the bare name and the
   other has no shortcut.
5. **A block takes its body inline** — including `if`.
6. **Platform names win.**
7. **The four DSL imports coexist**, and **anything that emits JavaScript is an `Action`**,
   never a String.

---

## HTML

| Was | Now |
|---|---|
| `button(attrs().id("x").onClick(h).style()….done(), text("Save"))` | `button(id("x"), onClick(h), style()…, "Save")` |
| `form(attrs().id("f").action(u).method("post").swapForm(u, "#s"), …)` | `form(id("f"), action(u), method("post"), swapForm(u, "#s"), …)` |
| `input(attrs().ref(r))` | `input(ref(r))` |
| `span(attrs().data("state", s.getId()), text(String.valueOf(s.get())))` | `span(bind(s), s.get())` |
| `a("/home", "Home")` — first String was the href | `a(href("/home"), "Home")` — a String is text |
| `label("email", "Email:")` | `label(for_("email"), "Email:")` |
| `option("us", "United States")`, `option("Chrome", "Chrome")` | `option(value("us"), "United States")`, `option("Chrome")` |
| `abbr("HTML", "HyperText…")` | `abbr(attr("title", "HyperText…"), "HTML")` |
| `blockquote(citeUrl, …)`, `datalist(id, …)`, `optgroup(label, …)` | `blockquote(attrs().cite(url), …)`, `datalist(id("x"), …)`, `optgroup(attrs().label("x"), …)` |
| `h1(text("Title"))` | `h1("Title")` — `text(String)` is deprecated |
| `data_(…)`, `var_(…)` elements; `title_(…)`, `style_(…)` shortcuts; `template_` | `tag("data", …)`, `tag("var", …)`; `attrs().title(…)`, a bare `style()` argument; `template` |
| `when(c).then(a).elif(c2, b).otherwise(d)`, `match(cond(…), otherwise(…))`, `Tag.ifElse` | **deleted** — `when(c, x)`, `when(c, x, y)`, `when(c).then(x).otherwise(y)`, or a `switch` expression |
| `body(new Nav().render(), …)` | `body(new Nav(), …)` — a Template is an Element |
| `submitButton("x")` | `button(type("submit"), "x")` (the name collided with app helpers) |

New top-level factories in `jweb.El`: every `on*` handler (both `Consumer<Event>` and
`Action` forms), `on(type, handler)`, `swap`, `swapOuter`, `swapMorph`, `swapForm`,
`swapPush`, `ref`, `bind`, `bindInput`.

### The second HTML pass (classes, conditionals, one form system)

| Was | Now |
|---|---|
| `class_("card")` everywhere | `cls("card")` — `class_` stays as the keyword-rule twin |
| `class_("chip" + (active ? " chip-on" : ""))` | `classes("chip", when(active, "chip-on"))` |
| `attrs().classIf("active", isActive)` | `attrs().classIf(isActive, "active")` — condition first, like `when` |
| `when(cond, x)` twice, the second negated | `when(cond, ifTrue, ifFalse)` |
| `when(c).then(a).elif(c2, b).otherwise(d)` | `when(c).then(a).otherwise(b)` — no `elif`; several branches are a `switch` |
| `match(cond(a, x), cond(b, y), otherwise(z))`, `Elements.CondCase`, `Elements.Condition` | **deleted** — `when`, a ternary, or a `switch` expression |
| `Tag.ifElse(c, a, b)`, `Elements.ifElse(c, a, b)` | **deleted** — `when(c, a, b)` |
| `text("Hi")` as a child | `"Hi"` — the wrapper is `@Deprecated` |
| `attrs().style().padding(px(8)).done().id("x")` | `attrs().style(s -> s.padding(px(8))).id("x")` — `Attributes.style()` and `InlineStyle.done()`/`toAttrs()` are **deleted** |
| `div(attrs().style().padding(px(8)), …)` | `div(style().padding(px(8)), …)` |
| `optgroup(attr("label", "Cars"), …)` | `optgroup(attrs().label("Cars"), …)` |
| `blockquote(attr("cite", url), …)` | `blockquote(attrs().cite(url), …)` |
| `option(attrs().set("selected", ""), …)` | `option(selected(), …)` / `attrs().selected(isChosen)` |
| `form(attrs().enctype("multipart/form-data"), …)` | `form(enctype("multipart/form-data"), …)` — a free static, and a record form with an `UploadedFile` component sets it itself |
| `button(title("x"), …)` silently nested a `<title>` | `button(attrs().title("x"), …)`; a `<title>` outside a head/SVG parent now logs a warning |
| SVG `defs/symbol/use/ellipse/tspan/textPath/stop/pattern/filter/mask/clipPath/image/animate/animateTransform/animateMotion/fe*` reachable only from `jweb.el.SVGElements` | exported from `jweb.El`. Three are renamed because the plain name is taken under the four wildcards: `svgText`, `svgLinearGradient`, `svgRadialGradient` |
| `<lineargradient>`, `<clippath>`, `<animatetransform>` in the output | the camelCase SVG names are preserved by the serializer |
| `jweb.Input` / `Input.text("q")`, `jweb.Button` / `Button.submit("Go")` | **deleted** — `input(type("text"), name("q"), id("q"))`, `button(type("submit"), "Go")` |
| `jweb.el.DialogHelper.showModal/show/close/toggle/…` | `jweb.El.openDialog(id)` / `closeDialog(id)` / `closeDialog(id, value)` / `toggleDialog(id)` — the rest was dead |
| `jweb.el.DetailsHelper.open/close/toggle/openExclusive/closeAll/…` | `jweb.El.openDetails(id)` / `closeDetails(id)` / `toggleDetails(id)` |
| `jweb.el.FormEnhancements.*` | **deleted** — `datalist`, `fieldset`, `legend`, `optgroup` are on `jweb.El`; the rest were typed-input helpers |
| `textInput/emailInput/passwordInput/numberInput/checkbox/radio/radioId/hiddenInput/fileInput/dateInput/timeInput/datetimeInput/monthInput/weekInput/searchInput/telInput/urlInput/rangeInput/colorInput/field` (23 statics) | **deleted** — a form is a record; anything else is the element with its attributes |
| `com.osmig.Jweb.framework.elements.Form` (the small builder) | **deleted** — `form(…)` the element, or `form(SomeRecord.class)` |
| `Form.create().text("name", f -> …).email(…).submit("Go").build()` | `form(Contact.class).action(url).field("name", f -> …).submit("Go")` |
| `FormModel.of(Registration.class)…build()`, `@FormField` / `@FormHidden` / `@FormIgnore`, `FormModel.bindFromParameterMap(...)` | **deleted** — `form(Registration.class)`, `@Form.Required` / `@Form.Email` / `@Form.Password` / `@Form.Multiline` / `@Form.Label` / `@Form.Length`, `Form.bind(Registration.class, req)` |
| `Csrf.getOrCreateToken(req)` passed into the page, `Csrf.tokenField(token)` per form | the record form adds the hidden field from the current request; `.csrf(token)` overrides |
| hand-written `if (isBlank(name) …) return error(...)` in the route | `Form.bind(...)`, then `bound.ok()` / `bound.errors()` |
| no field-level error rendering anywhere | `form(X.class).errors(bound)` — per-field message, `aria-invalid`, a summary, and the values as typed |

`jweb.Form` is now a real class (it used to be an alias for `forms/Form`), and it is
generic: `jweb.Form<T extends Record>`. `jweb.When` is the new chained-conditional type;
`jweb.Form.Bound<T>` is what `Form.bind` returns. `Form.styles()` is a drop-in stylesheet
for the class names the form emits.


## CSS

| Was | Now |
|---|---|
| `id("x")` ambiguous; `select()`/`tag("x")` returned a Selector | Selector starters moved to `jweb.css.Selectors` (`import static jweb.css.Selectors.*`); a String selector `rule(".card:hover")` needs nothing |
| `raw("x")` as a CSSValue (ambiguous with HTML `raw`) | `CSSValue.of("x")` — rarely needed, every property takes a String |
| `span("sidebar")` grid line-name span (captured `span("text")`) | `gridColumn("span sidebar")` |
| `.animation(anim("name"), s(3), …)` | `.animation("name", s(3), …)` |
| `stylesheet().mediaQuery(mq, new Rule(sel, style))` | `stylesheet().add(mq.rule(sel, style))` |
| `.keyframes(k)`, `.fontFace(f)`, `.supports(s)` | `.add(k)`, `.add(f)`, `.add(s)` (old names still work) |

### Stylesheets belong to pages

| Was | Now |
|---|---|
| `head(…, style(globalStyles()))` with `private String globalStyles()` returning `stylesheet()….build()` | `@Override public Stylesheet styles()` on the layout/page/component — the render collects every template's, dedupes by content, and emits one nonce-stamped `<style>` in `<head>` |
| `div(class_("docs-layout"), style(docsStyles()), …)` | the same `styles()` hook; no `<style>` in the body |
| `Stylesheet.toStyleTag()` (used 0 times — there was no way to attach a sheet to a page) | still there, but `styles()` is the way |
| `div().styled(s).hover(s2)` — `Tag.styled()/.hover()/.focus()/.active()` and `StyledElement`, which minted a counter-based `jweb-N` class and emitted a `<style>` block next to the element (no dedupe, no hoisting, a different class every render) | **deleted**. `div(style().apply(s).hover(s2))` |
| no way to write `:hover` on an element without a class rule | `style().hover(style()…)` — a content-hash class (`j-3f9a1c`) is generated, its rules ride the page stylesheet, the plain declarations stay inline |
| — | `.focus .focusVisible .focusWithin .active .visited .disabled .checked .placeholder .popoverOpen .before(content, s) .after(content, s) .on(":nth-child(2n)", s)` |
| — | `.at(md(), s)`, `.at(container("card")…, s)`, `.dark(s)`, `.reducedMotion(s)`, `.startingStyle(s)` — they nest, and `apply()` carries them |
| — | `MediaQuery.query()` / `ContainerQuery.query()` — the condition line without its rules |

Fragments, streamed blocks and WebSocket patches each carry the CSS their own render
introduced, exactly as they already carry Actions definitions.

### Layout is a style, not an element

| Was | Now |
|---|---|
| `jweb.Layout` / `com.osmig.Jweb.framework.layout.Layout` — 45 element-returning wrappers, used by nothing | **deleted**. `row() row(gap) stack() stack(gap) center() cluster(gap) container(maxWidth) card() truncate() truncate(lines) srOnly() fullBleed() aspect(w,h) cover() contain() grid(cols,gap) autoGrid(min) autoGrid(min,gap)` on `jweb.Css`, each returning a `Style` |
| `style().display(flex).flexDirection(column).gap(SP_4)` (×46 in our own pages) | `stack(SP_4)` |
| `style().maxWidth(px(500)).margin(zero, auto)` | `container(px(500))` |

### One token system

| Was | Now |
|---|---|
| `com.osmig.Jweb.framework.styles.Theme` (601 lines, `Theme.create()`, `toCss()/toFullCss()/toStyleElement()`) — dead code | **deleted**; `jweb.css.Theme` is the real type |
| `CSSVariables.designSystem()` / `DesignSystemBuilder` | **deleted** — a third token-naming scheme |
| `CSSVariables.theme()` / `ThemeBuilder` (`light(…).dark(…).buildBoth()`) | **deleted** — a fourth |
| `CSSVariables.scoped(scope, name)`, `component(c, p)`, `theme(name)` | **deleted** — string concatenation dressed as API |
| `CSSVariables.themeColor/spacing/radius/fontSizeVar/shadow(level, fallback)` | **deleted** — they encoded a naming convention; use `Theme.color("x")` etc. |
| an app's tokens as `static final CSSValue PRIMARY = hex("#4f46e5")` | `Theme.light().color("primary", hex("#4f46e5"))` emitted through `styles()`, read back as `Theme.color("primary")` → `var(--color-primary)`; the app keeps its constant names |
| dark mode by hand | `.dark(Theme.dark().color("bg", …))` → `:root`, `@media (prefers-color-scheme: dark){:root:not([data-theme=light])}` and `:root[data-theme=dark]` |
| `Utility.generateCss(Theme)` | `Utility.generateCss()` — the parameter was never read (`Utility` stays deprecated) |

### The modern-CSS modules are part of the DSL now

Ten modules were separate static imports whose methods returned raw
`"property:value"` strings, so they only composed through `.prop(...)`. They are now in
the `Css` inheritance chain — `CSSColors extends CSSNested extends CSSProperty extends
CSSScope extends CSSLayer extends CSSScrollSnap extends CSSMasking extends
CSSLogicalProperties extends CSSSubgrid extends CSSTextWrap extends
CSSAnchorPositioning` — and they are typed.

| Was | Now |
|---|---|
| ten imports: `import static jweb.css.CSSTextWrap.*;` … | `import static jweb.Css.*` reaches all of them |
| `style().prop(textWrapBalance())` — the method returned `"text-wrap:balance"` | `style().apply(textWrapBalance())` — it returns a `Style` |
| `style().prop("top", anchor("--menu", "bottom"))` — a bare `String` | `style().top(anchor("--menu", "bottom"))` — it returns a `CSSValue` |
| `CSSNested.rule(sel)` — collided with `Css.rule(sel)` | `nest(sel)` |
| `CSSProperty.color/length/number/percentage/integer/angle/time/image(name)` — collided with the unit and colour statics | `colorProperty/lengthProperty/numberProperty/percentageProperty/integerProperty/angleProperty/timeProperty/imageProperty(name)` |
| `CSSSubgrid.gridTemplateColumns/gridTemplateRows/gridColumn/gridRow(String)` | **deleted** — the `Style` methods of the same name already did it |
| `CSSAnchorPositioning.top/right/bottom/left(String)` | **deleted** — `style().top(anchor(…))` |
| every value-taking module method took a `String` | each also takes a `CSSValue` (`inlineSize(rem(1))`, `snapPadding(px(20))`, `maskSize(percent(100))`) |

The six value modules (anchor positioning, text wrap, subgrid, logical properties,
masking, scroll snap) are **no longer deprecated** — the deprecation said "returns a
pre-joined string, use the `Style` method", which stopped being true.

### Properties and at-rules that were missing

| Was | Now |
|---|---|
| `.prop("border-right", "none")` (only the 3-arg form existed) | `.borderRight(none)`, and `borderLeft/Top/Bottom(CSSValue)` |
| — | `fieldSizing(content\|fixed)`, `scrollbarColor(thumb, track)`, `scrollbarWidth`, `scrollbarGutter(stable\|stableBothEdges)`, `textBoxTrim`, `textBoxEdge`, `textBox(trim, edge)`, `anchorScope`, `viewTransitionClass`, `interpolateSize(allowKeywords)` |
| — | `@starting-style`: `style().startingStyle(s)` and `stylesheet().startingStyle(sel, s)` |
| — | `Selector.popoverOpen()` / `.open()`, and `style().popoverOpen(s)` |
| — | `ContainerQuery.style("--x", "y")` → `@container style(--x: y)` |
| — | keyword constants `content`, `thin`, `stableBothEdges`, `trimBoth/trimStart/trimEnd`, `capAlphabetic`, `exAlphabetic`, `allowKeywords` |

## JavaScript

| Was | Now |
|---|---|
| `import static jweb.Actions.*` + `import static jweb.Js.*`, collisions on `fetch/call/sleep` | one import, `import static jweb.Js.*`; `jweb.Actions` is the same surface |
| `Async.fetch("/url")`, `Async.sleep(ms)` (expression twins) | `fetch(str("/url"))` / `fetch(v("url"))`, `delay(ms)` — the bare names are the page-level Actions |
| `.if_(c).then_(a).elif_(c2).then_(b).else_(d).end()` | `.if_(c, a).elif(c2, b).else_(d)` |
| `let_`, `await_`, `awaitYield_`, `then_`, `elif_`, `in_`, `delete_` | `let`, `await`, `awaitYield`, `then`, `elif`, `in`, `delete` |
| `Actions.script()`, `query()`, `queryAll()` (deprecated aliases) | `actions()`, `dom()`, `domAll()` |
| `template("r")`, `field("x")`, `dateField`, `renderList` (client template engine) | deleted — render on the server and swap a fragment |
| `attrs().set("onclick", ref.focus())` — `Ref` returned Strings | `onClick(ref.focus())` — `Ref` methods are Actions; `ref.get("value")` is a `Val` |
| `Toast.successJs("x")` | `Toast.success("x")` (Action); `*Js` deprecated |
| `UI.Modal.openJs(id)` / `closeJs(id)` | `UI.Modal.open(id)` / `close(id)` (Actions) |
| `showPopover(id)` returned a String | returns an Action |
| `Toast.builder().action("Reload", "location.reload()")` | `.action("Reload", reload())` |
| `Template.onMount()`/`onUnmount()` returned JS Strings; `scripts()` returned `Optional<String>` | return `Action` / `Optional<Action>` (an `actions()` builder is an Action) |

An `Action` is a statement anywhere a statement goes: `.if_(cond, toggle("panel"))` works.

### Behaviors — the JavaScript our own app could not write in the DSL

The framework's own pages carried ~520 lines of raw JavaScript in Java strings: an SPA
content swap with its own fetch/cache/popstate code, a scrollspy, a copy button with an
`execCommand` fallback, a line gutter and a split-pane drag. All of it is verbs now, and
the four scripts are pure DSL (a test fails if `unsafeRaw` or `.raw(` reappears in them).

| Was | Now |
|---|---|
| `iife().unsafeRaw(guard("x").unsafeRaw(js).build())` — raw JS strings in every script | `actions().does(guard("x").does(behavior, behavior))` — `does(Object...)` takes an `Action`, `Val`, `Stmt` or `Func` |
| a hand-written copy button + `legacyCopy` textarea fallback | `copy(text)` / `copyFrom(selector)` with `.feedback(text, ms)`, `.feedbackClass`, `.failText`, `.trigger(el)` |
| 80 lines of scrollspy (activation line, click lock, MutationObserver) | `scrollSpy(nav, headings)` with `.within`, `.linkClass`, `.activeClass`, `.hasHeadingsClass`, `.host`, `.offset`, `.scrollMargin` |
| a private fetch + TTL cache + `pushState` + popstate reimplementation of `swap()` | `swap(...)`/`swapPush(...)` markup, plus `navigate(url)` (`.target/.push/.morph/.replace/.cache/.prefetch`), `prefetch(selector)` (`.within/.onHover/.onVisible/.delay/.cache/.url`), `activeLink(selector)`, `onPopState(Func)` |
| a drag handler and a wrapped-line gutter in strings | `splitPane(handle, left)` (`.container/.min/.max/.minPercent/.maxPercent/.persist`), `lineGutter(textarea, gutter)` (`.mirror/.errorClass`) + `relineGutter`, `markLine`, `resizeToContent`, `insertText` |
| no Java-side reader of client state (`JWeb.getState` existed, nothing called it) | `syncState(state)` → a `Val`; `onStateChange(state, callback)` → an `Action` |
| `.raw("if(x !== y) return;")` for an early return | `if_(condition, return_())` — a `Stmt`, anywhere a statement goes |
| `callback("e").raw("e.preventDefault()")` | `callback("e").does(preventDefault())`; also `stopPropagation()`, `stopImmediatePropagation()` |
| `.onUpgrade(callback("db").raw(createStore(v("db"), …).build().js()))` | `.onUpgrade(db -> createStore(db, "users").keyPath("id").build())` |
| no Web Components, no shadow DOM, no PWA manifest | `customElement(name)` (`observedAttributes/shadow/template/connected/disconnected/attributeChanged/adopted/extendsTag`), `attachShadow`, `shadowRoot`, `assignSlot`, `assignedSlot`, `assignedElements`, `assignedNodes`, `onSlotChange`; `jweb.js.Pwa` — `manifest(...)`, `link()`, `serve(app, manifest)`, `registerServiceWorker(url)` |
| `document.createElement` / `document.activeElement` / template parsing by hand | `createElement(tag)`, `activeElement()`, `parseHtml(html)` |
| `sse(url)` had no named-event listener | `.on(eventName, callback)` |
| `byId(x).addEventListener(type, callback("e").raw(debounced.js()))` | `byId(x).addEventListener(type, debounce(300, callback("e")…))` — the `Val` overload |

New builder types, all `jweb.js.*` and all `Action`s: `Behavior`, `Copy`, `Navigate`,
`Prefetch`, `ActiveLink`, `ScrollSpy`, `SplitPane`, `LineGutter`, `CustomElement`,
`Manifest`, `Pwa`.

The client runtime grew the implementations (`JWeb.copyText/scrollSpy/prefetchOn/
activeLink/splitPane/lineGutter/resizeToContent/insertText/onState/nearest/parseHtml`) plus
a ready queue behaviors install through, and `swap()` gained `opts.push`, `opts.mode`, a
shared TTL cache (`data-swap-cache`), and a `pushState`-before-dispatch order so a
`jweb:swap` listener sees the URL it navigated to.

### The browser modules take their own import

The pass briefly re-exported the ten most-used browser modules (`JSClipboard`,
`JSObservers`, `JSStorage`, `JSHistory`, `JSUrl`, `JSFormData`, `JSAnimation`,
`JSWebAnimations`, `JSMedia`, `JSIndexedDB`) from `jweb.Js`. The docs compile test showed
what that costs: the documented `import static jweb.js.JSStorage.*` next to
`import static jweb.Js.*` became an "ambiguous reference" for every shared name, because
Java has no way to tell two identical statics apart and a class cannot inherit from ten.
The re-exports are gone again (the `Modules` layer and `JsModuleFacadeTest` with them):
`Js.*` is the page-level DSL, and a browser API is reached through its module's own import,
exactly as the module guides show. `jweb.js.ModuleImportsCoexistTest` compiles all ten
module imports together with the four DSL wildcards.

| Was (briefly, unreleased) | Now |
|---|---|
| `import static jweb.Js.*;` alone for `local()`, `cookie("theme")`, `replaceState("/x")`, `openDB("app", 1)` | add `import static jweb.js.JSStorage.*;` (or `JSHistory`, `JSIndexedDB`, …) — the import the module guides always showed |

## Async

`Suspense.of(() -> …)` — only the `Callable` overload exists, so the cast is gone.

## Three

`plane(w, h).flat()`, `disc(r).flat()`, `ring(a, b).flat()` lay the shape on the ground
(`rotation(-90, 0, 0)`, named). Under `jweb.Js.*`, `patch(url)` is HTTP PATCH, so the
live-scene patch stays qualified: `Three.patch(sceneId)`.

## Short names are the real names

The 2026-09-07 rule: **every type an app author can name has a `jweb.*` spelling that IS the
type the framework returns or accepts.** The check is what the IDE auto-imports when you write
`X x = someJwebCall();` — if it starts with `com.osmig`, the job was not done. Until now many
`jweb.*` names were sub-type shells (a protected constructor over the framework class): fine for
statics, but `jweb.css.Stylesheet s = stylesheet()` did not compile because the framework
returned the supertype. The real classes now live under `jweb`; the old fully-qualified names
remain as `@Deprecated` aliases so existing source keeps compiling.

| Before | After | Old name is now |
|---|---|---|
| `com.osmig.Jweb.framework.elements.Tag` | `jweb.Tag` | subclass alias (all constructors) |
| `com.osmig.Jweb.framework.attributes.Attributes` | `jweb.Attributes` | subclass alias |
| `com.osmig.Jweb.framework.attributes.Attr` | `jweb.Attr` (record) | static factories only — not usable as a type |
| `com.osmig.Jweb.framework.events.Event` | `jweb.Event` | sub-interface alias |
| `com.osmig.Jweb.framework.js.Actions.Action` | `jweb.Action` | **deleted** (nested) |
| `com.osmig.Jweb.framework.js.JS.Val` | `jweb.Val` | **deleted** (nested) |
| `com.osmig.Jweb.framework.js.JS.Func` | `jweb.Func` | **deleted** (nested) |
| `com.osmig.Jweb.framework.js.JS.Stmt` | `jweb.js.Stmt` | **deleted** (nested) |
| `com.osmig.Jweb.framework.server.Request` | `jweb.Request` | subclass alias |
| `com.osmig.Jweb.framework.server.Response` | `jweb.Response` | subclass alias (statics) |
| `com.osmig.Jweb.framework.security.Principal` | `jweb.Principal` | subclass alias (statics) |
| `com.osmig.Jweb.framework.upload.UploadedFile` | `jweb.UploadedFile` | subclass alias |
| `com.osmig.Jweb.framework.middleware.Middleware` | `jweb.Middleware` | sub-interface alias |
| `com.osmig.Jweb.framework.error.JWebException` | `jweb.JWebException` (+ an `int` status constructor, no Spring import) | subclass alias |
| `com.osmig.Jweb.framework.error.ValidationException` | `jweb.ValidationException` | subclass alias |
| `com.osmig.Jweb.framework.routing.RouteHandler` | `jweb.RouteHandler` | sub-interface alias |
| `com.osmig.Jweb.framework.seo.Seo` | `jweb.Seo` (`Seo.of(title, description)` works) | subclass alias |
| `com.osmig.Jweb.framework.db.mongo.Doc` | `jweb.Doc` | subclass alias |
| `com.osmig.Jweb.framework.async.Streamed` | `jweb.Streamed` (record) | `of(...)` only |
| `com.osmig.Jweb.framework.async.BackgroundTask` | `jweb.BackgroundTask` | subclass alias |
| `com.osmig.Jweb.framework.sse.SseEvent` | `jweb.SseEvent` (record) | static factories only |
| `com.osmig.Jweb.framework.sse.SseBroadcaster` | `jweb.SseBroadcaster` | subclass alias |
| `com.osmig.Jweb.framework.i18n.Messages` | `jweb.Messages` | subclass alias (statics) |
| `com.osmig.Jweb.framework.http.FetchResult` | `jweb.FetchResult` | subclass alias |
| `com.osmig.Jweb.framework.validation.ValidationResult` | `jweb.ValidationResult` | subclass alias |
| `com.osmig.Jweb.framework.validation.Validator` | `jweb.Validator` | sub-interface alias |
| `com.osmig.Jweb.framework.core.ErrorBoundary` | `jweb.ErrorBoundary` | subclass alias (statics) |
| `com.osmig.Jweb.framework.testing.MockRequest` | `jweb.MockRequest` | subclass alias (statics) |
| `com.osmig.Jweb.framework.testing.MockSession` | `jweb.MockSession` | subclass alias |
| `com.osmig.Jweb.framework.testing.TestClient` | `jweb.TestClient` | subclass alias (statics) |
| `com.osmig.Jweb.framework.styles.Stylesheet` | `jweb.css.Stylesheet` | subclass alias (statics) |
| `com.osmig.Jweb.framework.styles.Stylesheet.Rule` | `jweb.css.Rule` (record, `build()`) | **deleted** (nested) |
| `com.osmig.Jweb.framework.styles.MediaQuery` | `jweb.css.MediaQuery` | subclass alias (statics) |
| `com.osmig.Jweb.framework.styles.MediaQuery.Rule` | `jweb.css.Rule` | **deleted** (nested; `Supports.makeRule` and the `rules(MediaQuery.Rule...)` overloads went with it) |
| `com.osmig.Jweb.framework.styles.Rule` | `jweb.css.Rule` | `of(...)` only |
| `com.osmig.Jweb.framework.styles.CSS.Selector` | `jweb.css.Selector` | **deleted** (nested) |
| `com.osmig.Jweb.framework.styles.ContainerQuery` | `jweb.css.ContainerQuery` | subclass alias (statics) |
| `com.osmig.Jweb.framework.styles.Keyframes` | `jweb.css.Keyframes` | subclass alias (statics) |
| `com.osmig.Jweb.framework.styles.FontFace` | `jweb.css.FontFace` | subclass alias (statics) |
| `com.osmig.Jweb.framework.styles.Supports` | `jweb.css.Supports` | subclass alias (statics) |
| `com.osmig.Jweb.framework.three.Three` | `jweb.Three` (the real facade) | subclass alias (statics) |
| `com.osmig.Jweb.framework.three.*` (every node, `ThreePatch`, `ThreeRuntime`, `ThreeAssets`, `SceneSetting`) | `jweb.three.*` | **no alias** — never a documented import |
| `com.osmig.Jweb.framework.forms.Form` (the fluent builder), `com.osmig.Jweb.framework.forms.FormModel`, `com.osmig.Jweb.framework.elements.Form` | `jweb.Form<T extends Record>` (a new class, not an alias) | **deleted** |
| — | `jweb.Form.Bound<T>`, `jweb.Form.Field`, and the `@Form.Required/Email/Password/Multiline/Label/Length` hints | new |
| `com.osmig.Jweb.framework.elements.Elements.Condition` (the `when(c)` chain) | `jweb.When` | **deleted** |
| `com.osmig.Jweb.framework.routing.Query<T>` | `jweb.QueryParam<T>` (renamed: `Query` is the `@Query` annotation in `jweb.api`) | subclass alias with covariant `of`/`orElse`/`required` |
| `com.osmig.Jweb.framework.ai.AI` / `Chat` / `Agent` / `Tool` | `jweb.AI` / `jweb.Chat` / `jweb.Agent` / `jweb.Tool` | subclass aliases |
| `com.osmig.Jweb.framework.security.RateLimit` / `Cors` / `OAuth2` | `jweb.RateLimit` / `jweb.Cors` / `jweb.OAuth2` (the nested `RateLimitBuilder`, `CorsBuilder`, `Provider`… move with them) | subclass aliases (statics and nested types inherited) |
| `com.osmig.Jweb.framework.server.Cookie` | `jweb.http.Cookie` (a sub-package: `Cookie` is the `@Cookie` annotation in `jweb.api`) | subclass alias |
| `com.osmig.Jweb.framework.middleware.MiddlewareChain` | `jweb.MiddlewareChain` — what `Middleware.handle` receives | sub-interface alias |
| `com.osmig.Jweb.framework.health.Health` / `HealthStatus` / `HealthCheck` | `jweb.Health` / `jweb.HealthStatus` / `jweb.HealthCheck` | subclass / sub-interface aliases |
| `com.osmig.Jweb.framework.metrics.Metrics` | `jweb.Metrics` (`Counter`, `Gauge`, `Timer` nested) | subclass alias |
| `com.osmig.Jweb.framework.context.Context` / `ContextKey<T>` | `jweb.Context` / `jweb.ContextKey<T>` | subclass alias / **deleted** (a record cannot be aliased) |
| `com.osmig.Jweb.framework.navigation.Link` / `Navigation` | `jweb.Link` / `jweb.Navigation` | subclass aliases |

Also in this pass:

- `Template.extraHead()` returns `Optional<jweb.Element>` (was the legacy `core.Element`, which
  forced a long import into every override).
- New shells for the modules the gallery imports: `jweb.Password`, `jweb.Jwt`, `jweb.Toast`,
  `jweb.Transition`, `jweb.Portal`, `jweb.Markitdown`, `jweb.Fetch`, `jweb.StateManager`,
  `jweb.RenderContexts`, `jweb.Button`. Their statics return `jweb` value
  types (`Toast.success(...)` is a `jweb.Action`, `Fetch.get(url).send()` a `jweb.FetchResult`).
- `JS.esc(String)` and `JS.toJs(Object)` became public (the hoisted `Val`/`Func` need them),
  so they now appear under `import static jweb.Js.*`.
- `SceneSetting.put(key, value)` and the node constructors are public — the facade lives in
  another package now. Whole-valued doubles still serialize narrowed (`40`, not `40.0`).
- Guards: `jweb.JwebSurfaceTest` and `jweb.NoLongImportsTest` (see `readme/known-issues.md`).

**Migration:** a subclass alias keeps `OldName.staticCall()` and `new OldName(...)` compiling,
but `OldName x = frameworkCall()` no longer does — the framework returns the `jweb` type.
Swap the import; the simple name is unchanged for everything except the hoisted nested types.

## State, sessions, routing, API

The 2026-09-07 batch over what a page author needs from the server side, and what Spring
should not leak into author code.

| Was | Now |
|---|---|
| `span(bind(clicks), clicks.get())` — the value written twice | `span(bind(clicks))` — `bind` renders the value when it is the element's only content (the two-argument form still works, once) |
| `input(bindInput(name))` rendered no value | renders `value=` (a Boolean state checks a checkbox) |
| no attribute or class reactivity | `bindAttr(state, "disabled")`, `bindClass(state, "on")` (in `jweb.State`) |
| `useComponent("id", () -> element)` — a `<div id>` wrapper re-rendered on every change, documented nowhere | `live(state, s -> element)`, `live(a, b, (x, y) -> ...)`, `live(() -> ..., deps...)` — the root gets `data-live`, re-renders only for its own states, and is **morphed** in (focus/scroll/input kept); `useComponent` stays as a deprecated alias |
| `items.update(l -> { l.add(x); return l; })` — same instance, `equals` true, no notification | notifies; plus `items.mutate(l -> l.add(x))` |
| `Visit.of(req)` hand-rolled over `req.session().getAttribute` | `Session.of(Visit.class, req)`, `Session.of(req).get/put/find/flash`, `session()` inside a Template |
| `Optional<String> pageTitle()` / `metaDescription()` | `String pageTitle()` / `String description()` (null = default); `metaDescription()` stays deprecated and feeds `description()` |
| page routes exact-match only | `app.pages("/users/:id", UserPage.class)`; `pathParam("id")` / `params()` / `beforeRender(req)` |
| `if (!isAuthenticated(ctx)) return Response.redirect(...)` per route | `app.guard("/admin/**", Auth.requireLogin("/admin/login"))` — pages, router routes and `@REST` alike |
| `read query param → validate against a Set<String> → mutate → redirect` per route | `app.action("/act/x", Rec.class, (req, rec) -> ...)` with enums by name, `Optional<T>`, `@Range`/`@Length`/`@Pattern`; 400 with a message on failure |
| `Response.redirect(cond ? "/a#x" : "/a#y")` | `Response.redirect("/a").anchor(cond ? "x" : "y")`, `Response.redirectBack(req)` |
| `Response.html(HttpStatus.INTERNAL_SERVER_ERROR, page)` | `Response.html(500, page)`, `json(201, body)`, `status(304).header(..).build()`, `.contentType("text/markdown")`, `tooManyRequests()`, `error(418, "...")` — the `HttpStatus` overloads are deprecated |
| `@PathVariable`, `@RequestParam`, `@RequestBody`, `HttpServletRequest`, `MultipartFile`, `@Component`, `@Value` in a `@REST` class | `@Param`, `@Query`, `@Body`, `jweb.Request`, `@Upload` → `UploadedFile`, `jweb.api.Component`, `jweb.api.Value` |
| a WebSocket event could fall back to the global handler registry | a message that names a context runs only that context's handlers; unknown context → logged and dropped |
| a guard/middleware throwing `JWebException` (401/403) rendered a 500 page | answers with its own status |

**Breaking:** `Template.pageTitle()` now returns `String` — an `Optional<String>` override
fails to compile (drop the `Optional.of`). `EventRegistry.get(sessionId, id)` /
`execute(sessionId, ...)` no longer fall back to the global registry. Everything else is
additive or deprecated-with-delegation. `Response.redirect(...)` returns `Response.Redirect`,
a `ResponseEntity<Void>` — existing declarations keep compiling.

**Why not `Template.title()`:** a method named `title` on `Template` would shadow the
`title(...)` element factory inside every Template class (a member shadows a static import
by simple name, whatever the arity) — `head(title("x"))` would stop compiling. So the plain
hooks are `pageTitle()` and `description()`.

---

## What the docs compile test found (2026-09-11)

Compiling every documentation sample turned up gaps that were each a violation of a rule
above. Fixed at the source:

| Was | Now |
|---|---|
| `State<Integer> clicks = useState(0);` under `import jweb.*` — "type State does not take parameters", because `jweb.State` is the hooks facade and owns the simple name | unchanged by design: the value type is `jweb.state.State<T>` and takes its own import (`import jweb.state.State;`) next to `import jweb.*`, which shadows the facade for the type position while `import static jweb.State.*` still supplies the hooks; `jweb.State`'s Javadoc now shows exactly that |
| `app.post("/x", req -> …)` — ambiguous between `post(String, RouteHandler)` and `post(String, Function<Request,Object>)`; the framework's own app cast every lambda to `(RouteHandler)` | the `Function` overload is **deleted**; a lambda is a `RouteHandler`. (`get` was never ambiguous: `() -> …` is the `Supplier` form, `ctx -> …` the handler.) |
| `Query.of("page", Integer.class)`, `AI.chat()`, `Tool.of(…)`, `RateLimit.perMinute(n)`, `Cors.allowAll()`, `OAuth2.google()`, `Cookie.of(…)`, `MiddlewareChain`, `Health.register(…)`, `HealthStatus.up()`, `Metrics.counter(…)`, `Context.key(…)`, `ContextKey<T>`, `Link.to(…)`, `Navigation.script()` — long names only | the `jweb.*` spellings listed in the table above; `Query` became `QueryParam` and `Cookie` went to `jweb.http` so neither collides with the `jweb.api` annotation of the same name |
| `style().transform("rotate(45deg)")`, `style().animationPlayState(paused)` — no such overloads | added, with the String twin of every remaining `CSSValue`-only setter (`borderTop`…`borderLeft`, `size`, `transitionAll`/`Colors`/`Background`/`Transform`/`Opacity`, `scrollbarWidth`/`Gutter`, `textBoxTrim`/`Edge`, `interpolateSize`) and a typed twin for `transformOrigin`, `objectPosition`, `textShadow`, `fontFamily`; `jweb/StylePropertyPairsTest` checks the pairing both ways by reflection, with an allow-list for the free-text properties |
| `circle(attrs().cx(32))` — `cx`/`cy`/`r`/`x`/`y`/`x1`…`y2` were String-only and `offset`, `strokeLinecap`, `opacity`, `stopColor`, `fontSize`… were missing from `attrs()`; the free-static form `circle(cx(50), …)` needed `jweb.el.SVGElements.*`, which is ambiguous with `El.*` | every SVG coordinate and length on `attrs()` takes `int`, `double` or `String`, the paint/text/gradient/filter/animation attributes are there too, and the same set is on `jweb.El` as free statics: `svg(viewBox(0, 0, 100, 100), circle(cx(50), cy(50), r(40), opacity(0.5)))` under `El.*` alone |
| `import static jweb.Js.*` + `import static jweb.js.JSStorage.*` → "reference to local is ambiguous" | the ten re-exports are gone — see "The browser modules take their own import" above |

---

## Breaking — and NOT compile errors

These still compile and now mean something else. Search for them:

1. **`a("/x", "Text")`** renders the text `/xText`. → `a(href("/x"), "Text")`.
2. **`label("id", "Text")`** → `label(for_("id"), "Text")`.
3. **`option("v", "Text")`** → `option(value("v"), "Text")`.
4. **`call("fn")`, `fetch("/url")`, `sleep(ms)` under `Js.*`** are now Actions, not
   Vals. Inside expression builders they still work as statements; if you assigned them to
   a `Val`, use `JS.call(...)`, `fetch(str(...))`, `delay(...)`.
5. **`span("x")` with `Css.*` imported** is now the `<span>` element (it used to be the
   grid line-name span).
6. **`center`, `cover`, `contain`, `grid`, `row` with `Css.*` imported** are still the
   keyword constants; the new same-named *methods* (`center()`, `grid(3, gap)`) are a
   separate namespace, so `display(grid)` is unchanged.
7. **A `style()` with only conditional rules** (`style().hover(…)` and nothing else) no
   longer renders an empty `style=""` attribute.
8. **`style().prop(textWrapBalance())`** — the single-argument `prop(String)` that split a
   `"property:value"` string still exists, but the modules no longer return one, so this
   is now a compile error. → `style().apply(textWrapBalance())`.
9. **`asyncFunc(...).does(...)`** takes `Object...` rather than `Action...`. Every call
   still compiles; only passing an `Action[]` array as the single argument changes meaning
   (it becomes one `Object` argument). Pass the actions, not an array.
10. **A `Val` handed to `El.addEventListener(type, ...)`** now binds the new `Val` overload
   (the listener expression) instead of failing to compile. That is the point —
   `debounce(300, …)` attaches directly — but a `Val` you meant as something else no
   longer errors.
11. **A page that swaps and pushes history** reloads instead of doing nothing when the user
   goes back past the first swap: that entry is the document as the server first sent it,
   and nothing in the page remembers what that was.
12. **`span(bind(state))` renders the value** (it used to render an empty element that the
   runtime filled in later). Code that wrote the value next to it, `span(bind(s), s.get())`,
   renders exactly as before.
13. **`state.update(l -> { l.add(x); return l; })` now notifies** — regions and bindings
   re-render where they silently did nothing.
14. **`attrs().classIf("active", isActive)`** still compiles — the old
   `(String, boolean)` order is deprecated and delegates to `classIf(boolean, String)`,
   so the meaning is unchanged, but new code should read condition-first.
15. **`when(cond)`** returns `jweb.When` instead of the deleted `Elements.Condition`.
   A chain that ended in `.end()` or `.elif(...)` is a compile error; a
   `.then(...).otherwise(...)` chain keeps working and now also renders on its own
   without `otherwise(...)`.
16. **`button(title("x"), …)`** compiled before and compiles now, and still nests a
   `<title>` element rather than setting the tooltip. It is now loud: the renderer logs
   a warning for a `<title>` outside a head or SVG parent. Use `attrs().title("x")`.

Everything else is a compile error with an obvious fix, or a deprecation warning:
deleted `abbr(text, title)` / `blockquote(cite, …)` / `datalist(id, …)` / `optgroup(label, …)`,
`data_` / `var_` / `title_` / `style_` / `template_`, `Css.raw`, the Selector starters in
`Css`, `Async.fetch(String)` / `Async.sleep`, the `_`-suffixed JS names, `Suspense.of(Supplier)`,
`submitButton` / `resetButton`, the Actions template engine and its `script/query/queryAll`
aliases, `Attributes.style()` / `InlineStyle.done()` / `InlineStyle.toAttrs()`,
`match` / `cond` / `otherwise` / `CondCase` / `Condition` / `ifElse`, `jweb.Input`,
`jweb.Button`, `jweb.el.DialogHelper` / `DetailsHelper` / `FormEnhancements`,
`elements/Form`, `forms/FormModel`, and the 23 typed-input statics. `Template.onMount/onUnmount/scripts` change return type, so overrides fail loudly.

The release is **source- and binary-incompatible** — recompile downstream code.

## Deliberately not done

- **Numbers meaning pixels** for length properties (React's convention). Real ergonomics,
  but a convention rather than a platform name.
- **Renaming `class_`, `for_`, `float_`, `if_`, `return_`** and the rest of the keyword set,
  `hex()`, `px()`, or removing `attrs()` — all honest.

<!-- ==================== END migrating-to-3 ==================== -->


<!-- ==================== BEGIN dsl-3-0-2 ==================== -->

# DSL 3.0.2 — class and id handles shared by HTML, CSS and JS, typed selectors in every rule, value builders, collected scripts(), request-aware pages, CSRF in recommended()

> Source: `readme/dsl-3.0.2.md` · topic id: `dsl-3-0-2`

# DSL 3.0.2 — no raw CSS or JavaScript strings

The 2026-10-08 pass. 3.0 made the rules true for the framework's own pages; 3.0.2 removes
the last reasons to type CSS or JavaScript as a string. Everything here is additive — 3.0
code compiles unchanged (two declared types change, see the end) — and the framework's
own app now builds under a hygiene test that fails on every raw shape listed below.

The one idea behind most of it: **a class name or id is a handle, declared once, used by
all three languages.**

```java
static final Cls CARD = cls("snip-card"), COPY = cls("snip-copy");
static final Id EDITOR = id("sandbox-editor");

article(CARD, button(COPY, "Copy"))                           // HTML: class="snip-card"
stylesheet().rule(CARD, style().display(grid))                 // CSS:  .snip-card{...}
            .rule(COPY.hover(), style().color(PRIMARY))        //       .snip-copy:hover{...}
            .rule(CARD.descendant(Selector.type("pre")), ...)  //       .snip-card pre{...}
delegate(CARD, "click", COPY)                                  // JS:   the same handle
byId(EDITOR).dot("value")                                      //       document.getElementById('sandbox-editor')
```

## HTML

| Was | Now |
|---|---|
| `cls("card")` returned an `Attr`; the stylesheet repeated `".card"` and the script `".card"` | `cls("card")` returns a `jweb.Cls`: an element argument, an immutable `Selector`, and a JS target. `id("x")` returns a `jweb.Id` likewise |
| `attrs().cls("a b")` / `classes("a", when(c, "b"))` | still work; `classes(...)` also takes `Cls` parts; `div(A, B)` sets both |
| — | `cls()` with no name mints a class scoped to the declaring class (`snippetcard-1`); declare it `static final` |
| a static component could not own styles — `SnippetCard.rules(sheet)` had to be called by every page | `styled(RULES, element)`: the stylesheet rides the page's one `<style>`, deduped, like a `Template.styles()` |
| `inlineScript(script.build())` — the only way to attach a script, a String by then | `inlineScript(Action)`; better, `Template.scripts()` — now collected for every template in the render (page, layout, components), deduped by content, and delivered with fragments, streamed chunks and DOM patches, exactly like `styles()` |
| `Ref` had no short name | `jweb.Ref` is the class; `Ref.of(Id)`, `ref.asId()`, `addClass(Cls)` |

## CSS

| Was | Now |
|---|---|
| `rule(String, Style)` only — `Selector` (100 methods) had no way into a stylesheet | `rule(Selector, Style)` on `Stylesheet`, `MediaQuery`, `ContainerQuery`, `Supports`, `Rule.of`, `startingStyle`, `scope`/`to` |
| `Selector` was a mutable builder (a shared one was corrupted by its first `.hover()`) | immutable: every method returns a new selector |
| the starters `tag("pre")`, `all()` lived only in `Selectors`, whose `id`/`cls` collide with `El` | `Selector.type("pre")`, `Selector.any()`, `Selector.of(css)` — qualified, no import clash |
| `fontFamily("ui-monospace, Menlo, monospace")` | `fontFamily(uiMonospace, font("Menlo"), monospace)` — constants `serif sansSerif monospace cursive fantasy systemUi uiSerif uiSansSerif uiMonospace uiRounded emoji math`; `font("Segoe UI")` quotes when needed |
| `.prop("box-shadow", "0 1px 2px rgba(…), 0 12px 32px -16px rgba(…)")` — one shadow only | `boxShadow(shadow(0, px(1), px(2), rgba(…)), shadow(0, px(12), px(32), px(-16), rgba(…)))`, `insetShadow(…)`, `textShadow(shadow…)` |
| `calc("100vh - 50px")` | `vh(100).minus(px(50))`, `.plus()`, `.times(2)`, `.div(2)` — on every `CSSValue`; nested calcs flatten |
| `keyframes("shift")` here, `.animation("shift", …)` there — a string on both ends | `static final Keyframes SHIFT = keyframes("shift")…;` then `.animation(SHIFT, s(3), linear)` and `.add(SHIFT)`; `animationName(SHIFT)` |
| `radialGradient("circle", …)` had no typed colour stop | `stop(hex("#e2e8f0"), px(1))` |
| `.prop("text-overflow", "ellipsis")`, `.prop("overflow-wrap", "anywhere")` — the keywords were missing | `ellipsis`, `anywhere`, `revert`, `listItem`, `default_` |
| `transition(CSSValue.of("filter"), …)` | `propFilter`, `propVisibility`, `propMaxHeight`, `propBorderBottomColor` beside the existing `prop*` constants |
| 40 properties with no setter (`outlineColor`, `borderTopWidth`, `rotate`, `scale`, `translate`, `zoom`, `perspective`, …) | added, typed and String forms each. `jweb/CssSpecCoverageTest` keeps the rule true: every property in `css-properties.txt` has its camelCase setter, every keyword in `css-keywords.txt` its constant |

## JavaScript

| Was | Now |
|---|---|
| `byId("x")`, `query(".x")`, `queryAll`, `dom`, `domAll`, `delegate(".a", "click", ".b")` — String only | each takes a `Selector` (so a `Cls`/`Id` handle); `byId(Id)`, `byId(Ref)` |
| `el.querySelector(".x")`, `closest`, `matches`, `classList.add('x')` by hand | `el.querySelector(SEL)`, `el.closest(SEL)`, `el.matches(SEL)`, `el.addClass(CLS)`, `removeClass`, `toggleClass`, `hasClass` |
| `*Script.build()` returned `String` | returns `Action`; the page's `scripts()` returns it (several: `all(a, b, c)`) |

## Pages, CSRF, responses

| Was | Now |
|---|---|
| a page with a constructor argument could not be in the page table, so it lost the default layout, `pageTitle()`, `scripts()`, `beforeRender()` and was hand-wrapped `app.get(path, ctx -> new Layout("Title", new Page(ctx.query("x"))))` | `app.pages("/sandbox", req -> new SandboxPage(req.query("file")))` — `pages(path, Function<Request, Template>)` and `pages(path, Supplier<Template>)` |
| `Response.html(new Layout(…))` skipped the runtime, hydration data and action definitions (only the CSS came along) | `Response.html(template)` returns a `Response.Page`; the router renders it exactly like a bare `Template`, with the status and headers you set |
| `Csrf.tokenField(token)` with the token threaded through page constructors | `Csrf.tokenField()`, `Csrf.tokenMeta()`, `Csrf.token()` read the request in flight |
| `Middlewares.recommended()` = security headers, request ids, compression; CSRF was checked by hand per route | `recommended()` includes `csrf()`: every router POST/PUT/PATCH/DELETE must carry the token (form field `_csrf` or header `X-CSRF-TOKEN`); a hand-written `fetch` reads it from `Csrf.tokenMeta()` |
| `Template.scripts()` was emitted only for the outermost template | collected for every template, see HTML above |

## The gate

`jweb/AppDslHygieneTest` fails the build when app code (not the docs samples or the Sandbox
interpreter's starter programs) contains:

```
.rule("   .prop("   CSSValue.of(   inlineScript("   .set("on…"   .color("#…"   .background("rgba
.fontFamily("   .boxShadow("   calc("   delegate/query/dom/byId("…   .querySelector("…   unsafeRaw(   .raw(
```

A deliberate demo of a raw form says so on the line: `// raw: <why>`.

## Breaking — two declared types

`cls(...)` and `id(...)` return `Cls` and `Id` instead of `Attr`. Every call site that
passes them as element arguments is unchanged; a variable declared as `Attr x = cls("a")`
becomes `Cls x = cls("a")` (two tests in this repo, nothing in the app). `Attr.name()` on
one of those gave the attribute name (`"class"`); `Cls.name()` gives the class name.
`Selector` methods return a new instance, so code that relied on in-place mutation of a
selector variable (none in this repo) must use the returned value.

`com.osmig.Jweb.framework.ref.Ref` is a deprecated alias of `jweb.Ref`; its factories
return `jweb.Ref`, so declare the variable as `jweb.Ref`.

<!-- ==================== END dsl-3-0-2 ==================== -->


<!-- ==================== BEGIN migrating-to-2 ==================== -->

# Migrating to 2.x — what changed, and what breaks silently

> Source: `readme/dsl-simplification.md` · topic id: `migrating-to-2`

# DSL Simplification — what changed

The 2026-08-30 pass over the HTML, CSS and JavaScript DSLs. The goal was parity with the
platform (anything you can write in HTML/CSS/JS you can write here) using names the
platform already taught you, and removing the places where the DSL guessed at your intent.

Nothing was deleted without either a replacement or a compile error pointing at one. Old
names still work unless listed under **Breaking** below.

**216 tests pass** (107 before this pass; 109 added).

---

## The rules the DSL now follows

These are the whole pass in six lines. Everything below is a consequence of one of them.

1. **A lone String child is text.** `a("Home")` renders `<a>Home</a>`.
2. **Every element is `name(Object... attributesAndChildren)`.** Attributes and children mix
   in any order, in one call.
3. **Every CSS property takes a plain String as well as a typed value.**
   `cursor("copy")` and `cursor(pointer)` both work.
4. **A Java keyword gets a trailing underscore, and nothing else does.** `if_`, `return_`.
5. **A block takes its body inline.** No `endFor()`, `endWhile()`, `endTry()`, `endSwitch()`.
6. **Platform names win.** `writeText`, `getRandomValues`, `getItem`, `fillStyle`,
   `pushState(state, url)`.

---

## HTML

| Was | Now |
|---|---|
| `a("Home")` → `href="Home"` | `a("Home")` → `<a>Home</a>`; use `a(href("/"), "Home")` |
| `label("Email:")` → `for="Email:"` | text (same for `blockquote`, `q`, `datalist`, `optgroup`) |
| `img(...)` had no varargs form | `img(src("x"), alt("y"), loading("lazy"))` |
| `div(attrs()…, children)` needed an `(Attributes, …)` overload | one `(Object...)` form per element |
| `attrs().style().color(...)` chained ~87 shim methods | `attrs().style(s -> s.color(...))` or `.done()` |
| `class_(cond, "x")` / `addClass(cond, "x")` / `classIf("x", cond)` | `classIf("x", cond)`, `classToggle(cond, a, b)` |
| `attrs().flexCenter()` overwrote the style attribute | merges; and is deprecated toward `style(s -> s.flexCenter())` |
| `Elements.link(href, text)` built an `<a>` | deleted — `a(href, text)` |
| `fetchPriority`, `popoverTarget`, `minLength` | `fetchpriority`, `popovertarget`, `minlength` (exact HTML spelling) |
| an unrecognized child rendered via `toString()` | throws, naming the type |
| `Style` inside an `Iterable` child was dropped | applied |
| `Input`/`Button` were dead ends | `.attr/.data/.aria/.toTag()`, and `Button.onClick` |
| `Elements.radio` → id `a-b`, `Input.radio` → id `a_b` | both `a-b` |

Deprecated composites (each saved about one attribute): `responsiveImg`, `lazyImg`,
`progressIndeterminate`, `timeWithDatetime`, `autoPopover`, `manualPopover`,
`popover*Button`, `lazyLoad`, `eagerLoad`, `submitButton`, `resetButton`, `strokeRound`,
`lineIcon`, `optgroupDisabled`, `fieldsetDisabled`.

Kept, because they save real boilerplate: `metaCharset`, `metaViewport`, `css(href)`,
`icon`, `appleIcon`, `targetBlank`, `option(v,t)`, `meter(v,min,max)`, `progress(v,max)`,
`abbr(text,title)`, and the Dialog/Details helpers.

## CSS

- **Every property takes a String** — 285 overloads added. The invented constants
  (`bgCover`, `selectNone`, `copyCursor`, `filterOpacity`, …) are now optional.
- `prop(name, value)` is the escape hatch again; `unsafeProp` is the deprecated one.
- The six **string-property modules** (`CSSAnchorPositioning`, `CSSLogicalProperties`,
  `CSSMasking`, `CSSScrollSnap`, `CSSSubgrid`, `CSSTextWrap`) returned `"prop:value"`
  strings that had to be wrapped in `.prop(...)` and couldn't take typed units. Deprecated;
  every property is a real `Style` method.
- `snapPadding`/`snapMargin` named the wrong CSS property → `scrollPadding`/`scrollMargin`.
- **29 animation presets deleted**: `fadeInUp`, `flipX`, `jello`, `tada`, `rubberBand` and
  friends had no `@keyframes` behind them, so they animated nothing. The 11 backed ones
  remain, and `Keyframes` ships their keyframes.
- One `Rule` record for `@media` / `@container` / `@supports` / `@scope`; `MediaQuery.and()`
  (a documented no-op) deleted.
- Duplicates deleted: `columnGapMulti` (same property as `columnGap`), `lightDark`, `env`,
  `colorMix`, `sequenceAnimations`, `timingSteps`, `animName`.
- Units added: `pt`, `cm`, `mm`, `q`, `inch`, `dpi`, `dppx`, `ms(double)`.
- Deprecated as opinion rather than parity: `Utility` (whose `hover:`/`dark:`/responsive
  builders emitted class names with no matching CSS), `Theme.preset()`, the shadow and
  radius scales, `mobile`/`tablet`/`desktop` (a second breakpoint set overlapping
  `xs`–`xxl`), the 17 `supportsX()` one-liners, `lighten`/`darken`, `CSSNested.block()`.

Bugs fixed: `::placeholder` was emitted as the invalid `:placeholder`; the BEM builder
emitted `&__header`, which native CSS nesting reads as a type selector; `retina()` used the
legacy `-webkit-min-device-pixel-ratio`; `AnimationBuilder.timeline()` was silently dropped;
`Style.toMap()` returned an unordered map, making declaration order inside
`@media`/`@container`/`@keyframes` non-deterministic — and in CSS order decides the winner.

## JavaScript

| Was | Now |
|---|---|
| `variable("x")`, `getElem("x")` | `v("x")`, `byId("x")` |
| `.ret(x)` | `.return_(x)` |
| `for_(...).body(...).endFor()` | `for_("i", 0, n, stmts...)` — same for while/forOf/forIn/doWhile |
| `try_().body(...).catch_("e").body(...).endTry()` | `try_().body(...).catch_("e", stmts...)` |
| `switch_(v).case_("a").then_(...).endSwitch()` | `.case_("a", stmts...)`, `.default_(stmts...)` |
| `setTextExpr`, `jsonExpr`, `bearerExpr`, `assignVar`… | one `Val` overload each |
| `Actions.query/queryAll/script` | `dom/domAll/actions` (the old names collided with `Js.*`) |
| `whenVar("x").equals("y")` | `.eq("y")` / `.neq("y")` — `equals` shadowed `Object.equals` |
| `debounce("timerVar", 300)` | `debounce(300, fn)` — a closure, no variable to name |
| `script().withHelpers()` | `actions()` — helpers are injected automatically |
| `setFillStyle(ctx, c)` / `getFillStyle(ctx)` | `fillStyle(ctx, c)` / `fillStyle(ctx)`; plus chainable `ctx2d(canvas)` |
| a bare String meant getElementById *or* querySelector | pass `byId("x")` or `query(".sel")` |
| `Async.promiseAll`, `Events.pushState`, `JSUrl.currentUrl` | `JSPromise`, `JSHistory`; `JSUrl.currentUrlObject()` |

Bugs fixed: `JSWebSocket.autoReconnect` rebuilt the socket without re-attaching any handler,
so every reconnected socket was deaf; `JSAnimation.transition` used one spelling for both
the JS style key and the CSS shorthand, so one half was always invalid;
`JSHistory.detectDirection` compared `history.length` across popstate, which never changes,
so it always answered "forward" (deleted); `PromiseExecutorBuilder.setTimeout` was an empty
method (deleted); an animation with no keyframes ran `element.animate([], …)` (now throws);
`JSGeolocation`'s builder threw at runtime for the wrong entry point (now a compile error);
`JSRegex`'s 24 shared constants were reassignable.

Widget-level builders in `Actions` are deprecated as outside HTML/JS parity: `alertModal`,
`showModalHtml`, `confirmDialog`, `statusFeedback`, `colorSwitch`, `tabs`, `renderList`, the
`template`/`field`/`dateField` engine, `externalService`, `hideOnBackdropClick`.

---

## Builder style

The DSL supports two styles and both reach the whole surface. Function style puts
attributes and children in one call; builder style chains from an empty element:

```java
div(class_("card"), id("main"), p(text("Body")))     // function style
div().class_("card").id("main").child(p().text("Body"))   // builder style
```

Builder style used to dead-end — `Tag` had 53 fluent methods against `Attributes`' 197, so
`rel`, `tabindex`, `colspan`, `accept`, `targetBlank` and ~40 event handlers forced you back
into varargs. Both classes now implement `HtmlAttributes<SELF>`, one interface whose default
methods are built on a single `set(name, value)`. One definition, and a chain keeps its
concrete type without casts.

The same contract holds elsewhere and is pinned by tests: CSS chains keep their
`StyleBuilder` type through typed *and* String values, every CSS builder ends in `build()`,
and a whole JS script — `const`, `function`, `let`, `if`, `for-of`, `return` — is one
unbroken chain. Three builders that could be chained but not finished from outside their
package (`Async.AsyncFunc`, `JSWebAnimations.KeyframeBuilder`, and earlier `JS.Func`) are
now public, and `JSPerformance`'s off-convention `buildCode`/`create` terminals point at
`build()` and `buildWithoutObserving()`.

## Breaking

Three changes are not compile errors, so they are worth checking for by hand:

1. **`textarea("hello")`** now renders text; it used to set `name="hello"`. Use
   `textarea(name("bio"))`.
2. **`JSHistory.pushState(Val, Val)`** now reads `(state, url)`, matching the platform. The
   `(String url, Val state)` and 3-argument forms are deleted, so those calls *do* fail to
   compile.
3. **29 CSS animation presets are deleted** — but they never animated anything, so the
   visible behaviour is unchanged.

Everything else either still compiles with a deprecation warning, or fails loudly.

The release as a whole is **source-compatible but not binary-compatible** — 139 element
overloads were deleted and the attribute surface moved to an interface, so downstream code
must be recompiled against the new jar rather than dropped in beside the old one.

## Deliberately not done

- **Raw-String twins for ~40 JS handlers.** Each would bake in a callback parameter name the
  caller cannot see, so a wrong guess produces silently broken JS. The rule is: handlers take
  a `Func`; for raw JS use `callback("e").unsafeRaw(code)`, which puts the parameter name
  where the code that uses it is written.
- **Expanding the top-level `Attr` statics to all ~150 attributes** (cross-import collision
  risk), **thinning `Tag`'s fluent surface**, and **removing the `swap*`/`ref` framework
  attributes** — those are jweb's server-driven-UI feature, not an HTML-parity gap.

<!-- ==================== END migrating-to-2 ==================== -->


<!-- ==================== BEGIN why-jweb ==================== -->

# Why JWeb, and the honest trade-offs

> Source: `readme/guides/why-jweb.md` · topic id: `why-jweb`

[← Back to README](./../README.md)

# Why JWeb?

*How JWeb compares to the 2026 frontend landscape — grounded in
[the framework research](./research.md).*

## The one-paragraph pitch

The 2026 research is blunt: for a team with a real backend, adopting a JS meta-framework
means duplicating your server in JavaScript, and the recommended alternative is the
**server-driven track** (HTMX-style). JWeb *is* that track — with the type safety HTMX
can't offer, the streaming and fragments the meta-frameworks brag about, and everything
in one language your team already knows.

## Feature parity with the leaders

| They lead with... | JWeb has it as... |
|---|---|
| Next.js streaming SSR + Suspense | `Streamed.of(() -> page)` — shell flushes instantly, blocks stream in parallel |
| HTMX fragment swaps | `attrs().swap(url, target)` / `swapForm` / `swapMorph` — typed, built in |
| idiomorph DOM morphing | `swapMorph` + morphing hot reload — focus and input state survive |
| TanStack type-safe routing | `TypedRoute.path("/users/:id", Long.class)` + `QueryParam.of("page", Integer.class)` |
| Astro zero-JS default | One small cached runtime; pages are HTML-first by design |
| View Transitions | Automatic on every swap |
| SvelteKit progressive forms | `swapForm` + native POST fallback — works with JS disabled |
| Next/Astro image optimization | `/jweb/img?src=...&w=...` — ImageIO, zero deps |
| Vercel AI SDK | `AI.ask/chat/agent` with tool loops — zero deps, any OpenAI-compatible API |
| Tailwind design tokens | `Theme` constants + `style().apply(fragment)` composition |

## What the JS stacks can't match

- **One language, one build, one deploy** — no Node toolchain, no JSON layer between
  your data and your HTML, no hydration mismatch class of bugs. `jweb build` → jar + Docker.
- **Compile-time checked everything** — elements, styles, routes, links, query params.
  The research calls HTMX "essentially untyped"; React's safety ends at the API boundary.
- **State lives on the server** — the signals the industry converged on, without
  shipping your state machine to the client.
- **~0 bundle anxiety** — React ships ~42KB before your first component; JWeb ships
  one small immutable-cached runtime and only the HTML you rendered.

## Honest trade-offs

- **Ecosystem and hiring**: React has "10 developers for every 1" of anything else, and
  the largest AI-training corpus. JWeb's counter: every Java developer is already a JWeb
  developer.
- **Rich client-side apps** (canvas editors, collaborative cursors, offline-first): a
  client-state framework is genuinely better there. JWeb's JS DSL covers a lot, but that's
  not the sweet spot.
- **Mobile**: no React Native equivalent. Capacitor-wrapping a JWeb app works for the
  WebView tier.

## The 5-minute start

```bash
# jweb = alias for: java -cp Jweb.jar com.osmig.Jweb.framework.cli.JWebCli
jweb new myapp && cd myapp
./mvnw spring-boot:run          # http://localhost:8085
jweb build                      # production jar + Dockerfile
```

<!-- ==================== END why-jweb ==================== -->


<!-- ==================== BEGIN architecture ==================== -->

# Architecture: rendering pipeline, request flow, routing, middleware

> Source: `readme/guides/architecture.md` · topic id: `architecture`

[← Back to README](./../README.md)

# Architecture

## Core Rendering System

JWeb renders HTML from a tree of `Element` objects via a Virtual DOM.

| Interface / Class | Package | Purpose |
|-----------|---------|---------|
| `Renderable` | `core/` | Base: `VNode toVNode()` |
| `Element` | `core/` | `Renderable` + `default String toHtml()`. Functional interface — lambdas like `() -> new VFragment(...)` are valid Elements |
| `Template` | `template/` | Reusable UI components: `Element render()` (plus lifecycle hook defaults — see caveat below) |
| `Component` | `core/` | Marker interface (`extends Element`, no members) |
| `Page` | `core/` | Marker interface (`extends Element`, no members) — it does **not** define `head()`/`body()` |
| `RawContent` | `core/` | Unescaped payloads: `RawContent.html(s)`, `.json(s)`, `.text(s)`, `.of(s, contentType)`; `isJson()`, `isHtml()` |
| `ErrorBoundary` | `core/` | Fluent error containment: `ErrorBoundary.of(supplier).fallback(fn).onError(consumer)`; statics `wrap`, `silent`, `withMessage` |

```
Element (interface)
    └── Template (interface) — render() returns Element
            ├── your pages/components
            └── layouts
```

## Virtual DOM

`vdom/` defines a **sealed** hierarchy:

| VNode Type | Purpose |
|------------|---------|
| `VElement` | HTML element with tag, attributes, children. Immutable; `withAttribute`/`withChild` copy-on-write |
| `VText` | Escaped text (`& < > " '` escaped — XSS-safe by default) |
| `VRaw` | Raw/unescaped HTML (use with caution) |
| `VFragment` | Groups nodes without a wrapper element |

Pipeline: `Template.render()` → `Element.toVNode()` → `VNode.toHtml()` → HTML string.

Mechanics worth knowing:

- Every DSL factory funnels through `Tag.create(name, Object... items)`. `Attr`, `Attributes`, and
  `InlineStyle` args become attributes; everything else becomes children (`VNode` as-is, `Element`
  via `toVNode()`, `String` as escaped `VText`, other objects via `toString()`). One level of
  `Iterable` is flattened.
- **Void elements** (`img`, `br`, `input`, `hr`, `meta`, `link`, …) throw
  `IllegalArgumentException` if given children.
- Escaping: `VText` and `VElement` attribute values both escape the same five characters
  (`& < > " '`), so text and attribute contexts are equally XSS-safe by default.

## Application Entry Point

```java
import org.springframework.boot.SpringApplication;

@JWebApplication  // = @SpringBootApplication + @PropertySource("classpath:jweb.yaml");
                  //   framework beans arrive via auto-configuration (JWebAutoConfiguration
                  //   carries @ComponentScan("com.osmig.Jweb.framework"))
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}
```

## Auto-Configuration Chain

1. `@JWebApplication` triggers Spring Boot + loads `jweb.yaml`; framework beans come from
   `JWebAutoConfiguration` (listed in `AutoConfiguration.imports`), which component-scans
   `com.osmig.Jweb.framework` — your app only scans its own package.
2. `JWebAutoConfiguration` creates the `JWeb` bean and calls `configure(app)` on every
   `JWebRoutes` bean in a **deterministic order**: `@Order`/`Ordered` beans first, then
   alphabetical by bean name. If middleware ordering matters across classes, use `@Order`
   (or keep a single `Routes` class).
3. `JWebConfiguration` connects MongoDB (when `jweb.data.enabled: true`) via an
   `ApplicationRunner`, exposes `Router` and `MiddlewareStack` beans, registers static
   resource handlers (`classpath:/static/`, `classpath:/public/`), applies the
   `jweb.dev.debug` / `jweb.runtime.enabled` / `jweb.ai.*` settings, and warms up page
   routes in the background at startup.
4. `JWebController` handles HTTP requests via `@RequestMapping("/**")`.

## The Three Routing Systems

JWeb has **three separate dispatch mechanisms**. Understanding which one serves a request is the
single most important thing about the architecture:

| System | Registered via | Dispatched by | Path params | Middleware applies? |
|--------|---------------|---------------|-------------|---------------------|
| **Page routes** | `app.layout(...).pages("/", HomePage.class, ...)` or `app.scanPages(pkg)` | `PageRegistry` — exact-string HashMap lookup | ❌ none | ✅ yes (GET/HEAD only; other methods → 405) |
| **Router routes** | `app.get/post/put/delete(path, handler)` | `Router` — linear scan, first match wins; HEAD served by GET | ✅ `:name` syntax | ✅ yes |
| **@REST controllers** | `@REST("/api/v1/...")` classes | **Spring MVC** (not JWeb at all) | ✅ `{name}` Spring syntax | ❌ JWeb middleware does not apply — use Spring mechanisms |

Key consequences:

- `JWebController` **hard-excludes** paths starting with `/api/v` (plus `/h2-console`, exact
  `/jweb`, and WebSocket upgrades) and returns before touching JWeb routing. `@REST` controllers
  must therefore live under `/api/v1/...` (or any `/api/v*` prefix). Conversely,
  `app.get("/api/v1/anything", ...)` on the JWeb router **silently never fires**.
- Page routes are matched by exact string only: no parameters and no trailing-slash tolerance.
  They answer GET/HEAD; other methods get a 405. Use Router routes for anything dynamic.
- Page routes, Router routes, and 404s all run through the middleware stack, so auth, CSRF,
  headers, logging, and metrics see every JWeb-dispatched request. A method mismatch on a
  Router path returns **405 with an `Allow` header** instead of 404.

## Request Flow (verified against `JWebController`)

```
HTTP Request
  → JWebController (@Controller, handles /**)
    → bypass?  path startsWith /api/v | /h2-console, path == /jweb, Upgrade: websocket
         → return (handled by Spring MVC / WebSocket instead)
    → PageRegistry.findByPath(path)          [O(1) exact match]
         → found: middleware stack runs, then instantiate page (cached no-arg ctor)
                  → render → wrap in layout
                  → inject prefetch + hydration scripts → 200 text/html
    → Router.match(method, path)             [O(n) linear scan]
         → no match: 404 ErrorPage           (middleware still executed)
         → match:  StateManager.createContext()
                   → MiddlewareStack.execute(request, handler)
                   → processResult(...)
                   → finally: context.clearContext()
```

`processResult` dispatch (in order):

1. `null` → empty 200
2. `Template` → lifecycle hooks (`beforeRender`/`render`/`afterRender`) + template extras
   + hydration injection, 200 `text/html`
3. `ResponseEntity` → returned as-is
4. `RawContent` → `application/json` if `isJson()` else `text/html`
5. `Element` → `toHtml()` + hydration injection, 200 `text/html` (plus a navigation
   `Cache-Control` header)
6. `String` → content-type sniffed: strings starting with `{` or `[` are served as
   `application/json`, everything else as `text/html`
7. anything else → serialized with `Json.stringify(result)` as `application/json` —
   POJOs become real JSON

Errors from handlers render `ErrorPage.render(500, ...)`. Stack traces are **off by
default** (production-safe); set `jweb.dev.debug: true` to show exception details during
development.

## Components and Templates

### Template Interface

```java
public class Card implements Template {
    private final String title;
    private final String content;

    public Card(String title, String content) {
        this.title = title;
        this.content = content;
    }

    @Override
    public Element render() {
        return div(class_("card"),
            h3(class_("card-title"), title),
            p(class_("card-content"), content)
        );
    }
}

// Usage — Templates are Elements, so compose freely:
div(class_("container"),
    new Card("Welcome", "Hello, World!"),
    new Card("Features", "Build apps in pure Java")
)
```

`Template` declares default lifecycle hooks — `beforeRender(Request)`, `afterRender(Request)`,
`onMount()`, `onUnmount()`, `pageTitle()`, `metaDescription()`, `extraHead()`, `scripts()`,
`cacheable()`, `cacheDuration()`.

> ✅ The controller invokes all of these for page routes and for Templates returned from
> Router handlers: `beforeRender(request)` runs before `render()`, `pageTitle()` overrides the
> layout title and `<title>`, `metaDescription()`/`extraHead()` inject into the head,
> `scripts()`/`onMount()`/`onUnmount()` (each an `Action`) inject before `</body>`, and
> `cacheable()`/`cacheDuration()` drive the Cache-Control header.

Since 3.0.2 `scripts()` is **collected** the way `styles()` is: every `Template` rendered in
a page contributes its script once (deduped by content), and the script rides swap
fragments, streamed chunks and WebSocket patches too. A component owns its behavior:

```java
public class CopyButton implements Template {
    static final Cls COPY = cls("copy");

    @Override
    public Element render() {
        return button(COPY, "Copy");
    }

    @Override
    public Optional<Action> scripts() {
        return Optional.of(actions().does(delegate(Selector.type("body"), "click", COPY)
            .handler(callback("e", "t").does(copyFrom(Selector.type("pre")).trigger(v("t"))))));
    }
}
```

### Layouts

A layout is a `Template` whose constructor accepts the page content. The controller looks for a
`(String title, Element content)` constructor first, then `(Element content)`:

```java
class Head implements Template { String title; Head(String title) { this.title = title; } public Element render() { return head(title(title)); } }
class Nav implements Template { public Element render() { return nav(); } }
class Footer implements Template { public Element render() { return footer(); } }
class HomePage implements Template { public Element render() { return div(); } }
class AboutPage implements Template { public Element render() { return div(); } }

public class Layout implements Template {
    private final String title;
    private final Element content;

    public Layout(String title, Element content) {
        this.title = title;
        this.content = content;
    }

    @Override
    public Element render() {
        return html(
            new Head(title),
            body(new Nav(), main(content), new Footer())
        );
    }
}

// ORDER MATTERS: layout() must come before pages() —
// PageRegistry captures the default layout at registration time.
app.layout(Layout.class)
   .pages("/", HomePage.class, "/about", AboutPage.class);
```

## JWeb API (route registration)

<!-- nocompile: API index — method signatures without bodies, not real statements -->
```java
JWeb app = JWeb.create();                          // done for you by auto-config

app.use(Middleware mw)                             // global middleware
app.useIf(boolean condition, Middleware mw)        // conditional registration
app.use(String pathPrefix, Middleware mw)          // plain prefix ("/api") or glob
                                                   // patterns ("/api/**", "/files/*.png")
app.useForMethods(List.of("POST","PUT"), mw)       // method-scoped

app.get(String path, Supplier<Element> page)       // simple page, no request access
app.get(String path, RouteHandler handler)         // handler: Object handle(Request)
app.post(String path, RouteHandler handler)
app.put(String path, RouteHandler handler)
app.delete(String path, RouteHandler handler)
app.route(String path, Supplier<? extends Page>)   // alias for get
app.addRoute(Route route)

app.layout(Class<? extends Template> layout)
app.pages(Object... pathsAndPages)                 // alternating "/path", PageClass.class
app.pages(String path, Function<Request, Template>) // 3.0.2: a page built from the request
app.pages(String path, Supplier<Template>)         //        or by a supplier — both keep the layout and the hooks
```

A page whose constructor needs something from the request — a query parameter, a store
lookup, a flash message — stays in the page table since 3.0.2, so it keeps the default layout,
`pageTitle()`, `styles()`, `scripts()` and `beforeRender()`:

```java
class DocsPage implements Template {
    DocsPage(String section, String version) {}
    public Element render() { return div(); }
}

public class Routes implements JWebRoutes {
    public void configure(JWeb app) {
        app.pages("/docs", req -> new DocsPage(req.query("section"), req.query("v")));
        app.pages("/about", () -> () -> div("About"));
    }
}
```

Not available (as of now): `patch()`, `head()`, `options()`, route groups. A method
mismatch on an existing path yields **405 with an `Allow` header** listing the methods
the path does support.

### Route path syntax (Router routes)

Path parameters use **colon syntax** — `{name}` braces are *not* supported here (they belong to
`@REST`/Spring routes):

```java
app.get("/users/:id", req -> { String id = req.param("id"); ... });
app.get("/files/*", handler);   // '*' segment matches anything
```

## Middleware

```java
// Functional interface: Object handle(Request request, MiddlewareChain chain)
app.use(Middlewares.logging());
app.use(Middlewares.csrf());                 // CSRF middleware lives in Middlewares, not Csrf
app.use(Middlewares.rateLimit(100, 60_000));
app.use(Middlewares.securityHeaders());

// Path-scoped: plain prefix or glob — "/api", "/api/**", "/files/*.png" all work
app.use("/api", Jwt.protect());
app.use("/admin", Auth.requireRole("admin"));

// Composition helpers
Middleware.before(req -> ...);
Middleware.after((req, result) -> ...);
Middleware.catchErrors((req, ex) -> fallbackResult);
Middleware.when(req -> req.isAjax(), mw);
```

Built-ins in `Middlewares`: `logging()`, `cors(...)` (3 overloads), `csrf(...)`,
`rateLimit(max, windowMs)`, `securityHeaders(...)`, `timing()`, `requestId()`,
`cacheControl(...)`, `noCache()`, `staticCache(...)`, `etag()`, `compressionHeaders()`.

> Header middlewares queue their headers on the `Request` (`req.responseHeader(...)`) and the
> controller applies them to the final response — so they work for `Element`, `String`, and
> `ResponseEntity` results alike, on page routes, Router routes, and 404s.

More middleware factories elsewhere: `Auth.requireAuth()/requireRole(...)/...` (security),
`Jwt.protect()/optional()`, `Cors.allowAll()/origins(...)/configure()`,
`RateLimit.perMinute(n)...build()`, `ErrorHandler.errorHandling()`, `Metrics.middleware()`,
`I18n.middleware()`.

## Request API (`server/Request.java`)

**Path parameters** (Router routes only):

```java
String id = req.param("id");                    // null if absent
String v  = req.param("id", "default");
Integer i = req.paramInt("id");                 // null on absent/bad input
int p     = req.paramInt("page", 1);            // safe with default
Long l    = req.paramLong("id");   long l2 = req.paramLong("id", 0L);
Double d  = req.paramDouble("score");
Boolean b = req.paramBool("active");            // true for "true|1|yes|on"
UUID u    = req.paramUUID("id");
Optional<String>  o1 = req.paramOpt("name");    // also paramIntOpt/paramLongOpt/paramUUIDOpt

// require* throw IllegalArgumentException when missing:
String name = req.requireParam("name");
int page    = req.requireParamInt("page");
long count  = req.requireParamLong("count");
UUID rid    = req.requireParamUUID("id");
```

**Query parameters:**

```java
String q  = req.query("q");
int page  = req.queryInt("page", 1);            // safe overload
Integer n = req.queryInt("page");               // null when missing or invalid
Long off  = req.queryLong("offset");            // same
Boolean v = req.queryBool("verbose");
Map<String, String[]> all = req.queryParams();  // note: String[] values
```

**Headers, body, form:**

```java
String ct = req.header("Content-Type");
Map<String, String> headers = req.headers();    // first value per name
String body = req.body();                       // raw body, read verbatim (cached)

// There is NO req.bodyAs(Class) — deserialize with the Json utility:
record User(String name) {}
User user = Json.parse(req.body(), User.class);

String name = req.formParam("name");
Map<String, String[]> form = req.formParams();
```

**Cookies, session, attributes:**

```java
import jakarta.servlet.http.HttpSession;

Object value = "example";
String token = req.cookie("auth_token");
Map<String, String> cookies = req.cookies();
HttpSession session = req.session();            // creates; session(false) does not
String val = req.sessionAttr("user");  req.sessionAttr("user", value);
String attr = req.attr("key");         req.attr("key", value);   // request-scoped
```

**Request/client info:**

```java
req.method(); req.path(); req.url(); req.queryString(); req.contentType();
req.ip();            // honors X-Forwarded-For
req.userAgent();
req.isAjax(); req.acceptsJson(); req.acceptsHtml();
req.raw();           // escape hatch: HttpServletRequest
```

## Response API (`server/Response.java`)

All factories return Spring `ResponseEntity` values:

```java
import org.springframework.http.HttpStatus;

Element element = div();
Template template = () -> div();
String htmlString = "<p>Hi</p>";
Object object = Map.of("key", "value");

// HTML
Response.html(element)                          // 200, rendered Element
Response.html(template)                         // 3.0.2: a Template renders as a full page —
                                                // hooks, styles, scripts, hydration, runtime —
                                                // with the status and headers you set
Response.html(htmlString)
Response.html(HttpStatus.OK, element)

// JSON
Response.json(object)                           // 200, serialized via Json.stringify
Response.json(HttpStatus.CREATED, object)       // ← use this for custom status
Response.json().put("status", "ok").put("count", 42).build()
// ⚠️ Response.json(obj).status(201) does NOT exist — pass the status as the first argument.

// Text / redirects
Response.text("plain text")
Response.redirect("/dashboard")                 // 302
Response.redirect("/new-url", true)             // 301
Response.seeOther("/other")                     // 303

// Success
Response.ok()                                   // ResponseBuilder: .header().contentType().body()/build()
Response.created("/api/users/123")              // 201 + Location
Response.noContent()                            // 204

// Errors (JSON bodies: {error:true, status, message})
Response.badRequest("Invalid input")            // 400
Response.unauthorized("Login required")         // 401
Response.forbidden("Access denied")             // 403
Response.notFound("Not found")                  // 404
Response.serverError("Internal error")          // 500
Response.error(HttpStatus.I_AM_A_TEAPOT, "..")  // any status
Response.error(418, "..")
```

## Error Handling

- `JWebException` — status-carrying runtime exception with factories: `badRequest`, `unauthorized`,
  `forbidden`, `notFound`, `notFound(resource, id)`, `conflict`, `unprocessable`,
  `tooManyRequests`, `serverError`, `serviceUnavailable` (+ optional error codes/causes).
- `ValidationException` — 422 wrapper around a `ValidationResult`.
- `ErrorResponse` — JSON error payload builder (`ErrorResponse.from(ex, path)`).
- `ErrorHandler` — converts exceptions to JSON/HTML responses; **must be registered manually**:
  `app.use(ErrorHandler.errorHandling())`. Without it, any exception becomes a generic 500 page
  (`ErrorPage`) and `JWebException` status codes are ignored.

## Health & Metrics

```java
List<Object> queue = new ArrayList<>();
void renderReport() {}

Health.register("db", () -> HealthStatus.up("connected").withDetail("latencyMs", 4));
Health.registerLiveness("app", () -> HealthStatus.up());
Health.setupEndpoints(app);      // GET /health, /health/live, /health/ready (200/503 JSON)

Metrics.counter("orders.placed").increment();
Metrics.gauge("queue.depth", queue::size);
Metrics.timer("report.render").record(() -> renderReport());
app.use(Metrics.middleware());   // http.requests.total/success/error + duration
Metrics.setupEndpoint(app);      // GET /metrics — JSON, or Prometheus with Accept: text/plain
```

Metrics middleware sees page-route and 404 traffic too (everything runs through the stack).

**Request ids.** Every request has one before anything sees it — the caller's `X-Request-ID`
when it sends one, a fresh one otherwise. `req.id()` reads it; the response carries it back;
every log line written during the request shows it (`[7f3a9c21b4d8e05f6a12]`, Spring Boot's
`logging.pattern.correlation` set by the framework — the trace id joins it when tracing is on);
the error page and the JSON error quote it, so a visitor's "it broke" and the log line are the
same id. `Middlewares.logging()` is the access log: `GET /orders/9 200 12ms`. For JSON logs,
Spring Boot's `logging.structured.format.console: ecs` — the id is a field.

**Traces.** `jweb.Trace` opens a span around every route and page render (`jweb.route`,
tagged with the pattern), every SQL statement (`jweb.sql`), every job (`jweb.job`), every
PubSub delivery, every `Mail.send`, every `Fetch` and every browser event (`jweb.event`), and
an app opens its own:

```java
import jweb.Trace;

record Order(long id) {}
Order order = new Order(9);

try (Trace.Span span = Trace.span("order.price", "kind", "retail")) {   // start tags: low cardinality
    span.tag("order.id", order.id());                                     // later tags: high cardinality
}
Order priced = Trace.in("order.price", () -> order);
```

Without a sink a span costs nothing. With the actuator the app has an `ObservationRegistry`
and every span becomes a Micrometer `Observation`: a timer per name, and with
`micrometer-tracing` plus an exporter (`micrometer-tracing-bridge-otel` and
`opentelemetry-exporter-otlp`) a span in a real trace, under the HTTP request's, with the
trace id in every log line. The reference topic `ref-observability` has the dependencies and
the full tag table.

**Micrometer.** Everything `Metrics` records is mirrored into a Micrometer `MeterRegistry`
when the application has one, so it reaches Prometheus, Datadog, OTLP or whatever the
registry exports to — the same names, every counter increment and timer sample, every gauge.
Add Spring Boot's actuator and a registry and nothing else is needed:

```xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
```

Then `/actuator/prometheus` carries `http_requests_total`, `orders_placed_total`, and the
framework's own `jweb_contexts_active` (pages holding server state right now). Bind one by
hand with `MicrometerSink.bind(registry)` (`jweb.MicrometerSink`), or keep the
two apart with `jweb.metrics.micrometer=false`. Without `micrometer-core` on the classpath
nothing changes: the built-in `/metrics` endpoint stays.

## Server-side HTTP Client (`http/Fetch`)

```java
import java.util.concurrent.CompletableFuture;

record User(String id) {}
String token = "abc123";
String url = "https://api.example.com/users";
Object payload = Map.of("name", "Ada");

FetchResult res = Fetch.get("https://api.example.com/users")
    .bearer(token)
    .timeout(Duration.ofSeconds(10))
    .send();                       // throws Fetch.FetchException on network errors

if (res.isOk()) {
    User u = res.as(User.class);
    Map<String,Object> m = res.asMap();
}
Fetch.post(url).json(payload).send();
Fetch.post(url).form(Map.of("k", "v")).send();
CompletableFuture<FetchResult> f = Fetch.get(url).sendAsync();
```

> `FetchResult.asList(User.class)` resolves the element type at runtime and returns real
> `User` instances. `res.as(new TypeReference<List<User>>() {})` also works.

## Context (`context/`)

Thread-local dependency passing without prop drilling:

```java
record User(String name) {}
class Page { Element render() { return div("Home"); } }
static final ContextKey<User> CURRENT_USER = Context.key("currentUser");
Page page = new Page();
User user = new User("Ada");
User guest = new User("Guest");

Context.provide(CURRENT_USER, user, () -> page.render());   // scoped provide
User u = Context.use(CURRENT_USER);                          // throws if absent
Optional<User> maybe = Context.find(CURRENT_USER);
User or = Context.useOrDefault(CURRENT_USER, guest);
```

> `Context` is cleared by `JWebController` at the end of every request. The scoped
> `provide(..., runnable)` forms are still the cleanest pattern, but bare writes no longer
> leak across pooled servlet threads.

## Navigation (`navigation/`)

`Link` — fluent SPA-style anchor builder rendering `data-*` attributes:

```java
Link.to("/about", "About")                          // simple (returns Element)
Link.to("/docs").text("Docs").prefetch()            // builder form (1-arg returns Link)
    .swap("#content").activeClass("active")
Link.navLink("/about", "About", req.path())         // auto 'active' class
Link.to("/ext").newTab()                            // rel="noopener noreferrer"
```

`Navigation.script()` injects the client runtime that powers those attributes: hover prefetch,
click interception, partial swaps (`innerHTML|outerHTML|beforeend|afterbegin`), View Transitions
when available, and `window.JWebNav.{navigate,prefetch,clearCache}`. It must be added to the
layout explicitly — only the simpler `Prefetch` script is auto-injected by the controller.

## Framework Structure

~247 framework files across ~47 packages, plus ~122 app files:

```
framework/
├── accessibility/      # A11y — WCAG 2.1 HTML auditor (validate/validateHtml)
├── ai/                 # AI, Chat, Agent, Tool, AiClient/AiConfig, AiChatEndpoint/Widget
├── api/                # @REST, @GET, @POST, @UPDATE, @PATCH, @DEL (Spring meta-annotations)
├── async/              # Jobs, Scheduler, BackgroundTask, Suspense, Streamed, StreamingContext
├── attributes/         # Attr (record), Attributes (fluent builder), Attrs (@Deprecated)
├── cache/              # Cache<K,V> — TTL cache with named/global instances
├── cli/                # JWebCli (scaffolding CLI), Templates (package-private)
├── config/             # JWebConfiguration — Mongo init, Router/MiddlewareStack beans, static resources
├── context/            # Context, ContextKey — thread-local DI
├── core/               # Element, Renderable, Component, Page, RawContent, ErrorBoundary
├── db/mongo/           # Mongo, Doc, Schema, MongoQuery, MongoUpdate, MongoDelete
├── dev/                # HotReload, DevController (/__jweb_dev), DevServer (file watcher)
├── docs/               # 15 internal .md reference docs (shipped in jar)
├── elements/           # HTML DSL — 24 modules (El facade, Elements, Tag, per-category modules)
├── error/              # ErrorHandler, ErrorResponse, JWebException, ValidationException
├── events/             # Event, DomEvent, EventHandler, EventRegistry (server-side events)
├── health/             # Health, HealthCheck, HealthStatus
├── http/               # Fetch, FetchResult — server-side HTTP client
├── hydration/          # HydrationData (__JWEB_DATA__), VNodeSerializer
├── i18n/               # I18n (locale resolution + middleware), Messages (in-memory bundles)
├── js/                 # JavaScript DSL — 43 modules (JS, Actions, Async, Events, JS*)
├── layout/             # Layout — ~45 static layout helpers (container/grid/stack/card/...)
├── markdown/           # Markitdown — document→markdown conversion via the markitdown CLI
├── metrics/            # Metrics — counters/gauges/timers + /metrics endpoint
├── middleware/         # Middleware, MiddlewareChain, MiddlewareStack, Middlewares
├── navigation/         # Link (SPA anchors), Navigation (client nav runtime)
├── openapi/            # OpenApi generator + @ApiDoc/@ApiParam/@ApiBody/@ApiResponse
├── performance/        # Prefetch — hover-prefetch script (auto-injected)
├── portal/             # Portal — render content into named outlets
├── ref/                # Ref — element references producing JS snippets
├── routing/            # Router, Route (:param regex), RouteHandler, TypedRoute, Query, PageRoute, PageRegistry, @Page
├── security/           # Auth, Principal, Jwt, Password, Cors, Csrf, CsrfToken, CspNonce, RateLimit, OAuth2
├── server/             # Request, Response, Cookie, JWebController, JWebAssetsController, JWebImageController, ErrorPage
├── sse/                # SseBroadcaster, SseEmitter, SseEvent
├── state/              # State<T>, StateHooks, StateBinding, StateManager, RenderableComponent
├── styles/             # CSS DSL — 29 modules (CSS facade, Style, units, colors, at-rules, Theme…)
├── template/           # Template interface
├── testing/            # JWebTest, MockRequest, MockSession, TestClient
├── transition/         # Transition (show/hide animation), TransitionBuilder (CSS transitions)
├── ui/                 # UI (buttons/cards/modals/tabs/tables…), Toast
├── upload/             # FileUpload, UploadedFile
├── util/               # Json, Log, YamlPropertySourceFactory
├── validation/         # Validator, Validators, ValidationResult, FormValidator, FieldValidator, NumberValidators
├── vdom/               # VNode (sealed), VElement, VText, VRaw, VFragment
├── websocket/          # JWebSocketConfig (/jweb), JWebSocketHandler, WebSocketMessage
├── JWeb.java           # Route/middleware registration facade
├── JWebApplication.java
├── JWebAutoConfiguration.java
└── JWebRoutes.java     # void configure(JWeb app)
```

For state, events, hydration, WebSocket, and SSE details, see
[State & Realtime](./state-and-realtime.md). For everything that is knowingly unwired or buggy,
see [Known Issues](./known-issues.md).

<!-- ==================== END architecture ==================== -->


<!-- ==================== BEGIN html-dsl ==================== -->

# HTML DSL: elements, attributes, forms, conditionals, builders

> Source: `readme/guides/html-dsl.md` · topic id: `html-dsl`

[← Back to README](./../README.md)

# HTML DSL

## Imports — one facade

```java
import static jweb.El.*;   // every element, attribute shortcut, event handler,
                           // conditional, popover, SVG element, state binding
```

One import. `jweb.El` carries every element, `attrs()` and the attribute shortcuts (`id`,
`cls`, `href`, `src`, `placeholder`, `data`, `aria`, `role`, ...), every `on*` handler, the
swap family, `ref`, `bind`, `when`/`classes`/`each`, popovers, `icon`/`appleIcon`, the
dialog and details Actions, the whole SVG element set, and `form(SomeRecord.class)`.

It coexists with the other three DSL imports — `jweb.Css.*`, `jweb.Js.*`, `jweb.Three.*` —
without an ambiguous name between them.

## Two rules cover the whole DSL

**1. Every element is `name(Object... itemsAndChildren)`.** Attributes, handlers, a bare
`style()` builder and children mix freely, in any order:

```java
void save() {}

div(cls("card"), id("main"),
    h1("Title"),
    p("Body"))

button(id("save"), onClick(e -> save()), style().padding(px(8)), "Save")

img(src("/logo.png"), alt("Logo"), loading("lazy"))
```

**2. A String argument is always text.** Wherever it appears, whatever the element:

```java
a("Home")                    // <a>Home</a>
a(href("/"), "Home")         // <a href="/">Home</a>
label(for_("email"), "Email:")
option(value("us"), "United States")
a("Hello ", strong("world")) // text, never a link to "Hello "
```

No element reads its first String as an attribute any more (`a(href, text)`,
`label(forId, text)`, `option(value, text)`, `blockquote(cite, ...)`, `datalist(id, ...)`,
`optgroup(label, ...)` and `abbr(text, title)` are gone — see
[Migrating to 3.0](./../dsl-simplification-3.md)). The exceptions are void elements, which
cannot hold text, so their Strings are their most common attributes: `img(src)`,
`img(src, alt)`, `meta(name, content)`, `input(type, name)`; and the code-bearing
`inlineScript(js)` / `style(css)`, whose String is emitted verbatim.

`text("…")` is deprecated — the bare String *is* the escaped text node. It only survives
for the rare spot that needs an `Element` value where no element factory is in reach.

> `data(name, value)` builds a `data-*` **attribute**. The rare `<data>` and `<var>`
> elements are `tag("data", value("SKU-1"), "Widget")` and `tag("var", "x")` — their
> bare names belong to the attribute and to the CSS DSL's `var(...)`.

## Two styles, one surface

```java
// Function style — attributes, handlers and children in one call
div(cls("card"), id("main"),
    h1("Title"),
    p("Body"))

// Builder style — chain from an empty element
div().cls("card").id("main")
     .child(h1().text("Title"))
     .child(p().text("Body"))
```

They produce identical HTML. Function style reads better when the structure is the point;
builder style when the configuration is. `Tag` and `Attributes` share one definition of the
attribute surface (the `HtmlAttributes` interface, built on a single `set(name, value)`),
so anything you can set on one you can set on the other, and a chain keeps its exact type:

```java
Tag row = td().colspan(2).cls("num").text("42");
Attributes a = attrs().rel("noopener").tabindex(1).targetBlank();
```

## Handlers are arguments

Every event handler `Attributes` accepts is also a plain element argument, in both
flavors, so `attrs()` is never needed just to attach one:

```java
// Server-side handler (Consumer<Event>) — runs in Java over the WebSocket
button(onClick(e -> counter.update(n -> n + 1)), "Increment")

// Client-side Action — runs in the browser, no round trip
import static jweb.Js.*;
button(onClick(toggle("panel")), "Toggle")
button(onClick(showModal("confirm-dialog")), "Open")     // DialogHelper returns Actions
button(onClick(inputRef.focus()), "Focus")               // so does Ref
button(onClick(Toast.success("Saved!")), "Save")         // and Toast

// Any event type
div(on("pointerdown", e -> start(e)), ...)
```

Available for both forms: `onClick`, `onChange`, `onInput`, `onSubmit`, `onFocus`,
`onBlur`, `onKeyDown`, `onKeyUp`, `onMouseEnter`, `onMouseLeave`, `onDoubleClick`; the
Consumer form also covers mouse/drag/touch/scroll/animation/clipboard events, and
`on(type, handler)` takes anything.

Every form is CSP-safe. Inside a page render no handler writes an inline `on<type>=`
attribute (a nonce CSP can never allow those): server handlers render
`data-jweb-on<type>`, Actions render `data-jweb-act<type>`, and the serializer rewrites
even raw `attrs().set("on<type>", js)` strings to the same delegation at render time. Handler
JS travels in a nonce-stamped definitions script — with the page, its streamed chunk, or
inside a swapped fragment. Outside a render context (bare `toHtml()`, static export)
everything falls back to classic inline attributes; `attrs().inlineHandlers()` forces that
per element for content that ships without the runtime.

## Server-driven UI as arguments

The swap family, refs and state bindings are arguments too — the whole
"fragment over the wire" story without `attrs()`:

```java
button(swap("/products/list?page=2", "#products"), swapPush("/products?page=2"), "Next")
form(id("contact-form"), action("/contact/submit"), method("post"),   // no-JS fallback
     swapForm("/contact/submit", "#form-status"),                      // progressive swap
     ...)

Ref search = Ref.create();
input(ref(search), type("text"))

State<Integer> clicks = useState(0);
p("Clicks: ", span(bind(clicks), clicks.get()))    // patched live on every change
```

## Attributes

`Attr` is a `record(name, value)` with the common shortcuts; `Attributes` is the fluent
builder returned by `attrs()`, for the long tail and for chaining several unusual ones:

```java
div(cls("card"), id("main"), data("user-id", "123"), aria("label", "User card"), ...)

div(attrs()
    .rel("noopener").tabindex(1)
    .classIf(isActive, "active")            // condition first, like when(...)
    .classToggle(isOpen, "open", "closed")  // one or the other
    .set("custom-attr", "v"),               // any attribute
    content)
```

`Attributes` covers essentially every HTML attribute: validation (`pattern`, `min`,
`max`, `step`, `minlength`, `maxlength`, `autocomplete`, `inputmode`), global (`tabindex`,
`lang`, `dir`, `title`, `contenteditable()`, `draggable`, `inert()`, `popover(type)`,
`popovertarget(id)`), link (`rel`, `download()`, `crossorigin`, `integrity`), media
(`srcset`, `sizes`, `loading`, `controls()`, `autoplay()`, `muted()`, `poster`), script
(`async()`, `defer()`, `nonce`), table (`colspan`, `rowspan`), SVG, microdata,
dialog/details (`open()`), iframe (`sandbox`, `allow`).

Attribute names use exact HTML spelling (`fetchpriority`, `popovertarget`, `minlength`).
A trailing underscore marks a Java keyword and nothing else: `class_`, `for_`.

Four attributes have no free static name because an element already owns it, so they live
on `attrs()` only: **`title`**, **`label`**, **`cite`** and **`style`**.

```java
button(attrs().title("Save"), "Save")        // the tooltip
button(title("Save"), "Save")                // NOT the tooltip — nests a <title> element
optgroup(attrs().label("Swedish Cars"), option(value("volvo"), "Volvo"))
blockquote(attrs().cite("https://example.com/source"), p("Quoted"))
div(style().padding(px(8)), "hi")            // the style builder is its own argument
```

`selected()` and `enctype(...)` are free statics, like `required()` and `checked()`.

`title(...)` is the `<title>` **element**. `button(title("x"), ...)` compiles and silently
nests a document title inside the button; the renderer logs a warning when a `<title>`
turns up outside a head or SVG parent.

### Class names

`cls("a b")` is the everyday spelling; `class_` is the same attribute under the
trailing-underscore keyword rule. `classes(...)` joins its parts and drops the ones that
did not match, so a class list is never built with `+` and a ternary:

```java
boolean active = true;

div(cls("card"))

String size = "lg";
div(classes("chip", when(active, "chip-on"), size))     // class="chip chip-on lg"
div(classes("chip", when(false, "chip-on"), size))      // class="chip lg"
div(attrs().cls("chip").classIf(active, "chip-on"))     // the same, on an attrs() chain
```

Since 3.0.2 `cls("card")` returns a `jweb.Cls` and `id("x")` a `jweb.Id`: a handle that is
also a selector and a JavaScript target, so a stylesheet rule and a script name the same
thing the element does. Declare it once and pass it everywhere:

```java
Cls chip = cls("chip");
Cls on = cls("chip-on");
boolean active = true;

div(chip, when(active, on))                              // class="chip chip-on"
div(classes(chip, when(active, on)))                     // the same, joined
div(attrs().cls(chip).classIf(active, on))               // on an attrs() chain
stylesheet().rule(chip.hover(), style().opacity(0.9));   // .chip:hover
dom(chip).addClass("seen");                              // the JS DSL
```

### Inline styles

```java
div(style().padding(SP_4).color(TEXT), "hi")              // bare builder as an argument
div(cls("card"), id("hero"), style().margin(zero), p("content"))

div(attrs().cls("card").style(s -> s.display(flex).gap(rem(1))).id("main"), ...)  // in a chain
```

There is no `attrs().style()` starter and no `.done()`: a style is either its own element
argument or a lambda inside an `attrs()` chain.

### Builder shortcuts that replace quoted strings

```java
metaCharset()                      // <meta charset="UTF-8">
metaViewport()                     // the standard responsive viewport tag
css("/app.css")                    // <link rel="stylesheet" href="/app.css">

// SVG line icons (viewBox from ints)
svg(viewBox(0, 0, 24, 24), attrs().width(24).height(24),
    path(d("M9 21H5a2...")))
```

## SVG

The whole SVG element set is on `jweb.El`:

```
svg  g  defs  symbol  use  path  rect  circle  ellipse  line  polyline  polygon
pattern  filter  mask  clipPath  image  stop
svgText  tspan  textPath
svgLinearGradient  svgRadialGradient
animate  animateTransform  animateMotion
feGaussianBlur  feDropShadow  feColorMatrix
```

Three carry an `svg` prefix because the plain name is taken by something authors reach
for more often under the four wildcard imports: `text(...)` is the text node, and
`linearGradient(...)` / `radialGradient(...)` are CSS values.

```java
svg(viewBox(0, 0, 100, 50),
    defs(svgLinearGradient(id("fade"),
        stop(attrs().set("offset", "0%").set("stop-color", "#6366f1")),
        stop(attrs().set("offset", "100%").set("stop-color", "#ec4899")))),
    rect(attrs().width(100).height(50), fill("url(#fade)")),
    svgText(attrs().x("50").y("30").set("text-anchor", "middle"), "JWeb"))
```

## Forms: a record is the form

There is one form system, `jweb.Form`, and it starts from a record. The record declares
the fields and their rules; the same record renders the form, validates the submission,
and comes back as a typed value.

```java
public record Contact(
    @Form.Required String name,
    @Form.Required @Form.Email String email,
    @Form.Required @Form.Multiline(rows = 4) String message) {}
```

```java
record Contact(
    @Form.Required String name,
    @Form.Required @Form.Email String email,
    @Form.Required @Form.Multiline(rows = 4) String message) {}

form(Contact.class)
    .id("contact-form")
    .action("/contact/submit")                    // works with JavaScript off
    .swapForm("/contact/submit", "#form-status")  // progressive fragment swap
    .field("email", f -> f.placeholder("you@example.com").autocomplete("email"))
    .submit("Send message")
```

That renders a labelled control per component, the CSRF hidden field taken from the
current request (no token passing), and a submit button.

The Java type picks the control:

| component type | control |
| --- | --- |
| `String` | `<input type="text">` |
| `@Form.Email String` | `<input type="email">` + email validation |
| `@Form.Password String` | `<input type="password">` |
| `@Form.Multiline String` | `<textarea>` |
| `int`, `long`, `Integer`, `Long` | `<input type="number">` |
| `double`, `float`, `BigDecimal` | `<input type="number" step="any">` |
| `boolean`, `Boolean` | `<input type="checkbox">` |
| an enum | `<select>` over its constants |
| `LocalDate` / `LocalTime` / `LocalDateTime` | `date` / `time` / `datetime-local` |
| `UploadedFile` | `<input type="file">`, and the form becomes `multipart/form-data` |

Hints: `@Form.Required`, `@Form.Email`, `@Form.Password`, `@Form.Multiline(rows = 4)`,
`@Form.Label("Your email")`, `@Form.Length(min = 3, max = 40)`.

The token comes from the request being handled, so a page that renders a form takes no
arguments. Pass one explicitly with `.csrf(token)` when the form is built outside a
request.

### Binding and errors

```java
record Contact(
    @Form.Required String name,
    @Form.Required @Form.Email String email,
    @Form.Required @Form.Multiline(rows = 4) String message) {}
class ContactStore { void save(Contact c) {} }
ContactStore store = new ContactStore();

app.post("/contact/submit", req -> {
    Form.Bound<Contact> submitted = Form.bind(Contact.class, req);
    if (!submitted.ok()) {
        return form(Contact.class)
            .action("/contact/submit")
            .errors(submitted)              // messages + the values the user typed
            .submit("Send message");
    }
    store.save(submitted.value());
    return p("Thanks!");
});
```

`Bound` carries `value()` (the record, or `null`), `errors()` (a `ValidationResult` keyed
by component name), `ok()` and `submitted()` (the raw input). `errors(...)` re-renders
every field with its message, `aria-invalid` on the control, and a summary above the form.
`Form.validate(Contact.class, values)` is the same validation without building the record.

`field(name, f -> ...)` only changes presentation — `label`, `placeholder`, `help`,
`type`, `rows`, `accept`, `autocomplete`, `options`. The rules stay on the record, so the
browser and the server cannot disagree about them.

### Styling

The form emits stable class names: `jweb-form`, `jweb-field`, `jweb-label`,
`jweb-control`, `jweb-help`, `jweb-error`, `jweb-errors`, `jweb-submit`. `Form.styles()`
is a ready-made stylesheet for them — put it in the head once and override what you like.

## Tag instance API

Every element factory returns a `Tag`, which is itself fluent:

```java
record Item(String name) {}
class ItemView implements Template {
    ItemView(Item item) {}
    public Element render() { return li(); }
}
List<Item> list = List.of(new Item("Widget"));
List<Item> users = list;
boolean isAdmin = true;
Tag adminBadge() { return span("Admin"); }

div()
    .addClass("card")
    .data("id", "42")
    .child(h2("Title"))
    .children(list.stream().map(ItemView::new).toList())
    .each(users, u -> li(u.name()))          // iterate on the instance
    .when(isAdmin, () -> adminBadge())       // conditional child
```

For per-element pseudo-classes, put them on the style instead —
`div(style().padding(px(16)).hover(style().backgroundColor(hex("#f5f5f5"))))` generates a
content-hashed class and puts the rule in the page's stylesheet (see CSS DSL doc). The old
`Tag.styled()/.hover()/.focus()/.active()`, which emitted a `<style>` block next to the
element under a counter-based `jweb-N` class, are gone in 3.0.

## Templates are elements

A `Template` is an `Element`, so components drop straight into a tree — no `.render()`:

```java
class Head implements Template {
    Head(String title) {}
    public Element render() { return tag("head"); }
}
class Nav implements Template { public Element render() { return nav(); } }
class Footer implements Template { public Element render() { return footer(); } }
String title = "Page";
Element content = div();

html(new Head(title), body(new Nav(), main(content), new Footer()))
```

## Modern HTML5 Elements

```java
// Dialog (modal) — the openDialog/closeDialog family on El returns Actions
dialog(id("confirm-dialog"),
    h2("Confirm Action"),
    p("Are you sure?"),
    button(onClick(closeDialog("confirm-dialog")), "Cancel"),
    button(onClick(closeDialog("confirm-dialog", "confirmed")), "Confirm")
)
button(onClick(openDialog("confirm-dialog")), "Open Dialog")

// Details/Summary — name attribute creates an exclusive accordion
details(name("faq"), summary("Question 1"), p("Answer 1"))
details(name("faq"), summary("Question 2"), p("Answer 2"))

// Progress and Meter
progress(70, 100)          // determinate
progress()                 // loading state (indeterminate)
meter(0.6, 0, 1)           // scalar measurement

// Machine-readable values
time(datetime("2026-08-08"), "August 8, 2026")
tag("data", value("SKU-123"), "Product Widget")
```

`<dialog>`: `openDialog(id)` (modal), `closeDialog(id)`, `closeDialog(id, returnValue)`,
`toggleDialog(id)`. `<details>`: `openDetails(id)`, `closeDetails(id)`,
`toggleDetails(id)`. All seven are Actions on `jweb.El` — the name says `Dialog`/`Details`
because `jweb.Js` already owns `show`, `hide`, `toggle` and `showModal` for plain elements.

## Popover API

```java
div(popover("auto"), id("tips"), p("Closes when clicking outside"))
div(popover("manual"), id("pinned"), p("Stays until explicitly closed"))
button(popovertarget("tips"), "Toggle tips")
button(popovertarget("tips"), popovertargetaction("show"), "Show")

// Actions, for when a button isn't the trigger — a selective import: the
// module's own popover(String) would otherwise collide with jweb.El's
import static jweb.el.PopoverElements.showPopover;
import static jweb.el.PopoverElements.hidePopover;
div(onMouseEnter(showPopover("tips")), onMouseLeave(hidePopover("tips")), "Hover me")
```

## Responsive Images

```java
picture(
    source(srcset("image.avif"), type("image/avif")),
    source(srcset("image.webp"), type("image/webp")),
    img("image.jpg", "Fallback description")
)

img(src("image.jpg"), alt("Description"), srcset("image@2x.jpg 2x"))
img(src("image.jpg"), alt("Description"), attrs().width(640).height(480).loading("lazy"))
```

The `media`/`sizes` attribute factories live in `jweb.el.PictureElements`.

## Definition Lists / Figures / Interactive Text

```java
dl(
    dt("HTML"), dd("HyperText Markup Language"),
    dt("CSS"),  dd("Cascading Style Sheets")
)

figure(cls("code-example"),
    pre(code("const x = 42;")),
    figcaption("Example: Variable declaration")
)

p("The ", abbr(attr("title", "HyperText Markup Language"), "HTML"), " specification")
p("Press ", kbd("Ctrl"), "+", kbd("C"), " to copy.")
p("Search results for: ", mark("JWeb framework"))
p("H", sub("2"), "O")
p("E = mc", sup("2"))
p(del("old price: $20"), " ", ins("new price: $15"))
blockquote(attrs().cite("https://example.com/source"), p("Quoted text with a cite URL"))
```

## Form markup outside a record

When the fields are not a record — a search box, a filter bar, a datalist — the form
elements are elements like any other.

```java
input(attrs().list("browsers")),
datalist(id("browsers"),
    option("Chrome"),                  // text — the browser uses it as the value too
    option("Firefox"),
    option("Safari")
)

select(name("car"),
    optgroup(attrs().label("Swedish Cars"),
        option(value("volvo"), "Volvo"),
        option(value("saab"), "Saab")),
    optgroup(attrs().label("German Cars"),
        option(value("bmw"), "BMW"),
        option(value("audi"), "Audi"))
)

fieldset(
    legend("Personal Information"),
    label(for_("name"), "Name:"),
    input(type("text"), name("name"), id("name"))
)

// Every other input is the element with its attributes — there is no second spelling
input(type("color"), name("theme-color"), id("theme-color"), value("#3b82f6"))
input(type("date"), name("event"), id("event"), attrs().min("2026-01-01").max("2026-12-31"))
input(type("range"), name("volume"), id("volume"), attrs().min(0).max(100).value(50))
```

## Conditional Rendering

`when(condition, element)` renders the element or nothing. `when(condition, ifTrue,
ifFalse)` is the same call one argument longer — so the predicate is never written twice,
negated. Branches take an `Element`, a `Supplier` lambda (only the taken branch is built),
or a String (text):

```java
class User { String getName() { return "Ada"; } }
boolean isLoggedIn = true;
User user = new User();
int unread = 3;
Element userMenu() { return div(); }
Element loginButton() { return button("Log in"); }

when(isLoggedIn, () -> span("Welcome, " + user.getName()))   // one branch
when(isLoggedIn, userMenu(), loginButton())                  // two
when(unread > 0, unread + " new", "All caught up")           // Strings are text
```

When a branch is long enough that the argument list stops reading, the chain says the same
thing down the page. A chain without `otherwise(...)` is still an element:

```java
boolean active = true;
String name = "java";

when(active)
    .then(span(cls("chip chip-on"), name))
    .otherwise(a(href("/tag/" + name), cls("chip"), name))
```

Several branches are what Java's `switch` expression is for:

```java
enum Role { ADMIN, MODERATOR, GUEST }
Role role = Role.GUEST;
Element adminPanel() { return div("Admin"); }
Element modPanel() { return div("Moderator"); }
Element guestPanel() { return div("Guest"); }

Element panel = switch (role) {
    case ADMIN -> adminPanel();
    case MODERATOR -> modPanel();
    default -> guestPanel();
};
```

`null` children render nothing, so a conditional branch never needs a placeholder.
`match(cond(...), otherwise(...))`, the `.elif(...)` chain and `Tag.ifElse` are gone in
3.0 — see [Migrating to 3.0](./../dsl-simplification-3.md).

## Collection Iteration & Fragments

```java
class ListUser {
    String getName() { return "Ada"; }
    String getEmail() { return "ada@example.com"; }
}
List<ListUser> users = List.of(new ListUser());

ul(each(users, user ->
    li(cls("user-item"),
        strong(user.getName()),
        span(" - " + user.getEmail()))
))

fragment(
    h1("Title"),
    p("First paragraph"),
    p("Second paragraph")
)
```

## Error Boundaries

```java
class RiskyComponent { Element render() { return div(); } }
RiskyComponent riskyComponent = new RiskyComponent();

errorBoundary(() -> riskyComponent.render(),
              error -> p("Error: " + error.getMessage()))
tryCatch(() -> riskyComponent.render())   // silent empty fallback

import jweb.ErrorBoundary;
ErrorBoundary.of((Supplier<Element>) () -> riskyComponent.render())
    .fallback(err -> div(cls("error"), p(err.getMessage())))
    .onError(err -> System.err.println("render failed: " + err.getMessage()));
```

## Layout mixins (`jweb.Css`)

The old `jweb.Layout` — 45 element-returning wrappers nothing used — is gone in 3.0.
Layout is a style, not an element, so it composes with everything else a style does:

```java
div(row(SP_4), a, b)                     // flex, centred, gapped
div(stack(SP_4).maxWidth(px(500)), …)
div(center().flex(1), …)
nav(cluster(SP_2).justifyContent(spaceBetween), …)
div(container(px(1200)).padding(SP_8, GUTTER), …)
div(autoGrid(px(240), SP_4), cards)      // as many columns as fit
span(truncate(2), longText)
span(srOnly(), "Skip to content")
img(src("/hero.jpg"), cover())
```

See [the CSS DSL](./css-dsl.md#layout-mixins) for the full list.

## Raw content & custom tags

```java
raw("<b>trusted html</b>")           // no escaping, use with care
                                     // (a bare String is already escaped text;
                                     //  text("…") is deprecated)
tag("custom-element", attrs().set("prop", "x"), span("child"))

// Full-response raw payloads (from route handlers):
Response.ok().contentType("application/json").body("{\"ok\":true}")   // application/json response
Response.html("<h1>hi</h1>")                                          // text/html response
```

<!-- ==================== END html-dsl ==================== -->


<!-- ==================== BEGIN css-dsl ==================== -->

# CSS DSL: inline styles, rules, units, colors, at-rules, themes

> Source: `readme/guides/css-dsl.md` · topic id: `css-dsl`

[← Back to README](./../README.md)

# CSS DSL

The core is `jweb.Style<T>` — the fluent property builder, ~400 methods, and the carrier for
pseudo-classes and media blocks too — plus the `jweb.Css` facade, which is one import for
keyword constants, units, colours, functions, [layout mixins](#layout-mixins) and the
modern-CSS modules. Everything that can appear on the right-hand side of a declaration
implements the one-method interface `CSSValue`; a component's class rules come back from
its [`styles()` hook](#page-owned-stylesheets); design tokens live in
[`jweb.css.Theme`](#theme--the-token-system).

## Imports

```java
import static jweb.Css.*;   // style(), rule(), units, colors, grid, animations,
                            // variables, media(), keyframes(), stylesheet()
import jweb.Style;          // the type your style helpers return
```

`jweb.Css` is the whole property/value/at-rule surface in one import. It aggregates
the property builder and keyword constants, units, colors, grid and animations, the
`var()`/`env()` references, the `media()`/`keyframes()`/`stylesheet()` factories, the
[layout mixins](#layout-mixins), and — new in 3.0 — the ten "modern CSS" modules that
used to need an import each: anchor positioning, text wrap, subgrid, logical properties,
masking, scroll snap, cascade layers, `@scope`, nesting and `@property`. Duplicate names
across the old modules (`colorMix`, `lightDark`, `var`, `env`) are resolved inside the
facade.

Builder **types** live at `jweb.css.*`: `Stylesheet`, `Theme`, `MediaQuery`,
`ContainerQuery`, `Keyframes`, `FontFace`, `Supports`, `Rule`, `Selector`. Two things
still want their own static import, because their starters would collide:

```java
import static jweb.css.Selectors.*;   // select() all() tag() cls() id() — id() is El's
import jweb.css.Theme;                // the token store; Theme.color("primary")
```

> The legacy imports still compile — the old entry classes are `@Deprecated`
> aliases feeding the same methods.

## Inline Styles

```java
// Lambda style (recommended)
div(attrs()
    .class_("card")
    .style(s -> s
        .display(flex)
        .padding(px(20))
        .backgroundColor(white)),
    content
)

// Shortcuts
div(attrs().style(s -> s
    .size(px(100))                          // width + height: 100px
    .boxShadow("0 4px 6px rgba(0,0,0,.1)")
    .borderRadius(px(8))
))
```

**Kept, because they express a multi-property pattern rather than one value:** `size`,
`full`, `fullWidth`, `fullHeight`, `flexCenter`, `flexCol`, `flexRow`, `flexBetween`,
`grid(n)`, `grid(n, gap)`, `truncate`, `srOnly`, `borderMask`, `absolute`, `centerX`.

**Deprecated, because each was one value under an invented name:** the `shadowXs`…`shadowXl`
and `roundedNone`…`roundedFull` scales, plus `bold`, `clickable`, `textCenter`, `noSelect`,
`relative`, `fixed`, `sticky`, `minSize`, `maxSize`, `widthRange`, `heightRange`,
`fullViewportWidth`, `fullViewportHeight`. Write the property: `borderRadius(px(8))`,
`fontWeight(700)`, `cursor("pointer")`, `height("100vh")`.

`Style` covers ~55 property sections: box model, flexbox, grid, typography, backgrounds,
borders, transforms, transitions, animations, filters, positioning, overflow, columns,
scroll behavior, containment, and more. If a CSS property exists, it's very likely a method.

### Composing style fragments

Define a style once, reuse it everywhere with `.apply(fragment)` — the composition
primitive for design systems:

```java
// In your Theme:
public static Style<?> brandFlow() {
    return style().background(BRAND_GRADIENT)
                  .backgroundSize(percent(300), percent(100))
                  .animation("gradientShift", s(3), linear, s(0), infinite);
}

// Anywhere:
button(attrs().style(s -> s.padding(SP_3).apply(brandFlow()).color(white)), ...)
```

Related helpers that kill common `prop("...")` strings:

```java
static Style<?> brandFlow() {
    return style().background(hex("#ff00aa"))
                  .backgroundSize(percent(300), percent(100))
                  .animation("gradientShift", s(3), linear, s(0), infinite);
}

style().content()        // content: '' — for ::before/::after rules
style().inset(zero)      // top/right/bottom/left in one call
style().borderMask()     // mask so only the padding ring shows — gradient borders:
style().position(absolute).inset(zero)
       .borderRadius(px(12)).padding(px(2))     // 2px border thickness
       .apply(brandFlow()).borderMask()

// Transition shorthands (Tailwind-style)
style().transitionAll(s(0.2))          // transition: all 0.2s
style().transitionColors(s(0.15))      // color + background-color + border-color
style().transitionBackground(s(0.2))   // also: transitionTransform, transitionOpacity

// Accessibility + cross-browser
style().srOnly()                       // full screen-reader-only pattern (9 props)
style().backdropFilter(blur(px(10)))   // emits -webkit- prefix automatically
style().backgroundPosition(percent(0), percent(50))
```

Every keyword has a constant: `display(flex)`, `border(none)`, `alignItems(center)`,
`justifyContent(spaceBetween)`, `border(px(1), solid, hex("#e5e7eb"))`,
`gridTemplateColumns(repeat(autoFit(), minmax(px(250), fr(1))))`.

**Every property also takes a plain String**, so anything you can write in CSS you can
write here without hunting for the constant — and the CSS you already know transfers
directly:

```java
style().display("flex").cursor("copy").margin("0 auto")
       .transition("color .2s ease, transform .3s ease-out")
       .gridTemplateColumns("repeat(3, 1fr)")
```

`prop(name, value)` writes any property by name — a vendor-prefixed one, or a spec so new
the DSL has not caught up. There is nothing unsafe or second-class about it: a `CSSValue`
is itself a `() -> String`, so the typed overload is exactly as expressive. `property(name,
value)` is the same call under a more discoverable name; `var(name, value)` is the one for
custom properties (`--name`). (`unsafeProp` is deprecated — there was never anything
unsafe about it.)

### SVG presentation properties

`fill`, `stroke`, `strokeWidth`, `strokeDasharray`, `strokeDashoffset`, `strokeLinecap`,
`strokeLinejoin` and `transformBox` are CSS properties, distinct from the same-named
`fill(...)`/`stroke(...)` **attributes** (`Attr`/`HtmlAttributes`) — as properties they can
live in a stylesheet, respond to `:hover`, and participate in transitions:

```java
rule(Selector.type("path").cls("route"))
    .fill("none").stroke(hex("#6366f1")).strokeWidth(px(2))
    .strokeLinecap("round").strokeLinejoin("round")
    .strokeDasharray("240").strokeDashoffset("240")
    .transition("stroke-dashoffset 1.2s ease-out")

rule(Selector.type("path").cls("route").cls("drawn")).strokeDashoffset("0")   // add/remove the class to animate the draw-in
```

Also new: `font("italic bold 14px/1.5 Georgia, serif")` (the `font` shorthand), `justifySelf`,
`textRendering`, and the legacy `clip("rect(0, 0, 0, 0)")` — still the standard
`position(absolute).clip(...).overflow("hidden")` screen-reader-only pattern.

### Bare styles as element arguments

When an element only needs styling, pass `style()` directly — it composes with `Attr`
shortcuts, and it is now the only spelling: the `attrs().style()` starter and the
`.done()` that ended it are gone in 3.0.

```java
CSSValue SP_4 = rem(1);
CSSValue TEXT = hex("#1e293b");

// Before (2.2.3): div(attrs().style().padding(SP_4).color(TEXT).done(), text("hi"))

// After
div(style().padding(SP_4).color(TEXT), "hi")
div(cls("card"), id("hero"), style().margin(zero), p("content"))
```

Inside an `attrs()` chain the style is a lambda, which keeps the chain going:

```java
div(attrs().cls("card").style(s -> s.display(flex).gap(rem(1))).id("main"), content)
```

Use `attrs()` when you need chained event handlers or many attributes.

## Handles: one name for HTML, CSS and JS (3.0.2)

A class name or id is declared once, as a handle, and used by all three languages. `cls("card")`
returns a `jweb.Cls` and `id("x")` a `jweb.Id`: each is an element argument, an immutable
`Selector` and a JavaScript target, so the name is never typed twice and a rename is a refactor.

```java
Cls card = cls("card");
Cls copy = cls("copy");
Id editor = id("editor");

article(card, button(copy, "Copy"), textarea(editor));           // class="card" … id="editor"

Stylesheet sheet = stylesheet()
    .rule(card, style().padding(rem(1)))                          // .card{padding:1rem}
    .rule(copy.hover(), style().color(hex("#4f46e5")))             // .copy:hover{…}
    .rule(card.descendant(Selector.type("pre")), style().margin(zero))   // .card pre{…}
    .rule(editor.focus(), style().outline(none))                   // #editor:focus{…}
    .add(media().maxWidth(px(960)).rule(card, style().display(block)));

delegate(card, "click", copy);                                     // the JS DSL takes the handle
byId(editor).dot("value");                                         // document.getElementById('editor')
```

Every `rule(...)` — on `Stylesheet`, `MediaQuery`, `ContainerQuery`, `Supports`, `Rule.of`,
`startingStyle`, `scope` — takes a `Selector` as well as a String. Tag and universal selectors
are `Selector.type("pre")` and `Selector.any()`; `Selector.of(css)` is the escape for the odd case.
`cls()` with no name mints a class scoped to the declaring class (`snippetcard-1`). A static
component attaches its rules with `styled(sheet, element)`, exactly as a `Template`'s
`styles()` does.

Values that used to be strings have builders too:

```java
style()
    .boxShadow(shadow(0, px(1), px(2), rgba(15, 23, 42, 0.04)),
               shadow(0, px(12), px(32), px(-16), rgba(79, 70, 229, 0.35)))
    .fontFamily(uiMonospace, font("SFMono-Regular"), font("Menlo"), monospace)
    .maxHeight(vh(100).minus(px(50)))                              // calc(100vh - 50px)
    .backgroundImage(radialGradient("circle", stop(hex("#e2e8f0"), px(1)), stop(transparent, px(1))))
    .whiteSpace(preWrap).overflowWrap(anywhere).textOverflow(ellipsis);

Keyframes shift = keyframes("shift").from(style().opacity(0)).to(style().opacity(1));
style().animation(shift, s(3), linear);                            // the name is declared once
stylesheet().add(shift);
```

## Page-owned stylesheets

A component owns the class rules it needs. Override `styles()` and the render collects it
— the page's, its layout's, and every `Template` rendered inside them — dedupes by
content hash, and emits **one** nonce-stamped `<style>` at the end of `<head>`. Ten
instances of a card contribute one copy.

```java
public class Card implements Template {
    static final Cls CARD = cls("card");

    private final String title;
    private final String body;

    public Card(String title, String body) { this.title = title; this.body = body; }

    @Override
    public Element render() { return div(class_("card"), h3(title), p(body)); }

    @Override
    public Stylesheet styles() {
        return stylesheet()
            .rule(CARD, style().padding(rem(1)).borderRadius(px(12)))
            .rule(CARD.hover(), style().boxShadow(zero, px(4), px(12), rgba(0, 0, 0, 0.1)))
            .add(md().rule(CARD, style().padding(rem(1.5))));
    }
}
```

A layout's stylesheet is where the reset and the design tokens belong, so no page has to
remember to include them:

```java
class Theme {
    static final CSSValue TEXT = hex("#1e293b");
    static final CSSValue BG = hex("#ffffff");
    static final jweb.css.Theme TOKENS = jweb.css.Theme.light().color("text", TEXT).color("bg", BG);
}

public class Layout implements Template {
    @Override
    public Element render() { return body(); }

    @Override
    public Stylesheet styles() {
        return stylesheet()
            .add(Theme.TOKENS)
            .rule(Selector.any().or(Selector.any().before()).or(Selector.any().after()), style().boxSizing(borderBox).margin(zero))
            .rule(Selector.type("body"), style().color(Theme.TEXT).backgroundColor(Theme.BG));
    }
}
```

A swap fragment, a streamed Suspense block and a WebSocket DOM patch each carry the CSS
their own render introduced — a `<style>` inserted through `innerHTML` applies, unlike a
script. Nothing is sent twice: each render path drains only what it has not delivered.

Outside a page render (a bare `toHtml()`, a static export) there is nowhere to deliver
rules, so a conditional style keeps only its plain declarations.

## Pseudo-classes and media on an inline style

An inline style can carry the things a `style=` attribute cannot express. They get a
generated class named after a content hash of the rules, the rules go into the page's
collected stylesheet, and the plain declarations still ride the attribute.

```java
CSSValue PRIMARY = hex("#4f46e5");
CSSValue SP_2 = rem(0.5);
CSSValue SP_3 = rem(0.75);
CSSValue SP_4 = rem(1);
CSSValue SP_6 = rem(1.5);
CSSValue ROUNDED = px(8);

a(href("/docs"), style()
        .color(PRIMARY).padding(SP_2, SP_4).borderRadius(ROUNDED)
        .hover(style().backgroundColor(hex("#eef2ff")))
        .focusVisible(style().outline(px(2), solid, PRIMARY))
        .at(md(), style().padding(SP_3, SP_6))
        .dark(style().color(hex("#a5b4fc"))),
    "Documentation")

// <a href="/docs" class="j-3f9a1c" style="color: var(--color-primary); …">
// .j-3f9a1c:hover{background-color:#eef2ff}
// .j-3f9a1c:focus-visible{outline:2px solid var(--color-primary)}
// @media (min-width: 768px){.j-3f9a1c{padding:0.75rem 1.5rem}}
// @media (prefers-color-scheme: dark){.j-3f9a1c{color:#a5b4fc}}
```

Because the class name is the hash, fifty buttons with the same hover rule share one
class and one rule block, and the name is identical in every render — a swap fragment
naming a class the page already carries is a no-op by construction.

| method | emits |
| --- | --- |
| `hover` `focus` `focusVisible` `focusWithin` `active` `visited` `disabled` `checked` | the matching pseudo-class |
| `placeholder` | `::placeholder` |
| `popoverOpen` | `:popover-open` |
| `before(content, style)` / `after(content, style)` | `::before` / `::after`, with `content` quoted for you |
| `on(":nth-child(2n)", style)` | anything else — write the colons |
| `at(md(), style)` / `at(container("card")…, style)` | inside that query |
| `dark(style)` | `@media (prefers-color-scheme: dark)` |
| `reducedMotion(style)` | `@media (prefers-reduced-motion: reduce)` |
| `startingStyle(style)` | `@starting-style` |

They nest — a `hover` inside an `at(...)` is wrapped by that query too — and `apply()`
carries them, so a mixin can bring its own hover state:

```java
static final CSSValue ROUNDED = px(8);
static final CSSValue TEXT = hex("#1a1a1a");
static final CSSValue SP_1 = rem(0.25);
static final CSSValue SP_2 = rem(0.5);
static final CSSValue SP_3 = rem(0.75);

static Style<?> chip() {
    return row(SP_2).padding(SP_1, SP_3).borderRadius(ROUNDED)
                    .hover(style().backgroundColor(hex("#eef2ff")));
}

span(chip().color(TEXT), "Java 21")      // the hover comes along
```

## Layout mixins

Every app writes `display: flex; align-items: center` a hundred times. These are that,
named. Each returns a `Style`, so it composes with `apply()`, with tokens, and with the
conditional rules above.

```java
CSSValue SP_2 = rem(0.5);
CSSValue SP_4 = rem(1);

row()                  // flex, items centred
row(SP_4)              // …with a gap
stack()                // flex column
stack(SP_4)            // …with a gap
center()               // centred on both axes
cluster(SP_2)          // a row that wraps — chips, tags, a toolbar
container(px(1200))    // full width up to a maximum, then centred
card()                 // surface: background, hairline border, radius, padding
truncate()             // one line, ellipsis
truncate(3)            // three lines, ellipsis
srOnly()               // visually hidden, still read aloud
fullBleed()            // break out of the container to the viewport width
aspect(16, 9)          // a fixed ratio
cover()                // fill a box — images and video
contain()              // or fit it, instead of filling it
grid(3, SP_4)          // three equal columns
autoGrid(px(240))      // as many columns as fit — no breakpoints needed
autoGrid(px(240), SP_4)
```

```java
// Before
div(style().display(flex).flexDirection(column).gap(SP_4)
           .maxWidth(px(500)).margin(zero, auto), …)

// After
div(stack(SP_4).maxWidth(px(500)).marginInline(auto), …)
```

`card()` reads `--color-surface`, `--color-border`, `--radius-lg` and `--space-4` with
neutral fallbacks, so it picks up a `Theme` when one is defined and still works when it
is not.

## CSS Rules and Stylesheets

Two more mechanisms, for CSS that is not attached to a component:

### 1. `CSS.styles(...)` — quick rule strings

```java
Cls CONTAINER = cls("container");
Cls BUTTON = cls("button");

String css = styles(
    rule(CONTAINER)
        .maxWidth(px(1200))
        .margin(zero, auto)
        .padding(px(20)),

    rule(BUTTON)
        .display(inlineBlock)
        .padding(px(10), px(20))
        .backgroundColor(hex("#3b82f6"))
        .color(white)
        .borderRadius(px(4)),

    rule(BUTTON.hover())
        .backgroundColor(hex("#2563eb"))
);
// place it: style(css)  — emits a <style> tag
```

`rule(...)` returns a `StyleBuilder extends Style<StyleBuilder>` — the full property API plus
`toRule()`. Calling `toRule()` without a selector throws `IllegalStateException`.

### 2. `Stylesheet` — the accumulator

```java
import jweb.css.Stylesheet;

Cls HERO = cls("hero");
Cls TOAST = cls("toast");
Cls SIDEBAR = cls("sidebar");

Theme TOKENS = Theme.light()
    .color("primary", hex("#4f46e5"))
    .color("text",    hex("#1e293b"))
    .color("bg",      hex("#ffffff"))
    .space("4",       rem(1))
    .radius("md",     px(6));

Stylesheet sheet = Stylesheet.stylesheet()
    .add(TOKENS)                                  // a Theme
    .rule(Selector.type("body"), style().margin(zero).fontFamily("system-ui, sans-serif"))
    .rule(HERO, style().padding(rem(4)).textAlign(center))
    .startingStyle(TOAST, style().opacity(0))
    .add(keyframes("gradientShift")
        .from(style().backgroundPosition(percent(0), percent(50)))
        .to(style().backgroundPosition(percent(100), percent(50))))
    .add(md().rule(SIDEBAR, style().display(block)));

// Emit:
sheet.build();          // formatted CSS — what styles() hands back
sheet.buildMinified();  // whitespace-squeezed
sheet.toStyleTag();     // "<style>…</style>" string
```

`add(...)` is the one verb for every at-rule builder — a `media()`/`md()` query that
already carries its rules, `container(...)`, `keyframes(...)`, `fontFace(...)`,
`supports(...)`, a `Theme`. `raw(css)` and `comment(text)` append text.

Returning the stylesheet from `styles()` is almost always better than placing it by hand:
it is deduped, it lands in `<head>`, and it carries the CSP nonce.

## CSS Units (`CSSUnits`)

```java
px(16), rem(1.5), em(1.2), percent(50), ch(60), ex(2), lh(1), rlh(1)
vh(100), vw(50), vmin(10), vmax(10)
dvh(100), dvw(50), svh(100), lvh(100)         // dynamic/small/large viewport
cqw(10), cqh(10), cqi(10), cqb(10), cqmin(5), cqmax(5)   // container query units
fr(1), num(1.5)
ms(300), s(0.5), deg(45), rad(1.57), turn(0.25)
auto, zero, none, inherit, initial, unset
CSSValue.of("anything")                        // typed escape hatch — rarely needed, since
                                                // every property also takes a plain String

// Math
calc("100% - 20px"), min(...), max(...), clamp(rem(1), vw(4), rem(2))
round(...), mod(...), abs(...), pow(...), sqrt(...), hypot(...), sin/cos/tan(...)

// Modern color spaces (these live in CSSUnits, not CSSColors)
oklch(0.7, 0.15, 200), oklab(...), lab(...), lch(...), hwb(...)
colorMix("srgb", colorA, colorB, 50)           // 4-arg form with color space
lightDark(lightColor, darkColor)

// Misc
env("safe-area-inset-top"), imageSet("a.png 1x", "a@2x.png 2x"), steps(4, "end")
repeatingLinearGradient(...), repeatingRadialGradient(...), repeatingConicGradient(...)
```

> Note: there is **no `pt()`** unit function.

## CSS Colors (`CSSColors`)

```java
white, black, transparent, currentColor
red, green, blue, yellow, cyan, magenta, gray, silver     // flat constants
orange, purple, pink, navy, teal, coral, crimson, gold,
indigo, violet, salmon, turquoise, skyBlue, slateGray, ...

hex("#3b82f6")
rgb(59, 130, 246)
rgba(59, 130, 246, 0.5)
hsl(217, 91, 60), hsla(217, 91, 60, 0.5)

colorMix(colorA, colorB, 50)      // 3-arg srgb mix
lighten(color, 20), darken(color, 20)
lightDark(white, black)           // theme-aware
```

> Note: there is **no shade palette** like `blue(500)` — colors are flat constants or
> functions. Build palettes with `CSSVariables.colorPalette(...)` / a `Theme` instead.

## Media Queries (`MediaQuery`)

```java
Cls CONTAINER = cls("container");
Cls SIDEBAR = cls("sidebar");

media().minWidth(px(768)).rule(CONTAINER, style().maxWidth(px(720))).build()
md().rule(SIDEBAR, style().display(block)).build()      // presets: xs sm md lg xl xxl

// mobile()/tablet()/desktop() are deprecated: they were a second breakpoint set
// that overlapped xs()-xxl() with different pixel values.

media().prefersDark()
    .rule(Selector.type("body"), style().backgroundColor(hex("#1a1a1a")).color(white)).build()
media().prefersReducedMotion()
    .rule(Selector.any(), style().animationDuration(ms(0)).transitionDuration(ms(0))).build()

// Also: portrait()/landscape(), retina(), hover()/coarsePointer(), print(),
// displayMode/standalone(), and()/not()/only(), condition("raw")

// query() is the condition line without the rules — what style().at(md(), …) wraps
md().query()                        // "@media (min-width: 768px)"
```

A breakpoint that only changes one element does not need a rule at all:

```java
h1(style().fontSize(rem(1.9)).at(md(), style().fontSize(rem(2.5))), "Title")
```

## Container Queries (`ContainerQuery`)

```java
import static jweb.css.ContainerQuery.*;

Cls CARD = cls("card");
Cls NAV = cls("nav");
Cls TITLE = cls("title");

container().minWidth(px(400)).rule(CARD, style().display(flex)).build()
container("sidebar").maxWidth(px(300)).rule(NAV, style().flexDirection(column)).build()

// Style queries — match on a custom property, not a size
container("card").style("variant", "featured")
    .rule(TITLE, style().fontWeight(700)).build()
// @container card style(--variant: featured) { … }

container("card").minWidth(px(400)).query()   // the condition line, for style().at(…)
```

## Feature Queries (`Supports`)

```java
import static jweb.css.Supports.*;

Cls CONTAINER = cls("container");

supports("display", "grid").rule(CONTAINER, style().display(grid)).build()
supportsGrid(); supportsFlexbox(); supportsCustomProperties();
supportsBackdropFilter(); supportsContainerQueries();     // 17 named presets total
```

## Other At-Rules

```java
Cls BTN = cls("btn");

// @layer — cascade layers. All of these reach through `import static jweb.Css.*`
CSSLayer.order("reset", "base", "components", "utilities")
CSSLayer.layer("components", rule(BTN).padding(px(8)))

// @scope
CSSScope.scope(".card").to(".card-footer").rule(rule(Selector.type("p")).margin(zero))

// @property — typed custom properties. The per-type shortcuts are named
// *Property (colorProperty, lengthProperty, …) so they don't collide with the
// unit and colour statics.
CSSProperty.register("--angle").syntax("<angle>").inherits(false).initialValue("0deg")
colorProperty("--brand").initialValue("#4f46e5")

// @font-face (FontFace)
FontFace.fontFace("Inter").src("/fonts/inter.woff2", "woff2")
    .fontWeight(100, 900).fontDisplay("swap")

// @keyframes (Keyframes)
keyframes("fadeIn")
    .from(style().opacity(0))
    .to(style().opacity(1))
    .build();
keyframes("pulse").at(0, style().opacity(1)).at(50, style().opacity(0.5)).at(100, style().opacity(1))
// 12 presets: fadeIn/fadeOut/slideIn*/pulse/bounce/shake/spin/zoomIn/zoomOut

// @view-transition — opts this document into cross-document View Transitions
// (a plain navigation, not the SPA-style Navigation.startViewTransition()).
// Without it, ::view-transition-* rules never fire on a full page navigation.
stylesheet().add(viewTransitions())     // @view-transition{navigation:auto}
```

## Selectors

Three ways in, in the order you should reach for them:

```java
Cls card = cls("card");
Id search = id("search");

// 1. A handle (3.0.2) — the same name the element carries, chainable, never mutated
stylesheet()
    .rule(card.hover(), style().color(hex("#4f46e5")))             // .card:hover
    .rule(card.child(Selector.type("li")).nthChild("2n+1"), style().opacity(0.9))
    .rule(search.focusVisible(), style().outline(none))
    .rule(card.has(Selector.type("input").invalid()), style().borderColor(hex("#b91c1c")));
// starters without an element: Selector.type("li"), Selector.any(); then pseudo-classes,
// pseudo-elements, attribute matches, and the combinators child()/descendant()/adjacent()/sibling()

// 2. The Selectors builder, when there is no handle — its starters live in their own
//    import because id/tag/cls are also HTML DSL names
jweb.css.Selectors.cls("card").hover();                           // .card:hover
jweb.css.Selectors.tag("li").nthChild("2n+1");

// 3. A plain string — the escape hatch for a selector no starter covers, with no checking.
//    The handle in 1 is the form to write; the app's hygiene test bans the string.
stylesheet().rule(".card:hover", style().color(hex("#4f46e5")));
```

## CSS Variables

```java
import static jweb.Css.*;

var("primary-color")             // var(--primary-color)   ← named var(), not var_()
var("spacing", "1rem")           // with fallback
varChain("a", "b", rem(1))       // var(--a, var(--b, 1rem)) — final fallback is a CSSValue
env("safe-area-inset-bottom")    // env(), same shapes
```

3.0 removed the three competing token builders that also lived here —
`designSystem()`, `theme()`/`ThemeBuilder`, and the `scoped()`/`component()`/`theme(name)`
string-concatenation helpers. `jweb.css.Theme` below is the one that stayed.

## `Theme` — the token system

Design tokens are CSS custom properties. Write the set once, emit it through a page's
stylesheet, and read tokens back as `CSSValue`s — so the value lives in the document
(devtools shows it, a toggle can change it) instead of being inlined at every use site.

```java
import jweb.css.Theme;

public static final Theme TOKENS = Theme.light()
    .color("primary", hex("#4f46e5"))
    .color("text",    hex("#1e293b"))
    .color("bg",      hex("#ffffff"))
    .space("4",       rem(1))
    .radius("md",     px(6))
    .text("lg",       rem(1.125))
    .dark(Theme.dark()
        .color("text", hex("#e2e8f0"))
        .color("bg",   hex("#0f172a")));
```

Groups and their prefixes: `color` → `--color-*`, `space` → `--space-*`,
`radius` → `--radius-*`, `font` → `--font-*`, `text` → `--text-*` (the type scale),
`shadow` → `--shadow-*`, `gradient` → `--gradient-*`. `token(name, value)` defines any
other name verbatim.

```java
static final Theme TOKENS = Theme.light().color("primary", hex("#4f46e5")).space("4", rem(1));

// emit — a layout's stylesheet is the natural home
public Stylesheet styles() { return stylesheet().add(TOKENS); }

// read
Theme.color("primary")          // var(--color-primary)
Theme.space("4")                // var(--space-4)
Theme.var("gap", rem(1))        // var(--gap, 1rem)
TOKENS.valueOf("color-primary") // "#4f46e5" — the literal, for a <meta theme-color>
```

`css()` emits three blocks: the light values on `:root`, the dark values under
`prefers-color-scheme` (guarded with `:root:not([data-theme=light])` so an explicit light
choice still wins), and the same dark values under `:root[data-theme=dark]` so a toggle
overrides the system setting in both directions.

An app keeps its own constant names — the demo app's `Theme` is `TOKENS` plus
`PRIMARY`, `SP_4`, `ROUNDED` and friends, each a one-line `var()` reference:

```java
public static final CSSValue PRIMARY = Theme.color("primary");
public static final CSSValue SP_4    = Theme.space("4");
```

## `Utility` — Tailwind-style class generator (deprecated)

Generates utility-class CSS and provides ~400 class-name builder methods.

Deprecated: it is a design opinion rather than CSS parity, and its coverage was uneven —
the `hover:`, `dark:` and responsive variant builders emitted class names for which
`generateCss` produced no CSS at all, so those classes silently did nothing. Use the
`Style` builder and the [layout mixins](#layout-mixins), with `Theme` for tokens.

## CSS Animations (`CSSAnimations`)

```java
import static jweb.Css.*;   // CSSAnimations is folded into the Css facade

int index = 0;      // e.g. this element's position in a list

// 11 presets, each with matching @keyframes in `Keyframes`: fadeIn, fadeOut,
// slideInLeft, slideInRight, zoomIn, zoomOut, pulse, bounce, spin, shake, plus
// rotate360 (deprecated — it emits `spin`).
//
// 29 further presets (fadeInUp, flipX, jello, tada, ...) were deleted: they had no
// keyframes behind them, so they animated nothing at all.
//
// The preset builders implement CSSValue; Style.animation(...) has the multi-arg
// (name, duration, timing, ...) forms, so presets go through prop():
style().prop("animation", fadeIn(s(1)))
style().prop("animation", slideInLeft(s(0.6)))
style().prop("animation", pulse(s(1.5)).iterationCount(infinite))
style().prop("animation", spin(s(2)).timing(linear))

// Ship the matching keyframes:
stylesheet().add(Keyframes.fadeIn()).add(Keyframes.spin())

// Builder chain: .timing() .delay() .iterationCount() .direction() .fillMode() .playState()

// Scroll-driven animations
style().prop("animation", fadeIn(s(1)))
       .animationTimeline(scrollTimeline())
       .animationRange("entry", "exit")

// Composition
composeAnimations(fadeIn(s(1)).css(), slideInLeft(s(1)).css())
staggerDelay(index, 100)
```

## Grid helpers (`CSSGrid`)

```java
import static jweb.Css.*;

style().gridTemplateColumns(repeat(3, fr(1)))
style().gridTemplateColumns(repeat(autoFill(), minmax(px(200), fr(1))))
style().gridTemplateRows(masonry())
minContent(), maxContent(), fitContent(px(300)), span(2), subgrid()
templateAreas("header header", "sidebar main", "footer footer")
```

## The modern-CSS modules, folded in

Ten modules — anchor positioning, text wrap, subgrid, logical properties, masking,
scroll snap, cascade layers, `@scope`, nesting, `@property` — used to be ten separate
static imports whose methods returned raw `"property:value"` **strings**, so they only
composed through `.prop(...)`. In 3.0 they are part of the `Css` chain and they are typed:
a method that produces a **declaration** returns a `Style`, one that produces a **value**
returns a `CSSValue`.

```java
import static jweb.Css.*;    // that is the whole import

// Declarations compose with apply(...)
style().apply(textWrapBalance())            // text-wrap: balance
style().apply(hyphensAuto())
style().apply(snapAlignStart()).apply(snapStopAlways())
style().apply(subgridColumns())             // grid-template-columns: subgrid
style().apply(clipCircle("50%"))
style().apply(marginBlock(rem(1)))          // logical properties, RTL-safe
style().apply(maskImage("linear-gradient(black 60%, transparent)"))

// Values slot into any property
style().top(anchor("--menu", "bottom"))     // anchor(--menu bottom)
style().width(anchorSize("--menu", "width"))
style().clipPath(clipHexagon())
```

Anchor positioning and `@property`:

```java
Cls TRIGGER = cls("trigger");
Cls TOOLTIP = cls("tooltip");

rule(TRIGGER).apply(anchorName("--menu"))
rule(TOOLTIP)
    .apply(positionAnchor("--menu"))
    .apply(positionArea("top"))
    .position(absolute)
    .top(anchor("--menu", "bottom"))

// @property registration — the type methods are *Property to keep out of the
// way of the unit and colour statics of the same name
colorProperty("--brand").initialValue("#4f46e5").inherits(true)
lengthProperty("--gap").initialValue("1rem")
```

Cascade layers, `@scope` and nesting:

```java
Cls BTN = cls("btn");

CSSLayer.order("reset", "base", "components", "utilities")
CSSLayer.layer("components", rule(BTN).padding(px(8)))
CSSScope.scope(".card").rule(Selector.type("h2"), style().fontSize(rem(1.5)))

// nest(...) — it was CSSNested.rule(...), which collided with Css.rule(...)
nest(".card")
    .style(style().padding(rem(1)))
    .nest("&:hover").style(style().transform(scale(1.02)))
    .parent()
    .build()
```

`CSSMasking`'s preset shapes (`clipDiamond`, `clipPentagon`, `clipHexagon`, `clipStar`,
`clipTriangle*`) encode the polygon coordinates so you don't have to.

Most of these properties are *also* plain `Style` methods (`textWrap(balance)`,
`marginBlock(rem(1))`, `clipPath(...)`, `scrollSnapType("x mandatory")`) — use whichever
reads better; the module functions earn their keep for the presets and the value
functions.

## Properties added in 3.0

```java
style().fieldSizing(content)                       // an input that grows as you type
style().scrollbarColor(hex("#94a3b8"), transparent)
style().scrollbarWidth(thin)
style().scrollbarGutter(stable)                    // or stableBothEdges
style().textBoxTrim(trimBoth).textBoxEdge(capAlphabetic)
style().textBox(trimBoth, capAlphabetic)           // the shorthand
style().interpolateSize(allowKeywords)             // animate to/from auto, fit-content
style().anchorScope("all")                         // confine anchor names to a subtree
style().viewTransitionClass("card")                // one rule for every card
style().borderRight(none)                          // one-value border sides (also L/T/B)
```

At-rules and selectors:

```java
Cls TOAST = jweb.El.cls("toast");   // qualified: the Selectors import below makes a bare cls ambiguous

// @starting-style — the values a transition animates FROM on first render
div(style().opacity(1).transition(all, ms(200), ease)
           .startingStyle(style().opacity(0)), "I fade in")

stylesheet().rule(TOAST, style().opacity(1).transition(all, ms(200), ease))
            .startingStyle(TOAST, style().opacity(0))

// :popover-open / :open
import jweb.css.Selectors;
import static jweb.css.Selectors.*;    // cls is ambiguous with the HTML DSL — qualify it

Selectors.cls("menu").popoverOpen()   // .menu:popover-open
tag("details").open()                 // details:open
div(popover("auto"), style().opacity(0).popoverOpen(style().opacity(1)), "menu")
```

## Transitions

```java
// On Style
style().transition("opacity 0.3s ease")
style().transitionProperty("transform").transitionDuration(ms(300))

// CSS facade helpers
style().transition(transitions(trans(propTransform, ms(300), easeOut)))
style().transition(transitions(trans(propOpacity, ms(200)), trans(propColor, ms(150))))

// Attribute-level builder (attrs().transition())
div(attrs().class_("box")
    .transition().property("opacity", "transform").duration(300).easeInOut().done(),
    content)
button(attrs().transition().fade().done(), "Hover me")
```

## Nested CSS

```java
import static jweb.Css.*;    // nest() lives on the facade like everything else

Cls CARD = cls("card");

nest(".card")
    .style(rule(CARD).padding(px(20)))     // base declarations via a StyleBuilder
    .nest("&:hover").prop("box-shadow", "0 2px 8px rgba(0,0,0,0.1)").parent()
    .nest("& .title").prop("font-size", "1.5rem").root()
    .build();
// Also: .media(...)/.supports(...)/.container(...) nesting, and a BEM helper
// (block("card").element("title")...)
```

<!-- ==================== END css-dsl ==================== -->


<!-- ==================== BEGIN javascript-dsl ==================== -->

# JavaScript DSL: actions, handlers, fetch, events, browser APIs

> Source: `readme/guides/javascript-dsl.md` · topic id: `javascript-dsl`

[← Back to README](./../README.md)

# JavaScript DSL

45 modules, ~25,000 lines, generating client-side JavaScript from type-safe Java. One
import, three layers:

```java
import static jweb.Js.*;
```

- **Actions** — the page-level layer: form/click handlers, fetch chains, DOM actions,
  message boxes. Everything here is an `Action`, and an `Action` plugs into any element
  handler. This is what pages normally use.
- **Behaviors** — client work that installs itself and keeps running: copy buttons, a
  scroll spy, client-side navigation with prefetching, a split pane, a line gutter. Also
  Actions, so they attach the same way.
- **Expressions and statements** — values (`v`, `str`, `obj`), functions, control flow,
  DOM access. Use it when an Action doesn't cover the shape you need.

`jweb.Actions` is the same surface under its old name (importing both is harmless). Every
browser-API module (`JSCanvas`, `JSClipboard`, `JSCrypto`, `JSStorage`, ...) lives at
`jweb.js.*` and takes its own static import next to `Js.*`:

```java
import static jweb.Js.*;
import static jweb.js.JSStorage.*;
import jweb.js.JSStorage;

JSStorage.local().set("token", "abc123")
```

`Js.*` does not re-export any module. It did briefly (ten of them, during the 3.0 pass), and
that made the pair of imports above an "ambiguous reference" for every shared name — Java
cannot tell two identical statics apart, and a facade cannot inherit from ten classes. So the
rule is one line: **the page-level DSL is `Js.*`; a browser API is its module's import.**
`jweb.js.ModuleImportsCoexistTest` compiles all ten of the most-used modules together with
the four DSL wildcards, so the pairing cannot silently break again.

Where the two layers shared a name, the page-level `Action` form owns it: `fetch("/url")`
is the `.ok(...)/.fail(...)` builder, `call("fn")` and `sleep(ms)` are Actions. The
expression twins are `fetch(str("/url"))` / `fetch(v("apiUrl"))`, `JS.call("fn", args...)`
and `delay(ms)`.

## Two script builders

`script()` and `actions()` are different builders:

| | `script()` | `actions()` |
|---|---|---|
| Output | bare statements | wrapped in an IIFE `(function(){...})()` |
| Accepts | `does(...)`, `var_/let/const_`, `Func`, `AsyncFunc` | `does(...)`, handlers (`onSubmit`/`onClick`/...), `state()`, `onLoad()` |

`actions()` is itself an `Action`, so a whole script can be returned from a Template's
`scripts()` hook. The DOM query builders are `dom(selector)` / `domAll(selector)`.

**Helpers are auto-injected.** Generated handlers reference `$_('id')` (plus `esc()` and
`fmtDate()`); `build()` detects their use and prepends the definitions automatically.

**Actions plug into element handlers directly** — no raw JS strings:

```java
button(onClick(reload()), "Retry")
button(onClick(toggle("panel")), "Menu")
button(onClick(Toast.success("Saved!")), "Save")      // typed Toast actions
button(onClick(dom("#search-input").focus()), "Focus")   // dom(...) methods are Actions too
```

## Handlers take a `Func`, and `does(...)` fills it

Every `onXxx(...)` in the browser-API modules takes a `Func`. `does(...)` is how any
DSL value becomes a statement in one — an `Action`, a `Val`, a `Stmt` or a nested
`Func`, in order:

```java
callback("e", "t").does(
    preventDefault(),                                  // a statement on the event
    copyFrom("pre").trigger(v("t")).feedback("Copied!"),  // an Action
    v("count").addAssign(1))                           // an assignment
```

The same `does(...)` is on `actions()`, `script()`, `iife()`, `guard()` and
`asyncFunc()`, so a whole page script never needs a JavaScript string.

Statements that used to need raw JS:

```java
preventDefault(); stopPropagation(); stopImmediatePropagation(); // on the callback's event, named e
Object value = "x";
return_(); return_(value);                                       // early return
if_(v("stale").eq(true), return_())                              // a guard, anywhere a statement goes
```

### The escape hatch

`unsafeRaw(String)` still exists on `Func`, `script()`, `iife()` and `guard()`, and
`raw(String)` on `actions()`. It is the last resort — for a construct the DSL has no verb
for. The framework's own pages use it nowhere; if you reach for it, the shape you need is
probably a missing verb worth adding.

## Locals are handles: `local(...)`

A script's variables are named once, as `Var` handles — the JavaScript counterpart of the
`Cls` and `Id` handles HTML and CSS share. A `Var` is a `Val`, so it reads anywhere an
expression goes; it declares itself, assigns itself, and stands as a callback parameter.
A misspelled name is a compile error, and a rename is one edit.

```java
import jweb.Var;
import jweb.Id;

Id INPUT = id("search"), RESULTS = id("results");
Var Q = local("q"), HTML = local("html"), E = local("e");
String url = "/docs/search?q=";

Action wiring = actions().does(
    byId(INPUT).addEventListener("input", debounce(150, callback()
        .let(Q, byId(INPUT).value().trim())                        // let q = ...
        .if_(Q.length().eq(0), byId(RESULTS).setHtml(""))           // q.length === 0
        .else_(fetch(str(url).plus(call("encodeURIComponent", Q)))
            .text()
            .then(callback(HTML).does(byId(RESULTS).setHtml(HTML))) // (html) => ...
            .toVal()))),
    byId(INPUT).addEventListener("keydown", callback(E)             // (e) => ...
        .if_(eventKey(E).eq("Escape"), byId(INPUT).setValue(""))));
```

`Q.let(expr)`, `Q.const_(expr)`, `Q.set(expr)`, `Q.increment()` are statements;
`func("name", E)` and `callback(E, T)` take handles as parameters (a bare `callback()` is
unchanged). `v("q")` still works for a one-off; the handle is for the names a script
repeats.

## Behaviors

A behavior is client work that installs itself once and keeps running. Each is an
`Action`, so it attaches to an element or goes on the page through `actions().does(...)`;
the implementation lives in the client runtime, so the generated code is one call.

```java
// Clipboard — navigator.clipboard with the execCommand fallback built in
button(onClick(copy("npm i jweb").feedback("Copied!")), "Copy")
button(onClick(copyFrom("pre")                 // the nearest <pre>: this element's
        .feedback("Copied!", 1600)             // subtree, then each ancestor's
        .feedbackClass("copied")
        .failText("Copy failed")),
    "Copy")

// Client-side navigation — the runtime's swap(), as an action
navigate("/docs/content?section=state")
    .target(".docs-content").push("/docs?section=state").cache(300_000)

// …and the two things swap markup cannot say
prefetch(".docs-nav-link").within(".docs-sidebar").delay(50).cache(300_000)
prefetch(".card a").onVisible()
activeLink(".docs-nav-link")                   // marks the link for the current URL
onPopState(callback("e").log(v("e").dot("state")))

// An "on this page" rail that follows the reader and survives swaps
scrollSpy("#toc", "h2, h3")
    .within(".docs-content").linkClass("toc-link")
    .activeClass("active").hasHeadingsClass("has-headings").scrollMargin(24)

// Editors
lineGutter("#editor", "#lines").mirror("#mirror").errorClass("errline")
splitPane("#gutter", "#code").container("#split").minPercent(20).maxPercent(80).persist("split")
resizeToContent("textarea.grow")
relineGutter("#editor"); markLine("#editor", v("n")); insertText("#editor", "    ");

// The client's copy of a server State
State<Integer> count = useState(0);
syncState(count.getId())                       // a Val — JWeb.getState('<id>')
onStateChange(count.getId(), callback("now", "before").log(v("now")))
```

`prefetch(...)` and `navigate(...).cache(ttl)` share one store, so a link warmed on hover
swaps without a round trip. `prefetch` takes the URL from `data-prefetch`, else
`data-swap-get`, else `href` — links already marked up with `swap(...)` need no extra
attribute. Behaviors queue themselves on the runtime instead of running inline (a page's
own scripts parse first); anything queued later, from a swapped fragment, installs at once.

Scroll-spy links carry `data-level` (2 for an `h2`, 3 for an `h3`) and `data-index`, so
indentation and size are CSS rather than inline styles.

## Web Components and PWAs

```java
customElement("user-card")
    .observedAttributes("name")
    .shadow()                                  // or .shadow("closed")
    .template(div(class_("card"), slot()))     // written into the shadow on connect
    .connected(callback().log("mounted"))
    .disconnected(callback().log("gone"))
    .attributeChanged(callback("name", "oldValue", "newValue")
        .does(dom(".card").setText(v("newValue"))))

attachShadow(v("host")); shadowRoot(v("host"));
assignSlot(v("child"), "footer"); assignedElements(v("slot"));
onSlotChange(v("slot"), callback("e").log("changed"))
```

```java
import jweb.js.Manifest;
import static jweb.js.Pwa.*;

Manifest APP = manifest("JWeb Demo").shortName("JWeb")
    .display("standalone").startUrl("/").themeColor("#4f46e5")
    .icon("/icon-192.png", "192x192");

serve(app, APP);                               // on the JWeb: GET /manifest.webmanifest
head(title("JWeb"), link())                    // <link rel="manifest">
registerServiceWorker("/sw.js")                // an Action
```

## The standard form-submission pattern

(The sample ContactPage now uses the zero-JS `swapForm(...)` fragment pattern instead — see
[Backend § Fragments](./backend.md) — but this remains the canonical `Actions` flow when you
want scripted control.)

```java
import static jweb.Actions.*;

String js = actions()
    .add(onSubmit("contact-form")
        .loading("Sending...")                       // disables submit button, swaps text
        .post("/api/v1/contact").withFormData()      // serialize form fields to JSON body
        .ok(all(
            showMessage("form-status").success("Message sent!"),
            resetForm("contact-form")))
        .fail(showMessage("form-status").error("Failed to send.")))
    .build();

// Put it on the page:
inlineScript(js)
```

### The magic-locals contract

Inside `ok(...)` / `fail(...)` actions, the generated code exposes these locals:

| Local | Meaning |
|-------|---------|
| `_data` | parsed JSON response body |
| `_res` | the `fetch` Response object |
| `_fd` | the `FormData` (form handlers) |
| `_form` / `_btn` | the form element / its submit button |

This is why `assign("users", response("users"))` and `showMessage(...).fromResponse("field")`
work.
Typed accessors exist: `response("name")` → `_data.name`, `formField("email")` →
`_fd.get('email')`.

### FormHandler chain reference

`onSubmit(formId)` → `.loading(text)` `.before(action)` `.post(url)`/`.get(url)`
`.withFormData()` `.withJson("k", "v", ...)` `.header(n,v)` `.headerFrom(n, varName)`
`.ok(action)` `.fail(action)` `.always(action)`.
With GET + `withFormData()`, fields become URL query params instead of a body.
`onSubmitExternal(formId)` targets cross-origin endpoints.

## Click / Change Handlers

```java
onClick("delete-btn")
    .confirm("Are you sure?")
    .delete("/api/items/42")
    .ok(reload())
    .fail(showMessage("error").error("Delete failed"))

onClick("toggle-btn").toggle("panel")     // no-network shortcuts: toggle/show/hide
onChange("country-select").then(get("/api/regions").ok(...))   // ChangeHandler wraps an action via then(...)

// Double-click — same two flavours as onClick everywhere it appears
// (attrs()/element handler attrs, the El/Elements facade, and Button):
// a Consumer<Event> server handler, or an Action, both delegated
// CSP-safe via data-jweb-ondblclick/data-jweb-actdblclick.
button(onDblClick(e -> zoomIn()), "Zoom")
button(onDblClick(toggle("fullscreen-panel")), "Zoom")
```

## Actions catalog

```java
// Messages (renders into an element; sets jweb-msg-<type> classes, themeable via
// --jweb-msg-<type>-bg/-fg CSS variables with green/red/amber defaults)
showMessage("status").success("Saved!")
showMessage("status").error("Failed!")
showMessage("status").warning("Careful…")
showMessage("status").fromResponse("message")

// Navigation & forms
navigateTo("/dashboard"); reload(); resetForm("contact-form")
pushState("admin", "true")                   // history.pushState({admin:true},'',location.href)
                                             // (for the real History API use jweb.js.JSHistory)

// DOM
show("modal"); hide("modal"); toggle("panel")
setText("title", "New Title"); setHtml("box", "<b>hi</b>")
addClass("card", "active"); removeClass("card", "active"); toggleClass("card", "open")
focus("email"); copyToClipboard("token-field"); download("/file.pdf")

// Composition & control flow
all(actionA, actionB, actionC)               // run in sequence
when(condition).then(a).otherwise(b)
whenOk().then(a)  /  whenResponse(field).then(a)     // _res.ok / truthy _data.<field>
tryCatch().try_(a).catch_(b)                 // optional .finally_(c); '_err' in the catch

// Response status handling
responseError("status-box").on401(...).on403(...).on404(...).on500(...).otherwise(...)

// Modals
showModal("dialog-id"); hideModal("dialog-id")
alertModal("modal-overlay", "modal-body").success("Approved!")
// (the client template engine — renderList/template/field — was deleted in 3.0:
//  render the list on the server and swap the fragment in)

// Behaviors (see above)
copy(text) / copyFrom(selector); navigate(url); prefetch(selector); activeLink(selector)
scrollSpy(nav, headings); splitPane(handle, left); lineGutter(textarea, gutter)
relineGutter(textarea); markLine(textarea, line); resizeToContent(textarea)
insertText(selector, text); syncState(state); onStateChange(state, callback)
customElement(name); attachShadow(el); assignSlot(el, name)

// Async
asyncBlock(await(get("/api/a").ok(...)), await(get("/api/b").ok(...)))
sleep(500)
```

## Fetch Builder (`Actions`)

```java
get("/api/users")
    .ok(assign("users", response("users")))
    .fail(showMessage("error").error("Failed to load"))

post("/api/users").json("{\"name\":\"John\"}").ok(...)
post("/api/contact").formData("contact-form").ok(resetForm("contact-form"))
put("/api/items/3").json(v("payload")).bearer("token").ok(...)

// Status-code handling
get("/api/data")
    .onUnauthorized(navigateTo("/login"))
    .onForbidden(showMessage("error").error("No access"))
    .onNotFound(showMessage("error").error("Missing"))
    .onStatus(429, showMessage("error").error("Slow down"))
    .ok(setText("result", "loaded"))
```

Full chain: `get/post/put/patch/delete/fetch(method, url)`, `.urlFromVar`, `.appendVar`,
`.json/.formData/.urlEncoded/.body`, `.header/.bearer/.credentials/.mode`
(each takes a literal `String` or a `Val` expression — the old `*Expr` twins are deprecated),
`.onStatus(...)` + named variants, `.ok/.fail/.then`.

> `Actions.promiseAll(actions...)` emits a true parallel `Promise.all([...])` (stripping any
> leading `await` from each action so calls don't serialize). To consume results, use
> `Actions.promiseAllThen(actions...)` which returns a chainable `Async.PromiseBuilder`
> (`.then(...)`/`.catch_(...)`), or `JSPromise.all(vals...)`.

## DOM Query Builder (`Actions`)

```java
dom("#myDiv").hide()
dom("#panel").addClass("visible")
dom("#title").setText("Hello World")
domAll(".item").addClass("processed")
domAll(".temp").remove()
```

Since 3.0.2 every selector position takes the handle the element carries — `dom`, `domAll`,
`byId`, `query`, `queryAll`, `delegate`, the behaviors (`copyFrom`, `prefetch`, `scrollSpy`,
`splitPane`, `lineGutter`, …) and `el.querySelector`/`closest`/`matches` — so no class or id
is a string in the script:

```java
Cls card = cls("card");
Cls copy = cls("copy");
Id editor = id("editor");

dom(card).addClass("seen");
byId(editor).dot("value");
delegate(card, "click", copy);
query(editor).closest(card);
byId(editor).hasClass(copy);
```

## Core JS Module (`JS`)

```java
// Values
v("count"); str("hello"); null_(); this_()      // v() is short for variable()
obj("name", "John", "age", 30)               // {name:'John',age:30}
array(1, 2, 3)
object().prop("a", 1).spread("rest").build()

// Functions
Func formatTime = func("formatTime", "seconds")
    .var_("hrs", floor(v("seconds").div(3600)))
    .return_(v("hrs").padStart(2, "0"));
Func cb = callback("e").log("clicked", v("e").path("target.id"));

// Control flow on Func bodies — every block takes its body inline, nothing closes it
func("check", "x")
    .if_(v("x").gt(10), call("big"))                 // if / else if / else
    .elif(v("x").gt(5), call("mid"))
    .else_(call("small"))
    .forOf("item", v("list"), stmt1, stmt2)          // same for while_/for_/forIn/doWhile
    .while_(v("go"), stmt)
    .try_().body(...).catch_("err", stmt)            // catch_ with a body closes the try
    .switch_(v("mode")).case_("a", stmt, "break").default_(stmt);   // default_ closes it

// A trailing underscore marks a Java keyword and nothing else: if_/else_/for_/while_/
// try_/catch_/switch_/case_/default_/return_/const_/var_/null_/this_ keep it;
// then, elif, let, await, in, delete do not. An Action is a statement anywhere a
// statement goes: .if_(cond, toggle("panel")) works.

// DOM
byId("submit")   // or $("submit")           → El (getElementById)
Js.query(".card")                            → El (querySelector)
JS.queryAll(".item")                         → Val (querySelectorAll)

// Viewport & media queries
matchMedia("(min-width: 768px)")             → Val (window.matchMedia(query))
reducedMotion()                              → Val boolean (prefers-reduced-motion: reduce)
viewportWidth(); viewportHeight()            → Val (window.innerWidth/innerHeight)

// Val chains (~180 methods): arithmetic, comparison, string/array/number ops
v("res").json()
v("e").path("target.result")
v("items").filter(...).map(...).join(", ")

// El chains: setText/setValue/addClass/toggleClass/setStyle/hide/show/closest/
//            addEventListener/scrollIntoView/getBoundingClientRect/...

// Script assembly (low-level; no IIFE, no helpers)
String js = Js.script()
    .var_("count", 0)
    .add(formatTime)
    .build();
```

## Async/Await (`Async`)

```java
asyncFunc("loadData")
    .does(
        await(get("/api/users").ok(assign("users", response("users")))),
        await(get("/api/posts").ok(assign("posts", response("posts")))),
        call("renderDashboard")
    )

JSPromise.all(valA, valB).then(callback("results").log(...)).catch_(...)   // Promise helpers live in JSPromise
Async.delay(500); Async.onceEvent(el, "transitionend")
```

`JSPromise` adds: `deferred()` (Promise.withResolvers), `cancellable`, `timeout`, `retry`,
`memoizePromise`, `debouncePromise`, `throttlePromise`.

## Events (`Events`)

```java
import static jweb.Js.*;

// Delegation — attach to parent, filter by selector
delegate("container", "click", ".card").handler(callback("e")
    .log("Clicked:", v("e").path("target.textContent")))

// Debounce / throttle (note: they wrap handlers, taking a state-variable name)
debounce(300, callback("e").log("search"))   // self-contained closure; no timer variable
throttle(100, callback("e").log("scroll"))
throttle("lastScroll", 100).wrap(callback("e").log("scrolled"))

// Keyboard
onKeyCombo("ctrl+s", callback("e").log("save"))     // binds keydown
onKey("Escape", callback().log("esc"))              // there is no onKeyDown() — use onKey/onKeyCombo
onEnter(callback().log("submitted"))

// Touch / swipe — builder, not onSwipe()
swipe(v("el")).threshold(50)
    .onLeft(callback().log("swiped left"))
    .onRight(callback().log("swiped right"))
    .build()

// Custom events (element first; eventDetail takes the event)
dispatchCustomEvent(v("document"), "cart:updated", obj("count", 3))
onCustomEvent(v("document"), "cart:updated",
    callback("e").log(eventDetail(v("e"))))

// Client-side SSE — on(name, ...) for named events, onMessage for unnamed
sse("/events")
    .onMessage(callback("e").log(v("e").dot("data")))
    .on("notification", callback("e").call("showNotification", v("e").dot("data")))
    .onError(callback().log("SSE error"))
    .build()
```

## Full module inventory (45)

Everything from `Actions` down to `Runtime` is reachable from `import static jweb.Js.*`; each
`JS*` module takes its own import.

| Module | Purpose |
|--------|---------|
| `Actions` | High-level UI DSL — handlers, fetch, DOM actions, messages (main entry) |
| `Behaviors` | copy/navigate/prefetch/activeLink/scrollSpy/splitPane/lineGutter/syncState, Web Components, `if_`/`preventDefault` statements |
| `JS` | Core expressions/statements — Val/El/Func/Script |
| `Async` | Fetch builder, async funcs, await, promise combinators |
| `Events` | Delegation, debounce/throttle, key combos, touch/swipe, custom events, SSE client |
| `Runtime` | IIFE, run-once guard, TTL cache, memoize patterns |
| `Pwa` | Web app manifest, `<link rel="manifest">`, its route, service-worker registration (`jweb.js.Pwa`) |
| `JWebRuntime` | The reactive-state client runtime (auto-injected into rendered pages; opt out with `jweb.runtime.enabled: false`) |
| `JSAbort` | AbortController/AbortSignal (timeout/any/onAbort) |
| `JSAnimation` | requestAnimationFrame loops, CSS transition/animation helpers, lerp/easing |
| `JSCanvas` | Canvas 2D — paths, gradients, transforms, ImageData, OffscreenCanvas |
| `JSClipboard` | copyText/copyElementText/readText/write + fallback |
| `JSConsole` | console.log/table/group/time/count/trace/assert |
| `JSCrypto` | Web Crypto — digest/encrypt/sign/generateKey/deriveKey/wrapKey |
| `JSDate` | Date manipulation — add/startOf/endOf/diff/UTC/format |
| `JSDragDrop` | draggable()/dropZone() builders, DataTransfer accessors |
| `JSFile` | FileReader, Blob, object URLs, download helpers, validation |
| `JSFormData` | FormData construction/manipulation, toUrlEncoded/toObject |
| `JSFullscreen` | request/exit/toggle fullscreen, state, fullscreenButton |
| `JSGeolocation` | getCurrentPosition/watchPosition builders, distanceBetween |
| `JSHistory` | pushState/replaceState, popstate, navigation guards, query params |
| `JSIndexedDB` | openDB/createStore/transaction/cursorQuery builders, IDBKeyRange |
| `JSIntl` | number/currency/date/relative-time/list/plural formatting |
| `JSIterator` | Generators, yield, async iterators, range/zip/chain |
| `JSJson` | stringify/parse/safeParse/deepClone/isValidJson |
| `JSMath` | Math.* wrappers, clamp, randomInt/randomRange |
| `JSMedia` | audio/video control, MediaRecorder, Picture-in-Picture, Web Audio synthesis |
| `JSNotification` | Notification permission + builder (icon/badge/tag/actions) |
| `JSObservers` | Intersection/Mutation/Resize observers |
| `JSOperators` | Optional chaining, nullish coalescing, spread, in/delete/void |
| `JSPerformance` | marks/measures, Navigation/Resource Timing, PerformanceObserver |
| `JSPointer` | Pointer events, pressure/tilt, capture, multiPointerTracker |
| `JSPromise` | deferred/cancellable/timeout/retry/memoize/debounce/throttle promises |
| `JSProxy` | Proxy (13 traps) + Reflect + property descriptors |
| `JSRegex` | Regex literals, named groups, lookarounds, EMAIL/URL/UUID presets |
| `JSServiceWorker` | SW registration + sw.js lifecycle, Cache API, push, sync |
| `JSShare` | Web Share API, shareFiles, canShare |
| `JSSpeech` | speechSynthesis (speak) + SpeechRecognition (recognizer) |
| `JSStorage` | localStorage/sessionStorage (+JSON), cookies, storage events, quota |
| `JSUrl` | URL/URLSearchParams parsing and building |
| `JSVisibility` | Page Visibility, onVisible/onHidden, visibility-aware intervals |
| `JSWebAnimations` | element.animate() + keyframe builder, playback control |
| `JSWebRTC` | RTCPeerConnection, getUserMedia, data channels, stats |
| `JSWorker` | Web Workers (dedicated/inline/shared), MessageChannel, BroadcastChannel |
| `JSWebSocket` | WebSocket builder with autoReconnect, sendJson |

## Selected module guides

### IndexedDB

```java
import static jweb.js.JSIndexedDB.*;

// onUpgrade takes a lambda: the database is the parameter, the store
// work is the return value
openDB("myApp", 1)
    .onUpgrade(db -> createStore(db, "users")
        .keyPath("id").autoIncrement()
        .index("email", "email")
        .build())
    .onSuccess(callback("db").log("Database opened"))
    .build();

transaction(v("db"), "users", "readwrite")
    .store("users")
    .add(obj("id", 1, "name", "Alice"))
    .build();

// Cursor iteration — the method is cursorQuery, direction via ascending()/descending()
cursorQuery(v("db"), "users")
    .descending()
    .onEach(callback("cursor").log(v("cursor").dot("value")))
    .build();
```

### History API

```java
// Several of these names are also declared elsewhere in jweb.Js (the Actions layer, or
// another module), so this section qualifies every call with JSHistory. to stay unambiguous.
import jweb.js.JSHistory;

JSHistory.pushState(obj("page", str("dashboard")), "/dashboard")   // platform order: (state, url)
JSHistory.replaceState(obj("page", "login"), "/login")
JSHistory.onPopState(callback("e").log(v("e").dot("state")))

// Navigation guards
JSHistory.navigationGuard("You have unsaved changes!")
JSHistory.navigationGuardWhen(v("formDirty"), "You have unsaved changes!")  // conditional form

// Query params
JSHistory.getQueryParam("page"); JSHistory.setQueryParam("page", "2"); JSHistory.removeQueryParam("filter");
```

### Drag and Drop

```java
import static jweb.js.JSDragDrop.*;

draggable("card-1")
    .data("text/plain", "Card 1 data")
    .effectAllowed("move")
    .onDragStart(callback("e").log("drag started"))
    .build();

dropZone("target-area")
    .dropEffect("move")
    .onDrop(callback("e")
        .let("data", getData(v("e"), "text/plain"))
        .log("Dropped:", v("data")))
    .build();
```

### Pointer Events

```java
import static jweb.js.JSPointer.*;

onPointerDown("canvas", callback("e")
    .log(pointerId(v("e")))
    .log(pressure(v("e"))))

multiPointerTracker("canvas")
    .onStart(callback("e").log("down:", pointerId(v("e"))))
    .onMove(callback("e").log("move:", clientX(v("e"))))
    .onEnd(callback("e").log("up"))
    .build();
```

### Speech

```java
import static jweb.js.JSSpeech.*;

speak("Hello, welcome to JWeb!")

speakBuilder("Hello world")
    .lang("en-US").rate(1.2).pitch(1.0).volume(0.8)
    .onEnd(callback().log("Done speaking"))
    .build();

// Recognition — note build(varName) requires a variable name
recognizer()
    .lang("en-US").continuous(true).interimResults(true)
    .onResult(callback("e").log("Heard:", transcript(v("e"))))
    .build("rec");
startRecognition(v("rec"));
```

### Storage & Cookies

```java
import static jweb.js.JSStorage.*;

local().setJson("user", obj("name", "Jo"))
local().getJsonOr("user", obj())
session().set("draft", v("text"))
cookie("theme").days(30).sameLax().set("dark")
onStorageKeyChange("user", callback("e").log(eventNewValue(v("e"))))
```

### Web Audio synthesis

```java
import static jweb.js.JSMedia.*;

Val ctx = audioContext();
Val gain = createGain(ctx);
Val osc = createOscillator(ctx);
connect(osc, gain); connect(gain, destination(ctx));

// Scheduling — these operate on an AudioParam (osc.frequency, gain.gain),
// reached with .dot("frequency")/.dot("gain"); unlike setFrequency/setGainValue
// (which stomp .value), these queue clickless, precisely-timed changes.
setValueAtTime(gain.dot("gain"), 0, 0);
linearRampToValueAtTime(gain.dot("gain"), 1, 0.05);
exponentialRampToValueAtTime(gain.dot("gain"), 0.0001, 0.4);

resume(ctx);        // short form of resumeAudioContext(ctx) — run from a
                    // click/keydown handler; AudioContext starts suspended
audioState(ctx);    // short form of audioContextState(ctx) → ctx.state

// Buffered playback
Val buffer = createBuffer(ctx, 1, 44100, 44100);
Val source = createBufferSource(ctx);
```

### WebSocket (client)

```java
import static jweb.js.JSWebSocket.*;

webSocket(wsUrl("/live"))
    .onOpen(callback().log("connected"))
    .onMessage(callback("e").log(v("e").dot("data")))
    .autoReconnect(3000)
    .build("ws");
```

## How generated JS reaches the page

There is no bundler and no automatic script injection for DSL output. Three mechanisms:

1. **Inline script tags** — the DSL produces a `String`; place it yourself:
   `inlineScript(actions().add(...).build())` (unescaped `VRaw` under the hood; the
   serializer stamps the request's CSP nonce on the tag).
2. **Event handlers** — `onClick(action)` as an element argument (or `attrs().onClick(...)`).
   Inside a page render this emits a `data-jweb-act<type>` attribute and ships the JS
   in a nonce-stamped definitions script the runtime delegates to (inline `on<type>=`
   can never run under the recommended nonce CSP); the serializer applies the same
   rewrite to raw `set("on<type>", js)` strings at render time. Outside a page render
   both fall back to classic inline attributes (`attrs().inlineHandlers()` forces that).
   A Template's `onMount()`/`onUnmount()` hooks return Actions and are wrapped in the
   right listener for you.
3. **Auto-injected framework scripts** — the controller injects three scripts before
   `</body>`: the `Prefetch` script (external, cached `/jweb/prefetch.js`), the
   `__JWEB_DATA__` hydration JSON (inline, per request), and the reactive-state client
   runtime (external, cached `/jweb/runtime.js`; opt out with `jweb.runtime.enabled: false`) —
   see [State & Realtime](./state-and-realtime.md).

<!-- ==================== END javascript-dsl ==================== -->


<!-- ==================== BEGIN state-and-realtime ==================== -->

# State, hydration, WebSocket, SSE, transitions, async rendering

> Source: `readme/guides/state-and-realtime.md` · topic id: `state-and-realtime`

[← Back to README](./../README.md)

# State & Realtime

This document covers reactive state, server-side events, hydration, WebSocket/SSE transport,
and the interactive UI utilities (transitions, portals, refs, toasts, suspense).

> ✅ **The reactive round-trip works end-to-end**: the client runtime is auto-injected into
> rendered pages (disable with `jweb.runtime.enabled: false`), render contexts survive until a
> TTL reaper collects them (5 min idle, refreshed by WebSocket activity), and `live(...)`
> regions re-render on the server and morph into the DOM. See
> [Known Issues](./known-issues.md) for the sharp edges.

## Reactive State

### Creating state

```java
import jweb.state.State;                                // the state value type
import static jweb.State.*;                             // useState & friends (alias of StateHooks)
import jweb.StateManager;

// The constructor is internal — create state through the hooks/manager:
State<Integer> count = useState(0);
State<String>  name  = useState();                      // null initial
State<Integer> other = StateManager.createState(0);     // equivalent
State<Integer> named = StateManager.createState("cart-count", 0);
```

> ⚠️ `State.of(...)` does **not** exist (older docs claimed it did).

### Using state

```java
class Log { static Logger framework() { return new Logger(); } static class Logger { void info(String f, Object... a) {} } }
State<Integer> count = useState(0);
State<List<Integer>> items = useState(new ArrayList<>());
int x = 1;

count.get();                 // read
count.set(5);                // write (no-op if value unchanged); marks dirty; notifies
count.update(n -> n + 1);    // transform — returning the same instance (a list you added to) still notifies
items.mutate(l -> l.add(x)); // mutate in place and notify
count.subscribe(v -> Log.framework().info("count is now {}", v));
count.getId();               // "state_<n>" — used by client bindings
count.toJson();              // {"id":"state_1","value":5}
```

### Hooks

```java
class Log { static Logger framework() { return new Logger(); } static class Logger { void info(String f, Object... a) {} } }
State<Integer> price = useState(10);
State<Integer> qty = useState(2);

// Computed state — re-evaluates whenever a dependency changes
State<Integer> total = useComputed(
    () -> price.get() * qty.get(), price, qty);

// Effect — runs immediately AND on every dependency change (no cleanup fn, no diffing)
useEffect(() -> Log.framework().info("qty changed"), qty);
```

### Contexts

`StateManager` scopes states per request in a `StateContext` (ThreadLocal + a registry keyed by
`ctx_<uuid>` — unguessable). `JWebController` creates a context per router request, renders,
serializes changed state into the hydration payload, then detaches the ThreadLocal — the
registry entry survives so WebSocket events can restore it. Contexts idle longer than
5 minutes are reaped by a background cleanup task (WebSocket activity refreshes the TTL).

### Binding state to elements

Four element arguments keep the DOM in step with state, with no client code written.
`bind`/`bindInput` live in `jweb.El`; `bindAttr`/`bindClass`/`live` in `jweb.State` — the two
wildcards coexist.

```java
import static jweb.El.*;
import static jweb.State.*;

record Person(String name) {}
record Todo(String text) {}
List<Todo> visible(List<Todo> list, String f) { return list; }

State<Integer> clicks = useState(0);
State<String> name = useState("");
State<Boolean> saving = useState(false);
State<Boolean> active = useState(true);
Runnable save = () -> saving.set(false);
State<List<Integer>> items = useState(new ArrayList<>());
State<Person> user = useState(new Person("Ada"));
State<List<Todo>> todos = useState(new ArrayList<>());
State<String> filter = useState("all");
State<String> first = useState("Ada");
State<String> last = useState("Lovelace");

// Text: bind renders the value AND patches it on every change
p("Clicks: ", span(bind(clicks)))               // <span data-state-bind="state_1">0</span>
span(bind(clicks), "Total: " + clicks.get())    // other content? then bind is just the attribute

// Input: value (or checked) rendered, and every keystroke sent back — two-way
input(type("text"), bindInput(name))

// Attribute and class, by truthiness (true / non-zero / non-empty set it; false / 0 / "" / null clear it)
button(bindAttr(saving, "disabled"), onClick(e -> save.run()), "Save")
li(bindClass(active, "on"), class_("tab"), "Home")   // put bindClass before class_(...)

// A region: re-rendered on the server from the state, morphed into the page
live(items, list -> ul(each(list, i -> li(i))))
live(user, u -> u == null ? a(href("/login"), "Sign in") : span("Hi " + u.name()))
live(todos, filter, (list, f) -> ul(each(visible(list, f), t -> li(t.text()))))
live(() -> p(first.get() + " " + last.get()), first, last)   // any number of states
```

**Live regions** are the answer to lists, conditionals and anything structural: the body
receives the current value and returns any element; its root gets a `data-live="live_N"`
attribute. When an event changes one of the region's states, the server re-renders the body
and ships the HTML as a `domUpdate` patch; the runtime **morphs** it into the existing element
— unchanged nodes stay, so focus, scroll position and typed input survive. A region only
re-renders for the states it was given (none listed = every change). Handlers inside the body
are re-registered on each render, like the first one. `useComponent(id, supplier)` is the
deprecated pre-3.0 form (a `<div id>` wrapper that re-renders on any change).

The client contract, for reference:

| Attribute | Behavior on state change |
|-----------|--------------------------|
| `data-state-bind="state_1"` (also legacy `data-state`) | text or value updated |
| `data-state-input="true"` + `data-state-bind` | input sends `setState` on every change |
| `data-state-attr="disabled=state_1"` | attribute set while truthy, removed otherwise |
| `data-state-class="on=state_1"` | class added while truthy, removed otherwise |
| `data-live="live_1"` | element morphed with the server's re-render |
| `data-state-text="on:off"` | picks text by truthiness |
| `data-state-toggle="state_1"` | toggles the `toggle-on` class |

`bind(state)` / `bindInput(state)` (in `jweb.El`) are element arguments carrying
`data-state-bind`; `bind(state)` renders the value itself, so a live counter is one line:

```java
State<Integer> clicks = useState(0);

p("Clicks: ", span(bind(clicks)))
button(onClick(e -> clicks.update(n -> n + 1)), "Click me")
```

A `jweb:stateChange` CustomEvent fires on every patch. The JS DSL reads the client's copy
of a state and listens for those patches without touching the event by hand:

```java
import static jweb.Js.*;

State<Integer> clicks = useState(0);

syncState(clicks)                                     // a Val — JWeb.getState('state_1')
button(onClick(setText("total", syncState(clicks).plus(1))), "+1")
onStateChange(clicks, callback("now", "before").log(v("now")))   // an Action
```

Events: `jweb:stateChange` fires on every state patch, `jweb:liveUpdate` after a region morphs.
The whole protocol lives in one runtime function (`initLive`) and one server class
(`StateBinding` + `LiveRegion`).

## Server-Side Events (`events/`)

Attach Java lambdas to DOM events; the framework registers them and renders a JS call:

```java
State<Integer> count = useState(0);

button(onClick(e -> count.update(n -> n + 1)), "Increment")
// renders: <button onclick="JWeb.call('h_1_9f3c2a…', event)">Increment</button>
```

- `EventRegistry.register(type, Consumer<Event>)` stores the handler under an unguessable id
  (`h_<n>_<random>` — counter for uniqueness, random hex suffix).
- The `Event` interface exposes: `value()`, `targetId()`, `type()`, `key()`, `keyCode()`,
  modifier keys, `clientX/Y()`, `checked()`, `formData()`, `data(name)`/`dataset()`.
- `preventDefault()`/`stopPropagation()` set flags on the server-side `DomEvent`; form submits
  are always prevented client-side before sending.
- Registration is **context-scoped when a render context is active** (the normal case):
  handlers get unguessable IDs (`h_<n>_<random>`), live in the context's namespace, and are
  evicted when the context dies. Outside a render they register globally (static export).
- **Handler ids are capabilities.** A WebSocket message that names a context can only run
  that context's handlers — there is no fallback to the global registry — and a message for
  a context that no longer exists is logged and dropped (the client sees a
  `context_expired` error and should reload). Only messages that name no context reach the
  global registry. See [Known Issues](./known-issues.md#the-handler-capability-model).
- The client populates `formData` for submits and `dataset` for every event
  (`Event.data("userId")` reads `data-user-id`).

## Hydration (`hydration/`)

For every `Element` response, the controller injects three scripts before `</body>`
(prefetch, hydration data, and the JWeb client runtime):

1. the `Prefetch` hover-prefetch script (external, cached `/jweb/prefetch.js`),
2. `<script id="__JWEB_DATA__" type="application/json">{"contextId":"ctx_...","vnode":null,"state":[{"id":"state_1","value":0}],"handlers":[]}</script>` (inline, per request), and
3. the JWeb client runtime (external, cached `/jweb/runtime.js`).

`HydrationData.builder()` supports `vnode(...)`/`handlers(...)` too, but the controller
currently populates only `contextId` + `states`. `VNodeSerializer` (VNode → JSON:
`{"type":"element","tag":...,"attrs":...,"children":[...]}`) is wired into
`HydrationData.builder().vnode(...)`, but no caller populates a vnode yet.

The consumer of this payload is `JWebRuntime` (`js/JWebRuntime.java`): it defines the global
`JWeb` object (`JWeb.init`, `JWeb.call`, WebSocket connect with reconnect + 30s ping) and reads
`__JWEB_DATA__` on `DOMContentLoaded`. It is **auto-injected** into rendered pages; opt out
with `jweb.runtime.enabled: false` (then `JWebRuntime.getScriptTag()` lets you inject it
manually, e.g. for pages rendered outside the controller).

## WebSocket (`websocket/`)

- `JWebSocketConfig` registers `JWebSocketHandler` at **`/jweb`**. Origins default to
  **same-origin only**; allow others with `jweb.websocket.allowed-origins` (comma-separated,
  `*` for dev).
- Message protocol (JSON, `type` discriminator):
  - client → server: `event` (handler id, contextId, event payload, formData), `init`
    (contextId), `setState` (id, value), `ping`
  - server → client: `connected`, `stateUpdate` (`[{id,value}]`), `domUpdate`
    (`[{id,html}]`), `eventHandled`, `initState` (`states`, and `known:false` when the
    server no longer holds the context the client named), `pong`, `error` (`message`, and
    `code:"context_expired"` when an event names a reaped context)
- On an `event` message the handler restores the `StateContext` by contextId, executes the
  registered handler (in that context only), collects `getChangedStates()`, and pushes
  `stateUpdate` back, followed by one `domUpdate` carrying the re-rendered HTML of every
  `live` region that depends on a changed state (`[{id:"live_1",html:"..."}]`; the runtime
  morphs each into its `data-live` element).

### When the connection drops, and when the page expires

A render context lives on the server for five idle minutes, in memory, on the instance that
rendered the page. The runtime owns what the user sees when that stops being true:

- **The socket closes** (network blip, deploy, server restart): the runtime reconnects with
  exponential backoff — 1s, 2s, 4s… capped at 30s, `jweb.runtime.reconnect-attempts` times
  (default 8). The device coming back online or the tab becoming visible retries at once.
  Server events fired meanwhile are held (up to 20) and delivered when the socket reopens,
  since the context outlives the socket. Past the last attempt, a fixed notice says
  *Connection to the server was lost. Reload to continue.*
- **The context is gone** (reaped after idling, the server restarted, or the socket reached
  an instance that never rendered the page): the server answers the reconnect's `init` with
  `known:false`, or an event with `error` + `context_expired`. The runtime then does what
  `jweb.runtime.expired` says — `banner` (default: *This page has expired. Reload to
  continue.*, with a Reload button), `reload` (reload at once), or `silent` (console only).
  Either way it dispatches `jweb:expired` on `document` for page scripts, and stops
  reconnecting. A page with no server-bound element (no server handler, live region or state
  binding) loses nothing when its context expires, so it is never told.
- The notice is one `div.jweb-notice[data-jweb-notice="expired|lost"]` with `role="alert"`,
  styled through `--jweb-notice-bg`, `--jweb-notice-fg`, `--jweb-notice-btn-bg` and
  `--jweb-notice-btn-fg` (set them on `:root` in a theme to restyle it).

Because contexts are per-instance, a deployment with more than one instance needs sticky
sessions for `/jweb` and the pages that use server events; without them every reconnect lands
on an instance that answers `known:false`. See [Running more than one instance](#running-more-than-one-instance).

## PubSub — one change, every page (`jweb.PubSub`)

A browser event changes one page. `PubSub` is the other direction: publish a message to a
topic, and every page that subscribed re-renders what the message touched — on this
instance and, with Redis, on every other. The chat room, the dashboard that follows a job,
the list that shows what someone else just added.

```java
import jweb.PubSub;
import static jweb.El.*;
import static jweb.State.*;

record Post(String author, String text) {}
String room = "42";
List<Post> recent = new ArrayList<>();

// In a page: subscribe while rendering, and the page's live regions follow the topic
State<List<Post>> posts = useState(recent);
PubSub.subscribe("room:" + room, Post.class, post -> posts.mutate(list -> list.add(post)));
Element wall = live(posts, list -> ul(each(list, p -> li(p.author() + ": " + p.text()))));

// From anywhere — a handler, a REST endpoint, a job, another instance
PubSub.publish("room:" + room, new Post("ada", "hello"));
```

A subscription made during a render belongs to that page's context: the handler runs in the
context, the live regions reading the states it changed are re-rendered, and the patches go
down the page's socket — exactly what a browser event does, started from the server. It ends
with the context (closed, or reaped after five idle minutes). Subscribe once per page, in
`render()`, not inside a `live` body, which re-runs. A page whose socket is closed still runs
the handler, so its state is current when it reconnects; nothing is sent until then.

A subscription made outside a render is a plain server-side listener until its `close()`:

```java
import jweb.PubSub;

record Order(long id, String status) {}

PubSub.Subscription audit = PubSub.subscribe("orders", Order.class, o -> System.out.println("order " + o.id() + " " + o.status()));
PubSub.Subscription raw = PubSub.subscribe("orders", json -> System.out.println(json));   // the JSON text
audit.close();
raw.close();
int listening = PubSub.subscribers("orders");
```

Messages are JSON on the wire — locally too, so a page behaves the same on one instance and
on five. The handler gets the message parsed to the class it asked for: a `String` arrives
as a `String`, a record as that record, `Map.class` as a map. Delivery runs on one thread in
publish order; a handler that throws is logged and the others still run.

**Backends.** `PubSub.Memory` (the default) reaches the pages on this instance.
`jweb.RedisPubSub` reaches every instance through Redis and needs no client library — it
speaks the Redis protocol over a socket:

```yaml
jweb:
  pubsub:
    redis:
      url: ${REDIS_URL:redis://localhost:6379}   # redis://[user[:password]@]host[:port], rediss:// for TLS
      prefix: jweb:                               # the channel a topic becomes: jweb:room:42
```

Set the URL and every instance with the same one shares every topic; the subscriber
connection reconnects with backoff when it drops, the publisher reconnects on the next
publish, and a publish while Redis is unreachable throws `PubSub.PublishException` — nothing
is queued. Any `PubSub.Backend` bean (NATS, Kafka, a database table) replaces both.

**In a test**, `JWebTest.render(page)` gives a `RenderedPage` whose `awaitPush()` is the
socket's side of a publish:

```java
import jweb.PubSub;
import jweb.RenderedPage;
import jweb.JWebTest;
import jweb.Template;
import static jweb.El.*;

Template roomPage = () -> p("the room page");
record Post(String author, String text) {}

try (RenderedPage page = JWebTest.render(roomPage)) {
    PubSub.publish("room:42", new Post("ada", "hello"));
    RenderedPage.Result push = page.awaitPush();        // the patches, applied to page.html()
    push.patches();                                      // region id -> new HTML
}
```

## Running more than one instance

What is per-instance, what is shared, and what to do about each:

| Piece | Lives | With several instances |
|-------|-------|------------------------|
| Render contexts (live regions, server handlers, `useState`) | In memory on the instance that rendered the page, five idle minutes | **Sticky sessions** for `/jweb` and the pages that use server events. A socket that lands elsewhere is answered `known:false` and the page shows its expired notice. This is the LiveView / Blazor Server model, by design: the context holds lambdas, not data. |
| `PubSub` | In memory by default | Set `jweb.pubsub.redis.url` (or a `PubSub.Backend` bean): a publish on one instance reaches the pages on all |
| HTTP session (`Session`, `Auth.login`, flash, the OAuth2 state) | The servlet session, in memory | Add **Spring Session** and `jweb.Session` works unchanged — it wraps the servlet session. `spring-session-jdbc` keeps sessions in the database `Sql` already uses (its schema ships in the jar: `spring.session.jdbc.initialize-schema: always`); `spring-session-data-redis` needs Lettuce |
| OAuth2 login state and PKCE verifier | The session | Shared with it; the callback can land on any instance |
| Durable `Jobs.enqueue` | The `jweb_jobs` table when `Sql` is connected | Shared: every instance's worker claims from the one queue, one job runs once |
| `Jobs.run`, `Scheduler` | The instance that scheduled them | Each instance runs its own schedule: wrap the work in `Cache.once("name", ttl, …)` and the instance that takes the lock runs it, the rest skip |
| `Mail` | The instance's SMTP connection | Nothing to share; `later()` goes through the durable queue |
| `Cache.create`/`named` | In memory, per instance | Use `Cache.shared(name, Type.class)` for what every instance must see: Redis under `jweb.cache.redis.url`, with locks (`Cache.lock`, `Cache.once`) |
| `RateLimit` | In memory, per instance | Counts in the shared store on its own once `jweb.cache.redis.url` is set: 100/min is 100/min across the fleet |
| `Migrations` | The database | Instances starting together take turns on a lock row |
| `Storage` (uploaded files) | The instance's `jweb.storage.dir` | Set `jweb.storage.s3.bucket`: every instance reads and writes the one bucket, and `Storage.url` points at it |

## Session

`jweb.Session` is the visitor's session — typed, null-safe, with one-shot flash messages —
so nothing hand-rolls `getAttribute`/`instanceof`/`setAttribute` again:

```java
import jweb.Session;

// A per-visitor object, created on first use through its no-arg constructor
Visit visit = Session.of(Visit.class, req);
visit.entered = true;
Session.of(Visit.class, req, () -> new Visit(defaults));   // or with a factory

// Keyed values
Session session = Session.of(req);
session.put("theme", "dark");
String theme = session.get("theme", String.class);          // null when absent
String theme = session.get("theme", String.class, "light"); // with a default
Optional<Cart> cart = session.find(Cart.class);
session.remove("theme"); session.has(Cart.class);

// Flash — written now, read once on the next page
session.flash("notice", "Saved!");
String notice = session.flash("notice");                    // then it is gone

// Lifetime
session.exists(); session.id(); session.end();              // end() = logout's invalidate

// Inside a page, without a Request parameter (page routes, router handlers, streamed blocks)
public Element render() {
    Visit visit = session().of(Visit.class);
    ...
}
```

**Lifetime:** the session is the servlet container's — created on the first write (reads never
create one), carried by the `JSESSIONID` cookie (HttpOnly, SameSite=Lax; set
`SESSION_COOKIE_SECURE=true` behind HTTPS), expired after the idle timeout
(`server.servlet.session.timeout`, 30 minutes by default), ended by `session.end()` /
`Auth.logout`. Values live in server memory: keep them small; a typed value whose class
changed shape across a deploy simply starts fresh. `Template.session()` and `Session.current()`
read the request in flight, so they are not available inside WebSocket event handlers —
capture what a handler needs at render time.

## Server-Sent Events (`sse/`)

Working server-side primitives for one-way streaming:

```java
import jweb.SseEmitter;
import jweb.SseBroadcaster;
import jweb.SseEvent;

Object payload = Map.of("count", 1);
Object obj = Map.of("count", 1);
Object data = Map.of("count", 1);
SseEvent event = SseEvent.of("Breaking!");
boolean someCondition = true;

SseBroadcaster broadcaster = new SseBroadcaster();       // 15s heartbeat comments
SseBroadcaster quiet = new SseBroadcaster(0);            // heartbeat disabled

// Events
SseEvent.of("plain data");
SseEvent.of("eventName", "data");
SseEvent.json("update", payload);                        // JSON-serialized
SseEvent.create().id("42").name("tick").data(obj).retry(3000).build();

// Broadcast
broadcaster.broadcast("New notification!");
broadcaster.broadcast(SseEvent.json("update", data));
broadcaster.broadcast("news", SseEvent.of("Breaking!"));  // channel-scoped
broadcaster.broadcastIf(em -> someCondition, event);
broadcaster.getSubscriberCount(); broadcaster.shutdown();
```

> **Serving the stream:** JWeb router handlers can return the emitter directly — the
> controller passes SSE emitters (JWeb's `SseEmitter` or Spring's) through to Spring MVC for
> streaming. Spring `@RestController`s work too.

```java
SseBroadcaster broadcaster = new SseBroadcaster();

// JWeb router route
app.get("/events", req -> {
    SseEmitter emitter = SseEmitter.create(0);   // 0 = no timeout
    broadcaster.subscribe(emitter);
    return emitter;                              // or emitter.toResponse()
});
```

Client side, use `sse("/api/v1/events").onMessage(...).build()` from the JS DSL
(`import static jweb.Js.*;`).

## View Transitions (`transition/`)

```java
import jweb.Transition;

boolean isVisible = true;
Element content = div();

// Conditional show/hide with enter animation classes
Transition.when(isVisible)
    .enter("jweb-fade-enter", 300)
    .render(() -> content)

Transition.fade(isVisible, () -> content);       // presets: fade, slideDown, scale
style(Transition.css());                          // emits the .jweb-fade-*/-slide-*/-scale-* rules
```

When hidden, nothing renders; when shown, enter classes and `data-transition`/
`data-enter-class`/`data-enter-duration` (plus `data-leave-*`) attributes are added. The JWeb
runtime removes enter classes after the enter duration, and `JWeb.leave(elOrId, callback)`
applies the leave classes, waits the leave duration, then removes the element.

For CSS `transition:` properties on any element, use `attrs().transition()...done()` (see the
CSS DSL doc).

## Portals (`portal/`)

Render content into a named outlet elsewhere in the tree (modals, toasts, tooltips):

```java
import jweb.Portal;

// In the layout — outlets must render AFTER all Portal.to() calls (put them last in body)
body(
    div(id("app"), nav, main(content)),
    Portal.outlet("modals"),
    Portal.outlet("toasts")
)

// Anywhere during the same render:
Portal.to("modals", div(class_("modal"), h2("Confirm"), ...));
Portal.modal(content);     // shorthand for to("modals", ...)
Portal.tooltip(content);   // "tooltips"
Portal.toast(content);     // "toasts"
```

> Storage is a ThreadLocal that `JWebController` clears at the end of every request, so
> content can't leak between requests on pooled threads. Call `Portal.clear()` yourself only
> when rendering outside the controller (e.g. background jobs).

## Refs (`ref/`)

Type-safe element references whose methods are `Action`s, so they plug into any handler:

<!-- nocompile: Ref has no jweb.* alias yet (framework.ref.Ref only), and a docs sample may not name a long framework package -->
```java
Ref inputRef = Ref.create();          // id "jweb-ref-<n>"; or Ref.of("existing-id")

form(
    input(ref(inputRef), type("text")),
    button(onClick(inputRef.focus()), "Focus the input")
)

inputRef.scrollIntoView(); inputRef.addClass("highlight");   // Actions
inputRef.set("value", "hello");                              // Action
inputRef.get("value");                                       // a JS expression (Val)
inputRef.selector();                                         // document.getElementById('...')
```

## Toasts (`ui/Toast`)

```java
import jweb.Toast;

// One-time setup in the layout (container + styles + script):
body(content, Toast.setup())                       // or setup(Position.TOP_RIGHT)

// Trigger from any handler — typed Actions:
button(onClick(Toast.success("Saved!")), "Save")
Toast.error("Failed"); Toast.warning("Careful"); Toast.info("FYI");
// (the *Js String variants are deprecated)

// Show on page load:
Toast.initial(Toast.Type.SUCCESS, "Welcome back!")

// Builder — the action button takes an Action too
Toast.builder().type(Toast.Type.INFO).message("Update available")
     .duration(8000).action("Reload", reload()).build()
```

(The methods are `toastScript()`/`init()`/`setup()` — there is no `Toast.script()`.)

## UI Components (`ui/UI`)

A large static library of prebuilt components: `primaryButton/secondaryButton/dangerButton`,
`badge`, `tag`, `alert` (+info/success/warning/error variants), `card`, `avatar`,
`progressBar`, `spinner`, `skeleton`, `emptyState`, `tooltip`, `breadcrumb`, `pagination`,
`kbd`, `codeBlock` — plus fluent builders:

```java
UI.Modal.create("confirm").title("Delete?").body(p("This cannot be undone."))
   .footer(UI.dangerButton("Delete", e -> ...)).build()
button(onClick(UI.Modal.open("confirm")), "Delete")   // open/close are Actions

UI.Tabs.create("settings").tab("general", "General", generalPanel)
   .tab("advanced", "Advanced", advancedPanel).style(UI.Tabs.TabStyle.PILLS).build()

UI.Dropdown.create("menu").trigger(button("Options"))
   .item("Edit", e -> ...).divider().item("Delete", e -> ...).build()

UI.Accordion.create("faq").item("Q1", a1).item("Q2", a2).allowMultiple().build()

UI.DataTable.<User>create()
   .column("Name", u -> text(u.name()))
   .column("Email", u -> text(u.email()))
   .data(users).striped().hoverable().build()
```

Include the matching scripts once per page: `UI.uiScripts()` (or the individual
`modalScript()`, `tabsScript()`, `dropdownScript()`, `accordionScript()`).

## Prefetch (`performance/`)

Auto-injected on every Element response. Hovering any `<a href>` (or `[data-prefetch-url]`)
prefetches the target; opt out per link with `data-no-prefetch="true"`.

Config (`jweb.yaml`): `jweb.performance.prefetch.enabled` (true), `.cache-ttl` (300000 ms),
`.hover-delay` (yaml sets 300 ms; code default is 100 ms).

The richer SPA navigation runtime (partial swaps, View Transitions, active-link tracking) is
separate: add `Navigation.script()` to your layout and use `Link` builders — see the
architecture doc.

## Async rendering (`async/Suspense`)

```java
import jweb.Suspense;
import static jweb.Suspense.*;

record User(String name) { String getName() { return name; } }
class UserService { List<User> getUsers() { return List.of(); } }
UserService userService = new UserService();
UserService slowApi = new UserService();
Element userList(List<User> users) { return ul(each(users, u -> li(u.getName()))); }
Supplier<Integer> loader = () -> 1;
var loadingElement = UI.spinner();
Function<Integer, Element> contentFn = n -> div(String.valueOf(n));
Function<Throwable, Element> errorFn = e -> UI.errorAlert(e.getMessage());

// Blocking (default): loader runs inline during render
Suspense.of(() -> userService.getUsers())
    .loading(() -> UI.spinner())                  // shown only in non-blocking timeout mode
    .error(e -> UI.errorAlert("Failed: " + e.getMessage()))
    .render(users -> ul(each(users, u -> li(u.getName()))));

// Non-blocking: give the loader a time budget; render loading state if exceeded
suspendFast(() -> slowApi.getUsers(), UI.spinner(), users -> userList(users));
suspendFast(() -> slowApi.getUsers(), UI.spinner(), 200, users -> userList(users));

// Convenience statics
suspend(loader, loadingElement, contentFn);
suspend(loader, loadingElement, errorFn, contentFn);
suspendSilent(loader, contentFn);                 // render nothing on failure
```

> `.timeout(n, unit)` bounds blocking loads (default 30s) — on expiry the error element
> renders. `nonBlocking(...)`/`suspendFast(...)` instead show the loading element when data
> isn't ready in time.

## Background Jobs (`async/Jobs`, `async/Scheduler`)

```java
import java.util.concurrent.CompletableFuture;

class Log { static Logger framework() { return new Logger(); } static class Logger { void info(String f, Object... a) {} void error(String f, Object... a) {} } }
record User(String email) {}
record Report(String body) {}
User user = new User("a@b.com");
void sendEmail(User u) {}
Report generateReport() { return new Report(""); }
Report doImport() { return new Report(""); }
void sendReminder() {}
void cleanupTempFiles() {}
void generateDailyReport() {}
void cleanupFiles() {}
void sendDigest() {}
void sync() {}

// Fire-and-forget on virtual threads
Jobs.run(() -> sendEmail(user));
CompletableFuture<Report> f = Jobs.submit(() -> generateReport());

// Tracked tasks with progress
BackgroundTask<Report> task = Jobs.trackWithProgress("Import", progress -> {
    progress.update(50, "halfway");
    return doImport();
});
Jobs.getTask(task.getId()).ifPresent(t ->
    Log.framework().info("{} {}%", t.getStatus(), t.getProgress()));
Jobs.cleanupCompletedTasks();    // call periodically — the task map is not self-cleaning

// Simple scheduling
Jobs.delay(Duration.ofSeconds(30), () -> sendReminder());
Jobs.schedule("cleanup", Duration.ofMinutes(5), () -> cleanupTempFiles());
```

<!-- nocompile: Scheduler has no jweb.* alias yet (framework.async.Scheduler only), and a docs sample may not name a long framework package -->
```java
// Cron scheduling (5-field: min hour dom month dow, 0=Sunday; supports * , - /)
Scheduler.cron("daily-report", "0 9 * * *", () -> generateDailyReport());
Scheduler.job("cleanup").cron("0 3 * * *").timezone("America/New_York")
         .initialDelay(Duration.ofMinutes(1))
         .onError(e -> Log.framework().error("job failed", e))
         .run(() -> cleanupFiles());
Scheduler.daily("digest", LocalTime.of(9, 0), () -> sendDigest());
Scheduler.everyMinutes(15, "sync", () -> sync());
Scheduler.pause("sync"); Scheduler.resume("sync"); Scheduler.cancel("sync");
Scheduler.getAllJobs();          // List<JobInfo(name, schedule, paused, lastRun, runCount, active)>
```

### Durable jobs (`Jobs.handle`, `Jobs.enqueue`)

`Jobs.run` is fire-and-forget in this JVM: a restart loses it and an exception ends it. A
**durable job** is named, carries a payload, is stored, retried with backoff, dead-lettered
after the last attempt, and — on the SQL store — survives a restart and is shared by every
instance on the database.

```java
import jweb.Jobs;
import jweb.Mail;

record Welcome(long userId, String email) {}
record Digest(long userId) {}

// once, at startup (a JWebRoutes.configure, a @Component constructor): what each job does
Jobs.handle("welcome", Welcome.class, w -> Mail.to(w.email()).subject("Welcome").text("Hello!").send());
Jobs.handle("reindex", () -> System.out.println("rebuilding the search index"));

// anywhere: enqueue — returns at once, the worker runs it
Jobs.Job job = Jobs.enqueue("welcome", new Welcome(42, "ada@example.com"));
Jobs.enqueue("digest", new Digest(42), Jobs.options().after(Duration.ofHours(1)).retries(5));
Jobs.enqueue("reindex", null, Jobs.options().key("reindex"));   // one pending at a time

// looking
Jobs.find(job.id());             // Optional<Jobs.Job>: status, attempts, runAt, error
Jobs.pending(); Jobs.running(); Jobs.failed();
Jobs.retry(job.id());            // a failed job back in the queue
Jobs.stats();                    // counts by status
```

- **Stores.** `Jobs.Memory` (the default) keeps jobs in this JVM. `Jobs.SqlStore` keeps them
  in the `jweb_jobs` table (created if missing) and is installed automatically when `Sql`
  is connected; each instance claims a job with one atomic update, so a job runs once. Any
  `Jobs.Store` bean replaces both; `jweb.jobs.store: memory` keeps the in-memory one even
  with a database.
- **The worker** starts with the application: it polls every `jweb.jobs.poll` (1s), runs
  due jobs on virtual threads (`jweb.jobs.concurrency`, 8), and on an exception schedules
  the next attempt at 2, 4, 8… seconds (capped at an hour) until the retries (default 3)
  are spent — then the job is `FAILED` with the error, kept for `Jobs.failed()` and
  `Jobs.retry(id)`. A job still `RUNNING` after `jweb.jobs.timeout` (10 minutes; its
  worker died) is released; finished jobs are purged after `jweb.jobs.keep-done` (24 hours).
  `jweb.jobs.enabled: false` keeps an instance from running jobs (it can still enqueue).
- **In a test**, `Jobs.runPending()` runs what is due on the calling thread and returns how
  many ran — no waiting for the poller.
- Timestamps are compared in the JVM's zone; run every instance in the same one (UTC).

## Cache (`cache/Cache`)

```java
import jweb.Cache;                                        // Js.Cache (a JS runtime module) collides otherwise

record User(String name) {}
record Report(String body) {}
static User user = new User("Ada");
static User load(String id) { return user; }

var users = Cache.<String, User>create(Duration.ofMinutes(10));
var global = Cache.<Object>global();                      // shared singleton, 5-min default TTL
var reports = Cache.<String, Report>named("reports", Duration.ofHours(1));

users.set("42", user);
users.set("42", user, Duration.ofMinutes(1));             // per-entry TTL
User u = users.getOrSet("42", () -> load("42"));          // atomic per key (single compute)
users.has("42"); users.delete("42"); users.touch("42");   // extend TTL
users.ttl("42"); users.stats();                           // CacheStats(total, active, expired, maxSize)
```

Background cleanup runs every minute. Size-capped caches evict the nearest-expiry entry (not
LRU).

### Shared across instances (`Cache.shared`, `Cache.once`, `Cache.lock`)

The caches above live in this instance's memory. The shared side is what every instance
sees — a Redis when `jweb.cache.redis.url` (or `jweb.redis.url`, for cache and PubSub
together) is set, spoken by the framework itself; this instance's memory otherwise, with the
same semantics, so code and tests do not change:

```java
import jweb.Cache;

record Price(String sku, long cents) {}
static Price lookup(String sku) { return new Price(sku, 100); }
static void sendDigest() {}

Cache.Shared<Price> prices = Cache.shared("prices", Price.class, Duration.ofMinutes(10));   // values travel as JSON
Price p = prices.getOrSet("sku-9", () -> lookup("sku-9"));
prices.set("sku-9", p, Duration.ofHours(1));
Optional<Price> cached = prices.get("sku-9");
prices.delete("sku-9");
prices.clear();                                                                          // every entry of this cache, everywhere
Cache.shared("counts", Long.class).increment("signups", 1, Duration.ofDays(1));

// Work only one instance should do
boolean ranHere = Cache.once("digest", Duration.ofMinutes(5), () -> sendDigest());      // whoever gets the lock runs; "once per 5 minutes"
try (Cache.Lock lock = Cache.lock("import", Duration.ofMinutes(1)).orElseThrow()) {    // held until closed, or until a dead holder's minute is up
    // ...
}
```

A store that cannot be reached is a miss for `get`, a no-op for `set`, "not acquired" for a
lock, and `RateLimit` lets the request through — logged once every thirty seconds, never a
500. `RateLimit` counts in the shared store on its own when one is configured (`.shared(false)`
keeps a limiter local, `.name("contact")` names it when the path, limit and window do not),
so `RateLimit.perMinute(100)` is a hundred per minute across the fleet.

<!-- ==================== END state-and-realtime ==================== -->


<!-- ==================== BEGIN three-dsl ==================== -->

# Three DSL: declarative 3D scenes — shapes, lights, shadows, models, click events

> Source: `readme/guides/three-dsl.md` · topic id: `three-dsl`

[← Back to README](./../README.md)

# Three DSL — 3D scenes in pure Java

Declarative 3D rendered with [three.js](https://threejs.org), declared the way
you declare HTML. The scene graph is a tree; JWeb's nested varargs describe
trees — so a scene is just another element:

```java
import static jweb.El.*;
import static jweb.Css.*;
import static jweb.Three.*;

div(class_("hero"),
    scene(style().height(px(420)),
        camera().position(0, 1.5, 4).orbit(),
        directionalLight().position(3, 5, 2),
        box().color("#10b981").spin()
    )
)
```

That is a complete, animated, orbit-controllable 3D scene. The raw three.js
equivalent is ~30 lines of imperative ceremony — scene, camera, renderer,
canvas mounting, resize handling, a `requestAnimationFrame` loop, and manual
`dispose()` calls to avoid GPU memory leaks. The JWeb runtime owns all of it.

## How it works

`scene(...)` returns a regular element (a `div`) carrying the serialized scene
graph in a `data-three` attribute. On the client:

- The JWeb runtime notices `[data-three]` and lazy-loads the vendored three.js
  bundle (~170KB gzipped, immutable-cached, served from your own jar at
  `/jweb/three-bundle.js`) plus a small interpreter. **Pages without scenes
  load neither** — the zero-JS default is untouched.
- The interpreter builds the scene, sizes it to its container with a
  `ResizeObserver`, and renders. A render loop runs **only if something
  animates**; still scenes render on demand (on drag, on resize) and cost
  zero CPU otherwise.
- When a fragment swap or morph replaces or changes the scene element, the
  interpreter re-initializes from the new JSON; when the element leaves the
  DOM, everything is disposed — geometries, materials, textures, the WebGL
  context.

Because `scene(...)` is a `Tag`, every HTML attribute chains on it:
`.id("hero")`, `.class_("card")`, styles, anything.

## Shapes

| Factory | three.js geometry | Defaults |
|---|---|---|
| `box()` / `box(2)` / `box(w, h, d)` | BoxGeometry | 1×1×1 |
| `sphere()` / `sphere(r)` | SphereGeometry | radius 1 |
| `plane()` / `plane(w, h)` | PlaneGeometry | 1×1, double-sided |
| `cylinder()` / `cylinder(r, h)` / `.radii(top, bottom)` | CylinderGeometry | radius 1, height 1 |
| `cone()` / `cone(r, h)` | ConeGeometry | radius 1, height 1 |
| `torus()` / `torus(r, tube)` | TorusGeometry | radius 1, tube 0.4 |
| `torusKnot()` / `torusKnot(r, tube)` | TorusKnotGeometry | radius 1, tube 0.4 |
| `capsule()` / `capsule(r, length)` | CapsuleGeometry | radius 1, middle 1 |
| `disc()` / `disc(r)` | CircleGeometry | radius 1, double-sided |
| `ring()` / `ring(inner, outer)` | RingGeometry | 0.5 → 1, double-sided |
| `tetrahedron(r)` `octahedron(r)` `dodecahedron(r)` `icosahedron(r)` | the platonic solids | radius 1 |
| `terrain(w, d)` / `.hills(h)` / `.hills(h, scale)` | PlaneGeometry, displaced | flat, lying down |

(`disc`, not `circle` — the SVG element owns that name under dual wildcard
imports. Same reason the text billboard below isn't called `label`, and the
polyline is `wire`, not `line`.)

`terrain` is ground that already lies flat — y up, centered on its
position, no rotation to remember — with rolling hills on request:

```java
terrain(60, 60).color("#5B7F4A")                  // a lawn
terrain(60, 60).hills(1.5).color("#C9B58A")       // low dunes
terrain(60, 60).hills(3, 12).seed(4).detail(128)  // taller hills 12 units across,
                                                  // reshuffled, finer mesh
```

Hills are seeded and deterministic (a re-render doesn't reshape the land);
crests rise and troughs dip around the node's height by about half the hill
height each way. `detail` is the mesh resolution per side (default 96,
clamped 8–256). It's a normal mesh: shadows, clicks, hover and materials all
apply.

Every node shares the transform surface — chains never dead-end:

```java
sphere(0.5)
    .position(2, 1, 0)      // x right, y up, z toward the viewer
    .rotation(0, 45, 0)     // degrees — the DSL is degrees everywhere
    .scale(1.5)             // or .scale(x, y, z)
    .name("moon")           // reachable from scripts (see escape hatch)
```

**Angles are degrees end-to-end** (matching the CSS DSL); the runtime converts
to radians. A ground plane is `plane(30, 30).flat()` — the same as `.rotation(-90, 0, 0)`, named.

## Curves

Three factories cover the shapes you'd otherwise fake with chains of
rotated boxes and cylinders:

```java
// a round tube swept along a smooth curve through x,y,z points —
// vines, pipes, cables, ribs, railings. The curve passes through
// every point; .closed() joins it into a loop.
tube(0.05,
    -2, 0, 0,
     0, 1.4, 0,
     2, 0, 0)

// a partial ring — an archway is half a torus. Sweep is degrees,
// counter-clockwise from the ring's +x; rotate the node to hang it.
arc(1.6, 0.09, 180).position(0, 2.1, -4.5)

// a surface of revolution from radius,height pairs, bottom-up —
// pots, vases, columns, domes, bells. Start or end at radius 0 to
// close that end. Double-sided, so open vessels read from inside.
lathe(0, 0,   0.5, 0,   0.35, 0.8,   0.55, 1.1,   0.3, 1.3)
    .segments(48)   // radial resolution (default 32)
```

An elliptical vault is an `arc` squashed with `.scale(1, 0.7, 1)`. One
`tube` through five points replaces a dozen hand-angled segments — and
reads like what it is.

Two more ride the same smooth curve:

```java
// a rectangular profile (width × height) swept along the curve —
// moldings, ribs, rails, gutters, cornices. The square-section tube.
sweep(0.12, 0.06,  -3, 0, 0,  0, 2.2, 0,  3, 0, 0)   // one vault rib, not 216 boxes
    .closed()         // loop it
    .steps(64)        // resolution along the curve (default max(24, points × 8))

// a thin, unlit polyline — outlines, guides, constellations, the pencil
// line of a diagram. (`wire`, because SVG owns `line`.)
wire(-2, 0, 0,  0, 1.4, 0,  2, 0, 0)
    .color("#fde68a").opacity(0.8)
    .closed()                       // last point back to the first
    .dashed(0.2, 0.1)               // dash, gap in scene units
    .draw(1200)                     // draws itself over 1.2s on load; draw(ms, delayMs)
```

`draw` traces the line from nothing when the scene loads (or scrolls into
view); combined with `dashed`, the dashes settle in once the trace is done.
WebGL lines are always one pixel wide whatever the zoom — for a line with
body, use `tube`. Lines are visual only: no clicks, no hover, no shadows.

## Materials

Shapes use three.js's physically-based `MeshStandardMaterial`, with the
platform's own property names:

```java
sphere()
    .color("#f59e0b")           // any CSS color; hex(...) works too
    .metalness(0.7)             // 0 dielectric … 1 metal (default 0)
    .roughness(0.25)            // 0 mirror … 1 diffuse (default 1)
    .emissive("#331100")        // self-illuminated glow
    .opacity(0.85)              // enables transparency
    .wireframe()                // edges only
    .texture("/assets/crate.png")  // image as the material map
```

### Glass

```java
box().glass()                                  // clear: light passes through and refracts
sphere().glass(0.6).color("#cfe8ff")           // transmission 0–1, tinted
cylinder().glass().roughness(0.4)              // frosted (default roughness 0.05)
```

Real transmission (`MeshPhysicalMaterial`), not a see-through opacity trick
— the scene behind bends through it. It costs a second render of what's
behind the glass; a vase or two is fine, a glass floor is a choice.

### Material presets

Declare a surface once and hand it to any number of shapes:

```java
var brass = material().color("#A07C4B").metalness(0.85).roughness(0.35);
var frosted = material().glass(0.9).roughness(0.4);

box().material(brass)
sphere().material(brass).roughness(0.1)     // brass, but polished
cylinder().material(frosted)
```

A preset carries every material verb (`color`, `emissive`, `metalness`,
`roughness`, `opacity`, `wireframe`, `texture`, `glass`) and copies only the
properties it set, so explicit calls before or after it still win in call
order. It's a plain Java value — `var` needs no import.

## Billboards

Two nodes always face the camera — for annotating scenes:

```java
billboard("Sun")                             // canvas-rendered text, no font file
    .color("#fde68a")                        // text color (default white)
    .background("rgba(15,23,42,0.85)")       // rounded pill behind it (default none)
    .size(0.5)                               // height in scene units
    .position(0, 1.8, 0)

sprite("/assets/pin.png").size(0.6)          // an image; width in scene units,
    .position(2, 1, 0)                       // height keeps the image aspect
```

Both take the full transform/animation/click surface (`float_()` on a
billboard reads nicely). Size them with `.size(...)` — `.scale(...)` doesn't
apply to billboards.

## Lights and shadows

```java
directionalLight(1.2).position(5, 8, 4)   // sun-like, aims at the origin
ambientLight(0.4)                          // even fill
pointLight(2).position(0, 3, 0)            // bulb-like
hemisphereLight("#bde0fe", "#3a5a40")      // sky + ground wash
spotLight(40).position(0, 6, 0)            // a cone of light, aimed straight down
    .target(0, 1, 0)                       // ...or at a point
    .angle(25)                             // cone half-angle, degrees (default 30)
    .penumbra(0.4)                         // edge softness 0–1 (default 0.3)
    .shadows()
```

A scene that declares **no lights** gets a soft hemisphere light so nothing
renders black. Declare any light to take over completely. Point and spot
lights fall off with distance (physical units): a lamp a few units from
its subject wants an intensity in the tens, not `1`.

Shadows — normally a five-place configuration chore — are one call:

```java
directionalLight(1.2).position(5, 8, 4).shadows()
```

That single `.shadows()` enables the renderer's shadow map (soft PCF),
configures the light's shadow camera, and makes every mesh in the scene cast
and receive. `pointLight(...).shadows()` and `spotLight(...).shadows()`
work the same way.

## Camera

```java
camera()
    .position(6, 4, 8)
    .lookAt(0, 1, 0)       // default: the origin
    .fov(60)               // default 50
    .near(0.1).far(2000)   // defaults shown
    .orbit()               // drag to rotate/zoom (OrbitControls)
    .autoRotate()          // slowly circles on its own; implies orbit
```

`autoRotate(speed)` tunes the lap speed (2 ≈ one lap every 30s). A scene
without a camera gets one at `(0, 0, 5)` looking at the origin. One camera
per scene; the first wins.

Orbit takes limits, so a product viewer can't be zoomed through or flipped
under the floor:

```java
camera().orbit()
    .noZoom()            // the scroll wheel stays with the page
    .noPan()             // the subject stays centered
    .distance(2, 12)     // zoom clamps, camera-to-target
    .polar(20, 90)       // vertical swing, degrees from straight overhead
                         // (90 = never below the horizon)
```

### Walk mode

`walk(eyeHeight)` turns the scene into a place. The scene starts framed
exactly as declared; walking is toggled by any element carrying
`data-three-walk="<scene id>"` — a plain button, no script — or starts by
itself with `autoStart()`:

```java
scene(style().height(px(460)),
    camera().position(0, 2, 7).lookAt(0, 2, 0)
        .walk(1.7)                  // eye height; or walk(eye, speed, run)
        .bounds(-8, -8, 8, 8)       // fenced floor: minX, minZ, maxX, maxZ
        .autoStart()                // W A S D / arrows start walking, no toggle
        .clickToMove()              // double-click the ground to glide there
        .fly(4)                     // hold Space to float, up to 4 units
        .footsteps()                // synthesized steps; or footsteps(url[, volume])
        .sway(),                    // gentle idle drift while framed
    ...
).id("hall")

button(attrs().data("three-walk", "hall"), text("Walk here"))
```

While walking: **W A S D** move, the **arrows** look (← → turn, ↑ ↓ tilt),
dragging looks around, **Shift** runs, **Esc** steps back out to the framed view.

**Feet on the ground.** The walker's feet follow the surfaces underfoot —
steps, ramps, walkways, dune slopes. The eye rides at its height above the
highest upward-facing surface below it: a `terrain()`, stacked boxes, the
floor of a `model()`, anything the scene draws (surfaces under 50% opacity
don't count). Stepping in, the eye settles down to its height and the
gaze levels out. `ground(false)` keeps the eye at a fixed height instead.

**Solid things.** Mark what blocks the walker with `.solid()` — its
world-space footprint, so a whole `group(...)` is one obstacle — or
`.solid(radius)` for a round column or trunk. Things below knee height are
stepped over, things above head height walked under. `bounds(...)` stays
the outer fence; `radius(r)` is the body's radius (default 0.32).

**Where walking begins.** By default where the framed camera stands,
facing the way it looks. `spawn(x, z, yawDeg)` places the walker instead
(0° faces −z) — a visitor coming back through a doorway starts at that
doorway.

**Other inputs.** `pointerLock()` locks the pointer while walking so the
mouse or trackpad alone looks (Esc releases it, clicking re-locks) and
clicks aim from the centre of the view. `touch()` gives
phones a thumb-stick where the thumb lands on the left half of the scene
and drag-to-look on the right. `gamepad()`: left stick moves, right stick
looks, A floats, B runs.

**The page knows where you are.** The scene element, the toggle and
`<body>` carry `three-walking` while walking, and `three-key-w` / `-a` /
`-s` / `-d` / `-space` / `-shift` / `-arrowup`… while each key is down — a
HUD that lights its keys is CSS. The scene element always publishes the
camera's heading as CSS variables, `--three-yaw` and `--three-pitch`
(degrees; `0deg` looks down −z), updated whenever it turns or moves:

```css
.compass .needle { transform: rotate(var(--three-yaw)); }
```

The scene also dispatches bubbling events: `jweb:three-walk`
(`detail.walking`) and `jweb:three-look` (`detail.{yaw, pitch, x, y, z,
walking}`). Head-bob and `sway()` are skipped for visitors who prefer
reduced motion. From scripts: `JWebThree.setWalk(id, on)`,
`JWebThree.walking(id)` and `JWebThree.pose(id)`.

## Scene atmosphere

Scenes are **transparent over the page** by default — a scene in a hero
section shows the page background behind it. Opt into a backdrop:

```java
scene(style().height(px(420)),
    background("#0f172a"),
    fog("#0f172a", 10, 30),   // fade to the color between 10 and 30 units
    grid(),                   // 10×10 reference grid; grid(size, divisions)
    ...
)
```

Fog plus a matching background is the classic infinite-depth look. `grid()`
is a placement aid while composing — delete it when done.

An equirectangular panorama (a plain wide jpg/png) can light the scene —
metallic and glossy materials pick up its reflections:

```java
environment("/assets/studio.jpg")   // reflections only, backdrop unchanged
sky("/assets/dusk.jpg")             // the panorama as visible sky AND light
```

`sky(...)` plus `sphere().metalness(1).roughness(0.05)` is a mirror ball in
two lines.

### Glow and tone

```java
scene(bloom(),                       // bright emissives actually glow
    sphere().emissive("#5FA98A"),    // now a lantern, not a flat bright ball
    ...)
```

`bloom()` is an HDR pass composited before tone mapping (which it implies —
the ACES filmic curve). Dials: `bloom(strength)` or
`bloom(strength, radius, threshold)` — only pixels brighter than the
threshold bloom, so raising it keeps the glow on the truly luminous.
Defaults (0.7, 0.35, 0.85) are calibrated for tasteful lantern-light, not
music-video. The scene must still read with bloom off: bloom is the halo,
not the lamp. Bloom composites over an opaque backdrop — declare a
`background(...)`.

`toneMapped()` alone applies the same cinematic curve with no glow —
highlights roll off instead of clipping. `toneMapped(exposure)` dials
brightness around it.

### Mirrors

```java
plane(20, 20).flat().mirror().color("#4a443e")
```

A real-time planar reflection (three.js `Reflector`). `.color(...)` tints
it — darker is dimmer. `mirror()` is chrome-sharp; a strength turns it
satin:

```java
plane(20, 20).flat().mirror(0.4).color("#4a443e").roughness(0.7)   // polished stone
```

At a strength below 1 the reflection shows through a surface in the
plane's `color` and `roughness` — `0.4` reads as a polished floor rather
than a pool of mercury. Other material properties don't apply.

## Animation presets

```java
box().spin()            // 20°/s x, 30°/s y — the pleasant default
box().spin(45)          // y axis, degrees per second
box().spin(10, 20, 30)  // per axis
model("/assets/drone.glb").float_()          // gentle hover: ±0.25 units
sphere().float_(0.5, 1)                      // amplitude, cycles per second

sphere().pulse()                             // scale breathes ±8%, one cycle per 2s
sphere().pulse(0.15, 1)                      // ±amount (fraction), cycles per second
torus().emissive("#5FA98A").glow()           // emissive breathes 60%–100%, 0.6 cps
torus().emissive("#5FA98A").glow(0.5)        // cycles per second

box().appear(600)                            // scales in from nothing over 600ms on load
box().appear(600, 200)                       // ...after a 200ms delay
```

`delay(ms)` holds `pulse`, `glow`, `float_` and `spin` still before they
start, so a row can come alive one member at a time:

```java
repeat(6, i -> torus(0.5, 0.15).position(i * 1.4 - 3.5, 1, 0)
    .emissive("#5FA98A").glow(0.5).delay(i * 220))
```

`appear` touches scale only, so it composes with `float_` and `spin`;
`pulse` and `glow` wait for an `appear` to finish before they begin.

`follow` moves a node along a closed, smooth path, facing the way it goes:

```java
cone(0.2, 0.6).rotation(90, 0, 0)            // a cylinder/cone points along y;
    .follow(4,                               // 90° on x lays it along the path
        3, 2, -2,   4, 2.5, -1,   3, 3, 0,   2, 2.5, -1)   // one lap every 4s
```

The path owns position; the declared rotation becomes an offset from the
direction of travel (with none, the node's +z points along the path).

Presets are why the render loop exists at all: a scene with no `spin`,
`float_`, `pulse`, `glow`, `follow`, `autoRotate`, `.animate()`d model,
drifting `particles` or `sway()` renders once and sleeps — one-shots like
`appear` and `draw` run the loop only until they finish — and an animated
scene's loop pauses whenever it scrolls offscreen (IntersectionObserver),
so a 3D hero costs nothing once the reader is past it.

## Particles

A whole cloud of points as one node and one draw call — dust, spores,
rain, snow, embers — instead of a hundred tiny meshes:

```java
particles(140)                  // dust holding the light
    .color("#E7D6B1").size(0.02)
    .spread(6, 3.4, 10)         // the box they fill, centered on position
    .position(0, 1.9, 0)
    .drift()                    // slow in-place wander; drift(speed) scales it
    .opacity(0.75)

particles(400)                  // rain
    .color("#7FA8C0").size(0.02)
    .spread(12, 8, 12)
    .fall(3)                    // units/sec, wrapping back to the top
```

Positions are seeded deterministically — a re-render doesn't reshuffle the
sky; `.seed(n)` picks a different arrangement. `drift`/`fall` animate the
cloud (and keep the loop alive); a static cloud is free after its first
frame.

A palette gives each particle one of several colors, chosen with the same
seed as its position — embers in three oranges, confetti:

```java
particles(300).colors("#ff7a00", "#ffb347", "#ff3d00").size(0.06)
    .spread(4, 3, 4).drift(2)
```

## Groups

`group(...)` shares one transform across children — build a compound object,
then move or animate it as a unit:

```java
group(
    cylinder(0.1, 2).color("#8899aa"),               // mast
    cone(0.4, 0.6).position(0, 1.3, 0).color("#cc4444")  // tip
).position(-2, 0, 0).spin(15)
```

Groups nest. Transforms, presets and click handlers on a group apply to the
whole subtree.

Three composition helpers keep scene-building declarative when the scene
depends on state:

```java
List<Box> myListOfNodes = List.of(box(), box());
boolean doorOpen = true;
boolean aligned = true;
Box doorway() { return box(); }
Box whale() { return box(); }

group(myListOfNodes)                  // any Iterable — no toArray ceremony

when(doorOpen, doorway())             // the node, or nothing; null vanishes
when(aligned, () -> whale())          // Supplier: only built if needed

repeat(10, i -> cylinder(0.08, 1.5)   // a colonnade in one line
    .position(-2.7 + i * 0.6, 0.75, 0))
```

`scene(...)`, `group(...)` and `repeat(...)` all skip nulls, so a
conditional branch never needs an empty-group placeholder.

### Instanced groups

A group of hundreds of similar shapes can draw as GPU instances — one draw
call per distinct geometry-and-material, whatever the count:

```java
repeat(400, i -> cylinder(0.1, 2).color(i % 2 == 0 ? "#bbb" : "#999")
    .position(-20 + i % 40, 1, -10 + i / 40)).instanced()
```

Members are batched by shape, size and material; `color` may differ per
member for free. What members give up is individuality: an instanced
member can't be clicked, hovered, named or animated on its own — one
carrying `spin`, `float_`, `pulse`, `glow`, `appear`, `follow`, `onClick`,
a hover effect or a `name` falls back to a normal mesh (with one console
warning per group). The group itself still moves, spins and takes clicks
as a unit, and nested plain groups flatten into it.

## Models

```java
model("/assets/rocket.glb").scale(0.5).position(2, 0, 0).float_()
```

Loads a glTF file — the standard format every 3D tool (Blender, etc.)
exports. Transforms, presets and click handlers attach to a wrapper the model
loads into, so the scene behaves identically before and after the file
arrives; still scenes re-render when it lands.

Plain `.glb`/`.gltf` only: Draco-compressed geometry needs a decoder JWeb
does not ship. Export uncompressed (in Blender: leave "Compression" off).

Models that ship animation clips play them with one call:

```java
model("/assets/robot.glb").animate()          // every clip the file carries
model("/assets/robot.glb").animate("Walk")    // just the named clip
```

An animated model keeps the render loop alive like `spin()` does; a missing
clip name logs the clips the file actually has.

## Interactivity

Clicking a shape raycasts into the scene and dispatches through the same
pipelines as element events. Two flavors:

**Swap a fragment** — the 3D counterpart of `swap(url, target)` on an element:

```java
sphere().name("product")
    .clickSwap("/api/product/42", "#detail-panel")
```

**Run a server handler** — the same event pipeline as
`onClick(...)` on an element, over the live WebSocket:

```java
State<Integer> rolls = useState(0);

box().name("die").onClick(e -> rolls.set(rolls.get() + 1))
```

**Run a client-side Action** — the Actions DSL, dispatched in the browser
with no server round-trip (and CSP-safe, like every Action handler):

```java
sphere().onClick(toggle("info-panel"))          // import static jweb.Js.*
```

The handler's `event.value()` (and `dataset.mesh`) carry the node's `name`,
and `dataset.pose` where the camera stood (`"x,y,z,yaw"`). If several are
set on one node: server `onClick` wins, then the Action, then `clickSwap`,
then `link`. The cursor becomes a pointer over clickable objects. Clicks on
groups and models hit their whole subtree.

**Hover effects** are declared on the shape and run entirely client-side —
raycast on pointer move, applied on enter, restored on leave:

```java
torusKnot()
    .hoverScale(1.1)             // grows while hovered (any node type)
    .hoverEmissive("#4c1d95")    // glow highlight (meshes)
    .hoverColor("#f43f5e")       // or a straight color swap (meshes)
    .onClick(show("hint"))
```

### Places that react

Three more ways a node answers the visitor — none of them a click:

```java
// a painting that is a doorway: clicking navigates. <body> gets
// three-crossing first so CSS can fade the way out; with the Navigation
// script on the page the hop uses its view transition. A drag that ends
// on it is not a click.
plane(2, 3).name("tide").link("/worlds/tide-archive")

// distance: within 3 units the scene element and <body> carry
// three-near-veil and a jweb:three-near event fires (again on leaving,
// detail.inside === false) — a veil that brightens as you approach, in CSS
plane(2, 3).name("veil").near(3)

// …or run handlers on the way in and out
sphere().name("lamp")
    .onNear(2.5, e -> tour.set("lamp"))     // server, over the socket
    .onFar(hide("lamp-caption"))            // or an Action, client-side

// a floor region: the door home, a trigger. Edges are x and z on the
// ground, any height. Being inside on first render doesn't count as
// entering, so a link never fires on load.
zone(-1.6, 14.9, 1.6, 16).name("back-door").link("/")
zone(-3, -3, 3, 3).name("plinth").onEnter(e -> ...).onLeave(e -> ...)
```

Handlers get the node's name in `event.value()` and the camera's place in
`dataset.pose`. Proximity is evaluated whenever the camera moves —
walking, a camera patch, orbit, `sway()` — with a little hysteresis so
standing on the line doesn't flicker. Zones draw nothing; they cost
nothing.

## Sound

```java
sound("/audio/sea.mp3").loop().volume(0.4)                        // everywhere
sound("/audio/fountain.mp3").loop().position(4, 1, -6).range(3)   // from the fountain
sound("/audio/bell.mp3").name("bell").paused()                    // played by a patch
```

A sound with a `.position(...)` is positional: it plays from there, louder
as the camera nears; `range(distance)` is where it's at full volume
(default 4). Browsers start audio only inside a user gesture, so every
sound waits for the visitor's first click, tap or key press and begins
then; `paused()` waits for a patch instead. Live, from any server handler:

```java
Three.patch("hall").node("sea").volume(0.1).tween(800)   // fade the sea
                   .node("bell").play()                   // ring the bell
```

`camera().walk(...).footsteps()` adds a synthesized step on every stride —
a soft scuff and thud, no file — or `footsteps(url[, volume])` plays a
clip. Steps pause while floating. `JWebThree.mute(id, on)` silences a
scene's sounds from a script.

## Live patches

The problem with state-driven scenes used to be that changing state meant
re-rendering the page — and the visitor's camera, walk position and every
animation phase reset with it. `Three.patch` updates the **live** scene
instead, from inside a server event handler, riding the WebSocket answer
that's already in flight:

```java
sphere().name("lantern").onClick(e ->
    Three.patch("hall")
         .node("lantern").emissive("#22d3ee").color("#a5f3fc").tween(500)
         .node("key-light").intensity(1.6).tween(500))
```

No reload, no scene rebuild — the lantern re-lights in place, mid-walk.
Targets are nodes carrying `.name(...)`. Per node:
`position / rotation / scale` (degrees, like everywhere),
`color / emissive / opacity` (meshes), `color / intensity` (lights),
`visible(boolean)`. `tween(ms)` eases the current target's changes;
without it they apply instantly.

`.camera()` glides the framing — `position`, `lookAt`, `tween`:

```java
button(onClick(e ->
    Three.patch("hall").camera()
         .position(0, 2.2, -0.6).lookAt(0, 2.1, -4.5).tween(1200)),
    "Approach the arch")
```

Camera patches respect whoever owns the camera: under OrbitControls they
reposition through the controls (instantly, so the two don't fight), and
while the visitor is walking they only update the framed view that Esc
returns to.

Patches work from any server event handler — a scene click, a button's
`onClick(Consumer)`, a form submit. They ride the event's own socket
answer, so outside a handler there's no page in flight and the patch is
dropped with a warning. Position patches on a `float_()`ing node move its
hover base along x/z and re-center the bob at the new height.

### Adding and removing nodes

The scene's structure changes the same way — nodes are built or disposed
in place, camera and animation phases untouched:

```java
Three.patch("hall")
     .add(sphere(0.3).name("orb").emissive("#5FA98A").appear(400))   // into the scene root
     .addTo("shelf", box(0.4).name("crate"))                        // inside a named group
     .remove("old-lamp", "dust")                                     // the whole subtree
     .replace("vase", lathe(0, 0, 0.5, 0, 0.3, 1.2).color("#8a6"))   // same parent, same name
     .node("orb").position(0, 2, 0).tween(600)                       // and patch what you added
```

Added nodes are exactly what `scene(...)` would have built — presets,
clicks, hover, shadows, all of it. Removal releases the node's geometry,
materials and textures and stops its animations. Within one patch removes
apply first, then additions, then property patches — so an `add` and a
`.node(...)` on the new name compose. `replace` gives the new node the old
name unless it carries its own. An unknown group in `addTo` logs a warning
and skips.

## Escape hatch: the raw three.js API

The DSL covers scenes, not shaders. When you need the full API, give the
scene an id — the live objects are exposed to scripts:

```java
scene(...).id("hero")
```

```java
// JWebThree.get('hero') -> {scene, camera, renderer, controls, objects}
script().unsafeRaw("""
    var hero = JWebThree.get('hero');
    hero.objects.moon.material.emissiveIntensity = 2;
    """)
```

`objects` maps every `.name(...)`d node to its three.js object. From there
the entire three.js API is yours; the runtime still owns sizing, the loop
and disposal.

Two interop courtesies, so page scripts never poll and never resort to
prototype tricks:

```js
// runs when the scene exists — immediately if it already does
JWebThree.ready('hero', h => {
    const v = new JWebThree.THREE.Vector3(0, 1, 0);   // the real module
    h.objects.moon.position.add(v);
});
```

`JWebThree.THREE` is the bundled three.js itself (`Vector3`, `Color`,
materials, everything). `setWalk(id, on)` and `walking(id)` control walk
mode from scripts when a `data-three-walk` element isn't enough;
`pose(id)` is where the camera is and looks (`{x, y, z, yaw, pitch,
walking}`), `mute(id, on)` silences the scene's sounds.

## Sizing

Give a scene a height (style, class — anything CSS). The canvas fills its
container and tracks it responsively. A zero-height container gets a 320px
minimum and a console hint rather than an invisible scene.

## Performance notes

- The bundle is ~798KB raw / ~205KB gzipped, cached immutably per version —
  one fetch per browser per upgrade, and only on pages that use scenes.
- Still scenes render on demand: zero CPU, zero battery until interaction.
  Live patches without a tween apply and render exactly one frame; tweens
  run the loop only until they finish.
- Animated scenes pause offscreen — the loop stops while the element is
  scrolled out of view and resumes when it returns.
- `bloom()` renders the scene through an HDR composer — roughly the cost of
  a second render at bloom resolution. One bloomed hero is fine; five
  bloomed scenes on one page is a choice.
- A `mirror()` re-renders the scene from the reflected view — same
  order-of-magnitude note as bloom.
- `particles(n)` is one draw call regardless of `n`; thousands are cheap.
  So is an `.instanced()` group — a few calls for hundreds of shapes.
- `glass()` renders what's behind it into a buffer first (three.js
  transmission) — like bloom, a per-frame cost that scales with how much
  glass is on screen.
- `terrain` is a mesh of `detail²` quads (96² by default); it builds once
  and costs nothing after.
- Pixel ratio is capped at 2 to keep retina laptops cool.
- Disposal is automatic and complete — geometries, materials, textures,
  composer targets and the GL context are released when the element leaves
  the DOM, including through fragment swaps.

## Limitations

- WebGL is required (universally available; scenes fail with a console error,
  never a crash).
- One camera per scene. Post-processing through the DSL is exactly
  `bloom()`/`toneMapped()` — arbitrary pass chains and custom shaders are
  escape-hatch territory.
- Walk mode's collision model is `bounds(...)` plus `.solid()` footprints
  (boxes) and `.solid(r)` cylinders — obstacles, not mesh-accurate physics;
  the walker never climbs a solid, only steps over low ones.
- Draco/KTX2-compressed assets are not supported (no decoder shipped).
- `onClick` server handlers and `Three.patch` need the JWeb runtime's
  WebSocket (on by default); `clickSwap` needs only fetch.
- `wire(...)` lines are one pixel wide at any zoom (a WebGL limit) and
  take no clicks or hover — use `tube` for a line with body.
- Members of an `.instanced()` group are not individually clickable,
  hoverable, nameable or animatable; members that need that fall back to
  normal meshes (with a console warning).
- The factory is `wire`, not `line`: `line(...)` is the SVG element under
  the `El` wildcard, and a `double...` overload would be ambiguous with it.

<!-- ==================== END three-dsl ==================== -->


<!-- ==================== BEGIN backend ==================== -->

# Backend: REST, OpenAPI, SQL and migrations, MongoDB, security, validation, uploads, mail, jobs

> Source: `readme/guides/backend.md` · topic id: `backend`

[← Back to README](./../README.md)

# Backend

## Fragments — server-driven UI (zero JavaScript)

Any route can return a fragment (an Element without `html`/`body`); the framework serves
it clean (no script injection). Declarative swap attributes fetch and insert fragments —
wrapped in a View Transition when the browser supports it:

```java
static Element productList(int page) { return div(); }

// Server: a route returning a fragment
app.get("/products/list", req -> productList(req.queryInt("page", 1)));

// Client: no JS written — the JWeb runtime handles it
button(swap("/products/list?page=2", "#products"),
       swapPush("/products?page=2"),                  // optional history entry
    "Next page")

// Progressive forms: POST + swap the response fragment
form(swapForm("/comments", "#comment-list"),
    input(type("text"), name("message")), button("Post"))
```

`swapOuter(url, sel)` replaces the target element itself; `swapMorph(url, sel)` **morphs
the target in place** — unchanged nodes are kept, so focus, scroll position and in-progress
input survive the update (best for lists/forms that refresh under the user). Back/forward
re-swap via history state; a `jweb:swap` event fires after each swap, with the address bar
already showing the URL the swap navigated to. Going back *past* the first swap reloads:
that history entry is the document as the server first sent it.

`data-swap-cache="<ms>"` (or `navigate(url).cache(ms)` in the JS DSL) reuses a fetched
fragment for that long, out of the same store
[`prefetch(selector)`](./javascript-dsl.md#behaviors) warms on hover — so a link the
pointer has rested on swaps with no round trip.

## Streaming SSR

Wrap a page in `Streamed.of(() -> ...)` and the shell flushes immediately; every
`Suspense` block renders its loading placeholder instantly and **streams its real HTML
into the page the moment its data resolves** — blocks load in parallel, arrive in
completion order, and no JavaScript is written:

```java
class Layout implements Template {
    Element content;
    Layout(String title, Element content) { this.content = content; }
    public Element render() { return html(body(content)); }
}
class Reports { Object slowQuery() { return "data"; } }
Reports reports = new Reports();
static Element spinner(String text) { return div(text); }
static Element reportTable(Object data) { return div(String.valueOf(data)); }

app.get("/dashboard", req -> Streamed.of(() -> new Layout("Dashboard", div(
    header(),                                        // paints immediately
    Suspense.of(() -> reports.slowQuery())           // streams in when ready
        .loading(() -> spinner("Crunching numbers..."))
        .render(data -> reportTable(data))
)).render()));
```

Measured on the demo route (`/demo/streaming`): TTFB ~35ms, total = the slowest block.
The page must be built inside the supplier (the element DSL evaluates eagerly).
Outside a streamed page, `Suspense` behaves exactly as before.

## Typed Routes — compile-time checked URLs

Declare a route's path and parameter types once; registration and every link are then
type-checked (String, Integer, Long, Double, Boolean, UUID):

```java
static Element userPage(Long id) { return div(); }
static Element comments(String slug, Integer page) { return div(); }

static final TypedRoute.Path1<Long> USER = TypedRoute.path("/users/:id", Long.class);
static final TypedRoute.Path2<String, Integer> POST =
    TypedRoute.path("/blog/:slug/comments/:page", String.class, Integer.class);

app.get(USER, (req, id) -> userPage(id));             // id is already a Long
app.get(POST, (req, slug, page) -> comments(slug, page));

a(href(USER.url(42L)), "Profile")                        // "/users/42", URL-encoded
```

Bad parameter values (e.g. `/users/abc`) return 400, not 500.

Query parameters get the same treatment:

```java
static Element productList(Integer page) { return div(); }

static final QueryParam<Integer> PAGE = QueryParam.of("page", Integer.class).orElse(1);
static final QueryParam<Long>    USER = QueryParam.of("userId", Long.class).required();

app.get("/products", req -> productList(PAGE.from(req)));   // parsed, defaulted, no null checks
```

## SEO (`seo/Seo`)

```java
head(metaCharset(), metaViewport(),
    Seo.of("JWeb — Java Web Framework", "Build complete web apps entirely in Java")
        .url("https://jweb.dev/").image("https://jweb.dev/og.png").siteName("JWeb")
        .render())
```

Emits title, meta description, canonical link, Open Graph, and Twitter card tags —
all consistent from one declaration.

## Image Optimization

`GET /jweb/img?src=/static/photo.jpg&w=400` serves the image scaled to 400px wide
(aspect kept), immutably cached, restricted to classpath `static/`/`public/` resources.
Zero dependencies (ImageIO). Use directly in the DSL:

```java
img("/jweb/img?src=/static/hero.jpg&w=800")
```

## Page routes with parameters

`app.pages(...)` paths take `:param` segments and a `*` wildcard, like router routes. Exact
paths still resolve by lookup and win over patterns. The page reads its parameters in
`beforeRender(req)` or through the `pathParam(name)` / `params()` accessors:

```java
class Layout implements Template { public Element render() { return div(); } }
class HomePage implements Template { public Element render() { return div(); } }
class PostPage implements Template { public Element render() { return div(); } }
class DocsPage implements Template { public Element render() { return div(); } }
record User(String name, String bio) {}
static class Users { User find(long id) { return new User("", ""); } }
static Users users = new Users();

app.layout(Layout.class).pages(
    "/", HomePage.class,
    "/users/:id", UserPage.class,
    "/posts/:category/:slug", PostPage.class,
    "/docs/*", DocsPage.class);

public class UserPage implements Template {
    private User user;

    @Override public void beforeRender(Request req) { user = users.find(req.requireParamLong("id")); }
    @Override public Element render() { return div(h1(user.name()), p("id " + pathParam("id"))); }
    @Override public String pageTitle() { return user.name(); }      // plain String, null = default
    @Override public String description() { return user.bio(); }     // <meta name="description">
    @Override public boolean cacheable() { return false; }
}
```

(`title()` is not the hook's name on purpose: a `Template.title()` would shadow the
`title(...)` element factory inside every Template.)

## Guards

A guard runs in front of every route under a path pattern — page routes, router routes
**and `@REST` controllers** — after the middleware stack and before the handler. It returns
`null` to pass, or the response to send instead; guards run in registration order and the
first answer wins.

```java
app.guard("/admin/**", req -> req.principal() != null ? null : Response.redirect("/admin/login"));
app.guard("/admin/**", Auth.requireLogin("/admin/login"));   // the same as sugar; the login page passes
app.guard("/admin/**", Auth.requireRole("admin"));            // any Middleware: its response or 401/403 answers
app.guard("/api/v1/admin/**", Jwt.protect());
```

Patterns follow `app.use(path, ...)`: a plain prefix, `/prefix/**`, or a glob with `*` in one
segment. `req.principal()` is what `Auth.login` stored. A `JWebException` thrown by a guard or
middleware now answers with its own status (401/403/...) instead of a 500 page.

## Action routes — typed parameters

A mutation reachable from a link **or** a form: `app.action` registers GET and POST, and binds
the query/form parameters to a record before the handler runs. `app.get(path, Type.class, ...)`
and `app.post(...)` do the same for one method; `req.bind(Type.class)` does it by hand.

```java
import jweb.api.Range;

enum Setting { EBB, SLACK, FLOOD }
record Vane(@Range(min = 1, max = 3) int n, Setting set) {}
record Pref(Optional<Setting> sound, Optional<Boolean> motion) {}
class Visit {
    Map<Integer, Setting> vanes = new HashMap<>();
    boolean soundOn;
    boolean vanesAligned() { return vanes.size() == 3; }
}

app.action("/act/tide/vane", Vane.class, (req, vane) -> {      // GET /act/tide/vane?n=2&set=flood
    Visit v = Session.of(Visit.class, req);
    v.vanes.put(vane.n(), vane.set());
    return Response.redirect("/worlds/tide-archive").anchor(v.vanesAligned() ? "content" : "chamber");
});

app.action("/act/pref", Pref.class, (req, pref) -> {
    pref.sound().ifPresent(s -> Session.of(Visit.class, req).soundOn = s != Setting.EBB);
    return Response.redirectBack(req);                            // the Referer (same site), or "/"
});
```

Binding rules: `String`, the primitives and boxes, `UUID`, enums (by name, case-insensitive),
`Optional<T>` (optional), `List<T>` (every value of that name). Everything not Optional is
required — a primitive `boolean` reads `false` when absent (an unchecked checkbox). `@Range`,
`@Length`, `@Pattern` (all in `jweb.api`) validate; a record's compact constructor may throw
`IllegalArgumentException` too. Any failure is a **400 with a message naming the component**
(an HTML page for browsers, JSON for `Accept: application/json`) — never an exception page.

## REST API (`@REST` controllers)

A controller is `jweb.*` and `java.*` only. `@REST` is a meta-annotated `@RestController`,
so component scanning registers it; the parameter annotations are resolved by JWeb's own
argument resolver (Spring's `@RequestParam` & co. target parameters only and cannot be
meta-annotated, so they are re-implemented, not aliased):

| Annotation | Purpose |
|------------|---------|
| `@REST("/api/v1/x")` | Controller with base path (must start with `/api/v`) |
| `@GET` `@POST` `@UPDATE` `@PATCH` `@DEL` | One per method; `@GET("/{id}")` adds a path |
| `@Param` | Path placeholder `{id}` → int, long, String, UUID, enum... (name = the parameter's) |
| `@Query` | Query/form parameter; `value`, `defaultValue`, `required`; `Optional<T>`, `List<T>` |
| `@Body` | JSON body → record, POJO, `Map`, `List` (a `String` gets the raw text) |
| `@Header`, `@Cookie` | A header / cookie value, same options as `@Query` |
| `@Upload("file")` | Multipart file → `jweb.UploadedFile` (never null — `isEmpty()`), or `List<UploadedFile>` |
| `jweb.Request` | A bare `Request` parameter is injected (path params included) |
| `@Component`, `@Value` | `jweb.api` spellings of the Spring stereotypes for your stores and config values |

```java
import jweb.Request;
import jweb.api.*;

@REST("/api/v1/users")
public class UserApi {

    private final UserStore users;
    public UserApi(UserStore users) { this.users = users; }        // @Component injected

    @GET
    public List<User> getAll() { return users.findAll(); }

    @GET("/{id}")
    public User getById(@Param long id) { return users.find(id); }

    @GET("/search")
    public List<User> search(@Query String q, @Query(value = "limit", defaultValue = "10") int limit) { ... }

    @POST
    public Object create(@Body User user) {
        return Response.created("/api/v1/users/" + users.save(user), user);   // 201 + Location
    }

    @UPDATE("/{id}")
    public User update(@Param long id, @Body User user) { return users.update(id, user); }

    @DEL("/{id}")
    public Object delete(@Param long id) { users.delete(id); return Response.noContent(); }

    @POST("/{id}/avatar")
    public Map<String, Object> avatar(@Param long id, @Upload("file") UploadedFile file) { ... }

    @GET("/whoami")
    public Map<String, Object> whoami(Request req) { return Map.of("ip", req.ip()); }
}
```

Missing or malformed input answers **400 with a message naming the parameter**. Statuses come
from `Response` — `serverError()`, `status(int)`, `unauthorized()`, `forbidden()`,
`tooManyRequests()`, `noContent()`, `created(location[, body])`, `error(code, message)`,
`html(code, element)`, `json(code, body)`, `redirect(url).anchor(..)`, `redirectBack(req)` —
so no `HttpStatus` import is ever needed.

What that implies:

- **Put controllers under `/api/v*`** — `JWebController` explicitly bypasses those paths so
  Spring MVC can serve them. (A JWeb router route under `/api/v1/...` would silently never fire.)
- **Guards apply** (`app.guard("/api/v1/admin/**", ...)`); **middleware does not**
  (`app.use(...)` runs only for what `JWebController` dispatches).
- The Spring spellings (`@PathVariable`, `@RequestBody`, `HttpServletRequest`, ...) still work
  in a controller; they are just no longer needed.

---

## OpenAPI Documentation

```java
import jweb.OpenApi;

class UserApi {}
class ContactApi {}

OpenApi.create()
    .title("JWeb Example API")
    .version("1.0.0")
    .description("Example REST API built with JWeb")
    .server("http://localhost:8085")
    .bearerAuth()
    .tag("users", "User management")
    .addApi(UserApi.class)
    .addApi(ContactApi.class)
    .mount(app, "/api");
```

`mount(app, "/api")` registers four routes:

| Route | Serves |
|-------|--------|
| `/api/openapi.json` | The OpenAPI 3.0.3 spec |
| `/api/docs` | Swagger UI |
| `/api/redoc` | Redoc |
| `/api/scalar` | Scalar API reference |

Enrich generated docs with annotations:

```java
@ApiDoc(
    summary = "Search users",
    params = { @ApiParam(name = "q", description = "Query string", required = true) },
    responses = {
        @ApiResponse(code = 200, description = "Matches found"),
        @ApiResponse(code = 400, description = "Bad query")
    })
@GET("/search")
public List<User> search(@RequestParam String q) { ... }
```

Notes: the spec is built eagerly at `mount(...)` time from `getDeclaredMethods()`; POJO schemas
degrade to `{"type":"object"}` (no property introspection); `scan(String pkg)` does a real
classpath scan for `@REST`-annotated classes (use it or `addApi(Class)`).

---

## Database: SQL (`Sql`, `Migrations`)

Any JDBC database — PostgreSQL, MySQL, SQLite, H2 — through the app's `DataSource`. No ORM
and no query language of its own: the SQL is yours; mapping rows to records, binding
parameters, transactions and the connection handling are the framework's.

**Setup.** Add the starter and a driver, give Spring Boot the URL, and the `DataSource` bean
it creates is bound to `Sql` at startup:

```xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
```

```yaml
spring:
  datasource:
    url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/myapp}
    username: ${DATABASE_USER:myapp}
    password: ${DATABASE_PASSWORD:}
```

Outside Spring (a test, a `main`), `Sql.connect(dataSource)` binds one by hand.

### Queries, updates, inserts

```java
import jweb.Sql;

record User(Long id, String name, String email, LocalDateTime createdAt) {}
long from = 1, to = 2;

List<User> users = Sql.query("select * from users where email like ?", "%@example.com").list(User.class);
Optional<User> one = Sql.query("select * from users where id = ?", 7).first(User.class);
User exactly = Sql.query("select * from users where email = ?", "ada@example.com").one(User.class);
long n = Sql.query("select count(*) from users").scalar(Long.class);
boolean taken = Sql.query("select 1 from users where email = ?", "ada@example.com").exists();

// named parameters from a map; :: is left alone, so a PostgreSQL cast still works
List<User> named = Sql.query("select * from users where name = :name", Map.of("name", "Ada")).list(User.class);

int changed = Sql.update("update users set name = ? where id = ?", "Ada", 7);
long id = Sql.insert("insert into users (name, email) values (?, ?)", "Ada", "ada@example.com").key(Long.class);
Sql.execute("create index users_email on users (email)");
Sql.batch("insert into users (name, email) values (?, ?)",
    List.of(new Object[]{"a", "a@x"}, new Object[]{"b", "b@x"}));

// rows without a record: typed getters, or a mapper
List<String> names = Sql.query("select name from users").list(String.class);
List<String> labels = Sql.query("select id, name from users").list(row -> row.getLong("id") + " " + row.getString("name"));
Sql.query("select * from users").forEach(row -> System.out.println(row.toMap()));   // streams, holds nothing

Sql.transaction(() -> {
    Sql.update("update accounts set balance = balance - ? where id = ?", 50, from);
    Sql.update("update accounts set balance = balance + ? where id = ?", 50, to);
});   // commit at the end; rollback on any exception; nested calls join
```

**Records.** A row maps to a record by column name: the component `createdAt` reads the
column `created_at` (or `createdAt`, or `CREATEDAT` — case and underscores are ignored). A
column the row does not have leaves the component `null` (zero / `false` for a primitive).
The types that convert: the numbers, `String`, `boolean`, `BigDecimal`, `LocalDate`,
`LocalDateTime`, `LocalTime`, `Instant`, `UUID`, enums by name, `byte[]`. A non-record class
(`String.class`, `Long.class`) maps the first column. Temporal columns are read as
`java.time` values, never through `java.sql.Date`.

### A table through a record

```java
import jweb.Sql;

record Todo(Long id, String title, boolean done) {}

Sql.Table<Todo> todos = Sql.table("todos", Todo.class);     // columns = components in snake_case; `id` is the key

Todo t = todos.insert(new Todo(null, "Write docs", false)); // the returned copy carries the generated id
todos.update(new Todo(t.id(), t.title(), true));            // every column, by id
todos.save(t);                                              // insert when id is null, update otherwise
Optional<Todo> maybe = todos.find(t.id());
Todo must = todos.get(t.id());                               // fails when absent
List<Todo> open = todos.where("done = ? order by id", false); // everything after `where`, ordering included
long left = todos.count("done = ?", false);
todos.delete(t.id());
todos.deleteWhere("done = ?", true);
```

Forms and tables meet on the record: `Form.of(Todo.class)` renders it, `Form.bind(req,
Todo.class)` validates it, `todos.save(...)` stores it.

### Migrations

Versioned SQL files, applied once each in order and remembered in `jweb_migrations`. The
file name is Flyway's convention, so the files move to Flyway unchanged the day an app wants
it; with Flyway or Liquibase on the classpath, JWeb's migrator steps aside.

```
src/main/resources/db/migration/V1__create_todos.sql
src/main/resources/db/migration/V2__add_todos_done_index.sql
```

```sql
create table todos (
    id bigint generated always as identity primary key,
    title varchar(200) not null,
    done boolean not null default false
);
```

Pending migrations run at startup, before any `ApplicationRunner` of the app's own, each in
one transaction (where the database makes DDL transactional: PostgreSQL, H2, SQLite — MySQL
commits DDL as it goes). A file edited after it was applied fails the next start naming the
version; put the change in a new migration, or `Migrations.repair(dataSource)` when the edit
was deliberate. Several instances starting at once take turns on a lock row. Statements are
split on `;` outside quotes, comments and PostgreSQL `$$` bodies; a file that starts with
`-- jweb:whole` is sent as one statement.

```java
import jweb.Migrations;
import javax.sql.DataSource;

DataSource dataSource = Sql.dataSource();

Migrations.Report report = Migrations.migrate(dataSource);   // what this call applied
List<Migrations.Migration> pending = Migrations.pending(dataSource);
List<Migrations.Applied> history = Migrations.applied(dataSource);
```

| Property | Default | Effect |
|----------|---------|--------|
| `jweb.sql.migrate` | `true` | Run pending migrations when the `DataSource` is bound |
| `jweb.sql.migrations` | `classpath:db/migration` | Where the `V*__*.sql` files are (`classpath:` or `file:`) |

**Errors** are `Sql.SqlException` (unchecked, with the SQL that failed) and
`Migrations.MigrationException`. Calling `Sql` with nothing bound says what to add.

---

### Scaffolding a resource

`jweb generate resource Todo title:string! done:boolean` writes the record (the row and the
form), the migration that creates its table (`V<next>__create_todos.sql`), a list page, a
form page and the `JWebRoutes` bean that wires them under `/todos`; `jweb new` already gave
the project its database. The generated code is plain code to edit, nothing to keep in sync.

## Admin (`jweb.Admin`)

A back office for the tables an app keeps through `Sql.table`: a page per table that lists,
searches and pages its rows, a form to add or edit one — the record's components are the
fields, its `Form.*` annotations the rules — and a delete. One line to mount, one to guard:

```java
import jweb.Admin;
import jweb.Auth;
import jweb.Sql;

record Todo(Long id, @Form.Required String title, boolean done) {}
record User(Long id, @Form.Email String email, String role) {}
Sql.Table<Todo> todos = Sql.table("todos", Todo.class);
Sql.Table<User> users = Sql.table("users", User.class);

Admin.mount(app, "/admin", todos, users)
     .title("Back office")
     .guard(Auth.requireRole("admin"));
```

The pages are templates in the page table, so the app's layout wraps them and
`Middlewares.recommended()` protects the forms; the guard stands in front of every admin
route. A table whose record has no `id` component is listed read-only. Behind it are two
small additions to `Sql.Table` any page can use: `page(n, size)` and `search(text, n, size)`
over the text columns.

## Database Integration (MongoDB)

The MongoDB driver is an **optional dependency**: add `org.mongodb:mongodb-driver-sync`
(5.10.0) to use `Mongo`; an app that does not never loads a Mongo type. With the driver
present and `jweb.data.enabled: true`, `MongoConfiguration.mongoInitializer()` connects at
startup (an `ApplicationRunner`, so after the context is up). URI/database come from
`jweb.data.mongo.uri` / `jweb.data.mongo.database` (see [Configuration](./configuration.md)).

| Class | Purpose |
|-------|---------|
| `Mongo` | Static facade: connection + CRUD (`save`, `insert`, `find`, `findById`, `update`, `delete`, `count`, `exists`, `deleteById`) + POJO overloads |
| `Doc` | Dynamic document: `of(coll)`, `set(k,v)` (dotted keys nest), typed getters, `toBson()`, `as(Class)` |
| `MongoQuery` | Fluent query builder |
| `MongoUpdate` | Fluent update builder |
| `MongoDelete` | Fluent delete builder |
| `Schema` | Schema/index definitions — validated on `save`/`insert` once registered (see notes) |

### CRUD

```java
import jweb.Mongo;
import jweb.Doc;

record User(String name) {}
String existingId = "000000000000000000000000";
String email = "john@example.com";
String input = "john";
Object entry = Map.of("action", "created");
User userPojo = new User("John Doe");

// Create
Doc user = Doc.of("users")
    .set("name", "John Doe")
    .set("email", "john@example.com")
    .set("profile.city", "Oslo")             // dotted keys create nested documents
    .set("createdAt", LocalDateTime.now());  // LocalDateTime <-> Date handled automatically
Mongo.save(user);                            // insert (no id) or replace (has id)

// Read by id — use findById, NOT find().where("id", ...)
Doc found = Mongo.findById("users", existingId);
Optional<Doc> maybe = Mongo.findByIdOptional("users", existingId);

// Query
List<Doc> adults = Mongo.find("users")
    .where("age").gte(18)
    .where("status", "active")               // 2-arg where = equality
    .orderBy("name")                         // ascending; orderByDesc(...) / .desc()
    .limit(10)
    .toList();

List<Doc> recent = Mongo.find("contacts").orderByDesc("_id").toList();
Doc first = Mongo.find("users").where("email", email).first();

// Richer conditions
Mongo.find("users").where("role").in("admin", "editor")
Mongo.find("users").where("role").notIn("banned")        // notIn, not "nin"
Mongo.find("users").where("bio").regex("java", "i")
Mongo.find("users").where("deletedAt").isNull()
Mongo.find("posts").where("tags").containsAll("java", "web")
Mongo.find("users").or(
    q -> q.where("email", input),
    q -> q.where("username", input))
Mongo.find("users").select("name", "email").skip(20).limit(10).toList();
long n = Mongo.find("users").where("active", true).count();

// Update ("id" IS translated to _id here)
Mongo.update("users")
    .where("id", existingId)
    .set("name", "Jane Doe")
    .inc("loginCount", 1)
    .push("history", entry)
    .execute();
Mongo.update("users").where("id", existingId).set("verified", true).upsert().execute();
Doc updated = Mongo.update("users").where("id", existingId).set("x", 1).returnNew().executeAndGet();

// Delete ("id" IS translated to _id here)
Mongo.delete("users").where("status", "inactive").execute();
Mongo.delete("logs").where("age").gt(30).execute();      // comparisons: gt/gte/lt/lte/ne/in
Mongo.deleteById("users", existingId);

// POJOs
String id = Mongo.save("users", userPojo);
User u = Mongo.findById("users", id, User.class);
List<User> all = Mongo.find("users").toList(User.class);
```

### Doc getters

`getString`, `getInt`, `getLong`, `getDouble`, `getBoolean` (each with default-value
overloads), `getDateTime` (⚠️ not `getDate`), `getList`, `getDoc`, `get(dotted.path)`, `has`,
`keys`, `toMap`, `as(Class)`.

### Notes

- **`MongoQuery.where("id", ...)` translates `id` → `_id`** (with String→ObjectId
  conversion), matching the update/delete builders. `Mongo.findById(...)` also works.
- **`Schema` definitions are enforced**: `register()` wires the schema into `Mongo`, field
  constraints (`required`/`min`/`max`/`minLength`/`maxLength`/`pattern`/`enum_`/defaults) are
  validated on `Mongo.save`/`insert` (throwing `Schema.ValidationException`), and declared
  indexes plus `unique()` fields are created on connect (`Mongo.ensureIndexes`).
  `Schema.timestamps()` auto-sets `createdAt`/`updatedAt` on save/insert and update.

---

## Security

### Session-based Authentication (`Auth` + `Principal`)

```java
import jweb.Auth;
import jweb.Principal;

Request request = req;

// Login — stores principal in session, regenerates the CSRF token
Auth.login(request, Principal.of("admin", "admin@example.com", "admin"));

// Checks
if (Auth.isAuthenticated(request)) {
    Principal user = Auth.getPrincipal(request);
    user.getId(); user.getName(); user.hasRole("admin");
}
Principal user = Auth.requirePrincipal(request);   // throws JWebException.unauthorized
Auth.hasRole(request, "admin");
Auth.hasAnyRole(request, "user", "admin");
Auth.logout(request);                              // invalidates the session
```

**Guards and middleware factories** — as a guard they also cover `@REST` controllers under
the prefix; as middleware they apply to JWeb-router routes and page routes:

```java
app.guard("/admin/**", Auth.requireLogin("/admin/login"));   // 302 → /admin/login; the login page passes
app.guard("/admin/**", Auth.requireRole("admin"));           // 401 then 403
app.guard("/api/v1/admin/**", Auth.requireAnyRole("user", "admin"));

app.use("/dashboard", Auth.requireAuth());            // 401 via JWebException
app.use("/dashboard", Auth.requireAuth("/login"));    // 302 → /login?redirect=<path>
app.use("/super", Auth.requireAllRoles("admin", "superadmin"));

Principal who = req.principal();                      // null when anonymous
```

> `Auth.customAuth(fn)` and `Auth.bearerAuth(fn)` take `Function<Request, Principal>` /
> `Function<String, Principal>` and **reject with 401** when the function returns null (or the
> Bearer header is missing). For optional authentication that attaches a principal without
> rejecting, use `Auth.attachPrincipal(fn)`.

**Principal construction:**

```java
Principal.of("admin-id");
Principal.of("user-id", "john@example.com");
Principal.of("user-id", "john@example.com", "user", "editor");

Principal.builder()
    .id("user-123").name("john@example.com")
    .roles("admin", "editor")
    .attribute("department", "engineering")
    .build();
```

### The flows: password reset, email verification, remember me, log out everywhere, two-factor

`Auth` keeps the user model yours — a record and a table, a `Principal` built from it —
and provides the mechanics every flow needs, all stateless or on a store the instances
share. Set `jweb.auth.secret` (32+ random characters) in every environment; without it a
secret is generated per start and tokens and cookies stop working on restart.

```java
import jweb.Auth;
import jweb.Mail;
import jweb.Password;
import jweb.Principal;
import jweb.Sql;
import jweb.Form;
import static jweb.El.*;

record User(Long id, String email, String passwordHash, boolean verified, String totpSecret) {
    Principal principal() { return Principal.of(String.valueOf(id), email, "user"); }
}
record Credentials(@Form.Required @Form.Email String email, @Form.Required @Form.Password String password, boolean remember) {}
record NewPassword(@Form.Required @Form.Password String password) {}
Sql.Table<User> users = Sql.table("users", User.class);
String base = "https://app.example.com";
Element loginPage = p("login");

// ---- sign up: the verification link is a token bound to the email
app.post("/signup", req -> {
    Credentials c = Form.bind(Credentials.class, req).value();
    User user = users.insert(new User(null, c.email(), Password.hash(c.password()), false, null));
    String token = Auth.token("verify", String.valueOf(user.id()), Duration.ofDays(1), user.email());
    Mail.to(user.email()).subject("Confirm your email")
        .html(p(a(href(base + "/verify?token=" + token), "Confirm"))).later();
    return Response.redirect("/check-your-email");
});
app.get("/verify", req -> {
    String token = req.query("token");
    Optional<User> user = Auth.subject(token)                     // the id, unverified yet
        .flatMap(id -> users.find(Long.valueOf(id)))
        .filter(u -> Auth.verify("verify", token, u.email()).isPresent());   // now verified, against that user's email
    if (user.isEmpty()) return Response.forbidden("This link is not valid any more");
    User u = user.get();
    users.update(new User(u.id(), u.email(), u.passwordHash(), true, u.totpSecret()));
    return Response.redirect("/login?verified");
});

// ---- log in, with remember me and an optional second factor
app.post("/login", req -> {
    Credentials c = Form.bind(Credentials.class, req).value();
    Optional<User> found = users.first("email = ?", c.email());
    if (found.isEmpty() || !Password.verify(c.password(), found.get().passwordHash())) {
        return Response.html(401, loginPage);
    }
    User user = found.get();
    if (user.totpSecret() != null) {
        Session.of(req).put("pending-2fa", user.id());     // finish on /login/2fa
        return Response.redirect("/login/2fa");
    }
    Auth.login(req, user.principal());
    Response.Redirect home = Response.redirect("/");
    return c.remember() ? home.cookie(Auth.rememberCookie(user.principal(), Duration.ofDays(30))) : home;
});
app.post("/login/2fa", req -> {
    Long id = Session.of(req).get("pending-2fa", Long.class);
    User user = id == null ? null : users.get(id);
    if (user == null || !Auth.Totp.verify(user.totpSecret(), req.formParam("code"))) {
        return Response.html(401, p("Wrong code"));
    }
    Session.of(req).remove("pending-2fa");
    Auth.login(req, user.principal());
    return Response.redirect("/");
});
app.use(Auth.remember(id -> users.find(Long.valueOf(id)).map(User::principal).orElse(null)));   // returning visitors
app.post("/logout", req -> {
    Auth.logout(req);
    return Response.redirect("/").cookie(Auth.forgetCookie());
});
app.post("/logout-everywhere", req -> {
    Auth.logoutEverywhere(req);                          // every session and remember-me cookie of this user
    return Response.redirect("/").cookie(Auth.forgetCookie());
});

// ---- forgot / reset: the token is bound to the password hash, so it works once
app.post("/forgot", req -> {
    users.first("email = ?", req.formParam("email")).ifPresent(user -> {
        String token = Auth.token("reset", String.valueOf(user.id()), Duration.ofHours(1), user.passwordHash());
        Mail.to(user.email()).subject("Reset your password")
            .html(p(a(href(base + "/reset?token=" + token), "Choose a new password"))).later();
    });
    return Response.redirect("/check-your-email");         // the same answer whether or not the email exists
});
app.post("/reset", req -> {
    String token = req.query("token");
    Optional<User> user = Auth.subject(token)                     // the id, unverified yet
        .flatMap(id -> users.find(Long.valueOf(id)))
        .filter(u -> Auth.verify("reset", token, u.passwordHash()).isPresent());   // verified against the current hash
    if (user.isEmpty()) return Response.forbidden("This link has expired or was already used");
    NewPassword form = Form.bind(NewPassword.class, req).value();
    User u = user.get();
    users.update(new User(u.id(), u.email(), Password.hash(form.password()), u.verified(), u.totpSecret()));
    Auth.logoutEverywhere(String.valueOf(u.id()));            // every other session is out
    return Response.redirect("/login?reset");
});

// ---- two-factor setup
app.post("/2fa/enable", req -> {
    User user = users.get(Long.valueOf(Auth.requirePrincipal(req).getId()));
    String secret = Auth.Totp.secret();
    String uri = Auth.Totp.uri("MyApp", user.email(), secret);     // render as a QR code; show the secret too
    List<String> recovery = Auth.recoveryCodes(8);                // show once; store Password.hash(code) for each
    Session.of(req).put("2fa-setup", secret);
    return p(uri);
});
```

The pieces, one by one:

| Piece | What it does |
|-------|--------------|
| `Auth.token(purpose, subject, ttl[, fingerprint])` / `Auth.verify(purpose, token[, fingerprint])` | A signed token (HMAC-SHA256, `jweb.auth.secret`): the subject comes back only for the same purpose, before the expiry, untampered, with the same fingerprint. Bind a reset token to the password hash and it dies when the password changes; bind a verification token to the email and it verifies that address. `Auth.subject(token)` reads the subject *unverified* — to load the user whose fingerprint the verify then needs; `Auth.expiry(token)` the expiry. |
| `Auth.rememberCookie(principal, ttl)` / `Auth.forgetCookie()` | The remember-me cookie — HttpOnly, SameSite=Lax, `Secure` with `jweb.auth.remember.secure` — and the one that clears it. `Response.redirect(...).cookie(...)` and `Response.ok().cookie(...)` attach them. |
| `Auth.remember(loader)` | Middleware: a visitor with no session login but a valid cookie is logged in with the principal the loader returns for the id. `Auth.remembered(req)` is the id alone. |
| `Auth.logoutEverywhere(userId)` / `Auth.logoutEverywhere(req)` | Bumps the user's session version: every session and remember-me cookie that logged in before is no longer valid, on every instance. Versions live in memory, or in `jweb_auth_versions` when `Sql` is connected (reads cached ten seconds); a `Auth.Versions` bean replaces both. |
| `Auth.Totp.secret()` / `uri(issuer, account, secret)` / `verify(secret, code)` | RFC 6238 TOTP (SHA-1, six digits, thirty seconds, one period of skew) — Google Authenticator, 1Password and the rest. `code(secret, epochSeconds)` for tests. |
| `Auth.recoveryCodes(n)` | `xxxx-xxxx` one-time codes from an alphabet without look-alikes; store `Password.hash(code)` for each, delete on use. |

Throttle the login and forgot routes with `RateLimit` (`app.use("/login", RateLimit.perMinute(10).byIp().build())`).

### JWT Authentication

```java
import jweb.Jwt;

Request request = req;

Jwt.init("your-256-bit-secret-key-minimum-32-chars");   // or Jwt.init() → env JWT_SECRET

String token = Jwt.create()
    .subject("user123")
    .claim("role", "ADMIN")
    .roles("user", "admin")
    .expiresIn(Duration.ofHours(24))
    .sign();

if (Jwt.isValid(token)) {
    Jwt.Token parsed = Jwt.parse(token);
    parsed.subject(); parsed.claim("role"); parsed.roles(); parsed.isExpired();
}

app.use("/api", Jwt.protect());          // 401 on missing/expired/invalid Bearer token
app.use("/api", Jwt.optional());         // attaches token when present, never rejects
Optional<Jwt.Token> t = Jwt.getToken(request);   // inside handlers, after protect()
```

### CSRF

```java
// The middleware lives in Middlewares (there is no Csrf.middleware()):
app.use(Middlewares.csrf());
app.use(Middlewares.csrf(Set.of("/webhooks")));   // excluded paths

// In forms / pages:
form(attrs().action("/save").method("POST"),
    Csrf.tokenField(request),                     // hidden _csrf input
    ...)
head(..., Csrf.tokenMeta(request))                // <meta name="csrf-token"> for AJAX

// Manual: Csrf.validate(request) throws CsrfException; token in form param "_csrf"
// or header "X-CSRF-TOKEN"; tokens expire after 30 min; comparison is constant-time.
```

Since 3.0.2 `Middlewares.recommended()` includes `csrf()`, so every router POST/PUT/PATCH/DELETE
must carry the token (`/api/v*` controllers are outside the stack). Inside a render the
helpers need no request: a record form adds the field itself, a hand-built form renders
`Csrf.tokenField()`, and a page whose script POSTs by hand renders `Csrf.tokenMeta()` and
sends the `_csrf` parameter read from it:

```java
form(attrs().action("/save").method("POST"),
    Csrf.tokenField(),                            // the request in flight
    button(type("submit"), "Save"));
```

### Password Hashing (BCrypt)

```java
import jweb.Password;

String pw = "userPassword123";
String hashed = Password.hash("userPassword123");
boolean ok = Password.verify("userPassword123", hashed);
Password.needsRehash(hashed);                     // strength upgraded since hashing?
Password.setStrength(12);                          // 4..31, default 12
String generated = Password.generate(16);          // random password
Password.isStrong(pw); Password.validate(pw);      // strength checks
```

### CORS

```java
app.use(Middlewares.cors());                                            // "*", all methods
app.use(Middlewares.cors("https://app.example.com, https://admin.example.com"));
app.use(Middlewares.cors("https://app.example.com", "GET, POST", "Content-Type, Authorization"));
```

### Rate Limiting

```java
app.use(RateLimit.perMinute(60).build());                       // by client IP
app.use(RateLimit.perHour(1000).byApiKey("X-Api-Key").build());
app.use(RateLimit.requests(10).per(Duration.ofSeconds(30))
    .byUser()                                                    // falls back to IP
    .forPath("/api/*")                                           // glob supported HERE
    .skipIf(req -> req.ip().equals("127.0.0.1"))
    .onLimit((req, info) -> Response.error(429, "Slow down"))
    .build());
// Emits X-RateLimit-Limit/Remaining/Reset + Retry-After; 429 JSON body on limit.
```

### OAuth2

```java
OAuth2.Provider google = OAuth2.google()
    .clientId(System.getenv("GOOGLE_CLIENT_ID"))
    .clientSecret(System.getenv("GOOGLE_CLIENT_SECRET"))
    .redirectUri("https://app.example.com/oauth/callback")
    .scopes("openid", "email", "profile")
    .build();

app.get("/oauth/login", req -> Response.redirect(google.authorizationUrl(req)));

app.get("/oauth/callback", req -> {
    try {
        OAuth2.Login login = google.callback(req);     // state checked, code exchanged, profile fetched
        OAuth2.UserInfo info = login.user();
        Auth.login(req, Principal.of(info.id(), info.email(), "user"));
        return Response.redirect("/dashboard");
    } catch (OAuth2.OAuth2Exception e) {
        return Response.forbidden("Login failed: " + e.getMessage());
    }
});
```

Providers: `google()`, `github()`, `discord()`, `microsoft()`, `custom(name)`.
`authorizationUrl(req)` stores a fresh state and a PKCE verifier in the visitor's session
(ten minutes, single use) and sends the S256 challenge with the request; `callback(req)`
checks the state against the session, exchanges the code with the verifier and fetches the
profile. Because the state is in the session, the callback can land on any instance (with
Spring Session, on any instance of a cluster). `callback` throws `OAuth2Exception` naming the
reason: the provider's error (`error` / `error_description`), a state that does not match, an
expired attempt, a missing code, a failed exchange. `.pkce(false)` on the builder turns PKCE
off for a provider that rejects it. The in-memory `generateState()` / `verifyState()` and the
request-less `authorizationUrl()` still work and are deprecated.

### Real-World Example: Admin Authentication (from the sample app)

```java
import jweb.api.Value;

class Layout implements Template { Layout(String title, Element content) {} public Element render() { return div(); } }
class AdminLoginPage implements Template {
    AdminLoginPage(String error, CsrfToken token) {}
    AdminLoginPage(String error, String notice, CsrfToken token) {}
    public Element render() { return div(); }
}
class AdminMessagesPage implements Template {
    enum Order { NEWEST, OLDEST; List<Doc> apply(List<Doc> docs, int limit) { return docs; } }
    AdminMessagesPage(List<Doc> messages, CsrfToken token, Order order) {}
    public Element render() { return div(); }
}
static AdminApi adminApi = new AdminApi();

// AdminApi.java — session-based admin auth with env config (jweb.api.Component / Value)
@Component
public static class AdminApi {

    @Value("${jweb.admin.token:}") private String adminToken;
    @Value("${jweb.admin.email:}") private String adminEmail;

    public boolean login(Request request, String email, String token) {
        if (adminToken == null || adminToken.isBlank()) return false;   // fail closed
        if (isRateLimited(request.ip())) return false;                  // 5 failures / 15 min
        // Timing-safe comparison so the token can't be guessed byte-by-byte
        if (!constantTimeEquals(adminToken, token)
                || !constantTimeEquals(adminEmail, email)) return false;
        Auth.login(request, Principal.of("admin", email, "admin"));
        return true;
    }

    private static boolean isRateLimited(String ip) { return false; }   // 5 failures / 15 min, by IP

    private static boolean constantTimeEquals(String expected, String actual) {
        if (expected == null || actual == null) return false;
        return java.security.MessageDigest.isEqual(
            expected.getBytes(java.nio.charset.StandardCharsets.UTF_8),
            actual.getBytes(java.nio.charset.StandardCharsets.UTF_8));
    }

    public boolean isAuthenticated(Request request) { return Auth.isAuthenticated(request); }
    public void logout(Request request) { Auth.logout(request); }
    public List<Doc> getMessages() { return Mongo.find("contacts").orderByDesc("_id").toList(); }
}

// Routes.java — one guard protects everything under /only-admin (the login page passes),
// the login form binds to a record, the messages view binds its query, logout leaves a flash
public record Login(Optional<String> email, Optional<String> token) {}
public record MessagesView(Optional<AdminMessagesPage.Order> order,
                           @Range(min = 1, max = 500) Optional<Integer> limit) {}

app.guard("/only-admin/**", Auth.requireLogin("/only-admin/log/in"));

app.get("/only-admin/log/in", ctx -> {
    if (adminApi.isAuthenticated(ctx)) return Response.redirect("/only-admin/messages");
    String notice = Session.of(ctx).flash("notice");              // "You have been signed out."
    return Response.html(new Layout("Admin Login",
        new AdminLoginPage(null, notice, Csrf.getOrCreateToken(ctx)).render()).render());
});

app.post("/only-admin/log/in", Login.class, (ctx, login) -> {
    if (Csrf.isValid(ctx) && adminApi.login(ctx, login.email().orElse(null), login.token().orElse(null))) {
        return Response.redirect("/only-admin/messages");
    }
    return Response.html(new Layout("Admin Login",
        new AdminLoginPage("Invalid email or token", Csrf.getOrCreateToken(ctx)).render()).render());
});

app.get("/only-admin/messages", MessagesView.class, (ctx, view) -> {   // ?order=oldest&limit=20
    var order = view.order().orElse(AdminMessagesPage.Order.NEWEST);
    var messages = order.apply(adminApi.getMessages(), view.limit().orElse(Integer.MAX_VALUE));
    return Response.html(new Layout("Messages - Admin",
        new AdminMessagesPage(messages, Csrf.getOrCreateToken(ctx), order).render()).render());
});

app.post("/only-admin/logout", ctx -> {
    if (Csrf.isValid(ctx)) {
        adminApi.logout(ctx);
        Session.of(ctx).flash("notice", "You have been signed out.");
    }
    return Response.redirect("/only-admin/log/in");
});
```

---

## Validation

| Class | Purpose |
|-------|---------|
| `Validator<T>` | Functional interface: `validate(value, field)`; compose with `and()`/`or()`; build ad hoc with `Validator.of(predicate, message)` |
| `Validators` | Static factories — strings: `required`, `notNull`, `minLength`, `maxLength`, `exactLength`, `lengthBetween`, `pattern`, `email`, `alphanumeric`, `alpha`, `numeric`, `url`, `phone`; numbers: `min`, `max`, `range`, `positive`, `negative`, `nonNegative`; collections: `notEmpty`, `minSize`, `maxSize`; objects: `equalTo`, `oneOf`; booleans: `isTrue`, `isFalse` |
| `ValidationResult` | Per-field error map: `isValid()`, `hasErrors([field])`, `getErrors(field)`, `getFirstError(field)`, `getAllErrors()`, `getAllMessages()`, `getFieldsWithErrors()`, `getErrorCount()`, `merge(other)` |
| `FormValidator` | Multi-field form validation (lambda + legacy chained styles) |
| `FieldValidator` | Single string field: `required`, `optional`, length/pattern/format checks, `custom(Validator<String>)`, `check(predicate, msg)` — **no numeric `min`/`max`** |
| `NumberValidators` | `required`, `min`, `max`, `range`, `intRange`, `positive`, `negative`, `nonNegative` |

```java
import static jweb.Validators.*;

String value = "user@example.com";
String email = "user@example.com";
String password = "hunter2000";
String site = "https://example.com";
Number parsedAge = 30;

// Composable validators (required() collides with the HTML DSL's required() attribute — qualify it)
Validator<String> emailValidator = Validators.required().and(email()).and(maxLength(100));
ValidationResult result = emailValidator.validate(value, "email");
if (!result.isValid()) {
    List<String> errors = result.getErrors("email");    // NOT result.errors()
    String first = result.getFirstError("email");
}

// Form validation (lambda style)
ValidationResult form = FormValidator.create()
    .field("email", email, f -> f.required().email())
    .field("password", password, f -> f.required().minLength(8))
    .field("website", site, f -> f.optional().url())
    .validate();

// Numeric fields: validate the parsed number with Validators/NumberValidators
ValidationResult age = NumberValidators.range(18, 120).validate(parsedAge, "age");

// Failing fast with a 422
// if (!form.isValid()) throw new ValidationException(form);
```

---

## Forms

One form system, `jweb.Form`, and a form is a record. The record declares the fields and
their rules; the same record renders the form, validates the submission, and comes back as
a typed value. See [HTML DSL → Forms](./html-dsl.md#forms-a-record-is-the-form) for the
rendering side.

```java
import jweb.Form;

enum Plan { FREE, PRO, TEAM }

public record Registration(
    @Form.Required @Form.Label("Full name") String name,
    @Form.Required @Form.Email String email,
    @Form.Required @Form.Password @Form.Length(min = 8) String password,
    Plan plan,                                     // an enum renders a <select>
    boolean newsletter) {}

// Render — the CSRF hidden field comes from the current request
form(Registration.class).action("/register").submit("Sign up")

// Bind a submission
class Users { void create(Registration r) {} }
Users users = new Users();
app.post("/register", req -> {
    Form.Bound<Registration> submitted = Form.bind(Registration.class, req);
    if (!submitted.ok()) {
        return form(Registration.class).action("/register").errors(submitted).submit("Sign up");
    }
    users.create(submitted.value());
    return Response.redirect("/welcome");
});
```

`Bound` carries `value()`, `errors()` (a `ValidationResult` keyed by component name),
`ok()` and `submitted()` (the raw input, so the re-render shows what the user typed).
`Form.validate(Registration.class, values)` runs the same rules without building the
record; the messages come from `jweb.Validators`.

The 2.2.3 `Form.create()` fluent builder and `FormModel` (POJO ⇄ form, `@FormField`,
`bindFromParameterMap`) are both gone — see
[Migrating to 3.0](./../dsl-simplification-3.md).

---

## File Uploads

```java
import jweb.FileUpload;
import jweb.UploadedFile;
import java.nio.file.Path;

app.post("/avatar", req -> {
    UploadedFile file = FileUpload.getFile(req, "avatar");        // never null (empty wrapper)
    Optional<UploadedFile> f = FileUpload.getFileOptional(req, "avatar");
    List<UploadedFile> files = FileUpload.getFiles(req, "photos");

    var validation = FileUpload.validate(file)
        .required().maxSizeMB(5).imagesOnly();                    // or allowedExtensions("pdf")
    if (!validation.isValid()) return Response.badRequest(validation.getFirstError());

    Path dir = Path.of("uploads");
    try {
        Path saved = file.saveTo(Path.of("uploads"));             // UUID filename
        file.saveTo(dir, "custom-name.png");
    } catch (java.io.IOException e) { return Response.serverError(e.getMessage()); }
    file.isImage(); file.getExtension(); file.getSize();
    return Response.noContent();
});
```

Requires multipart requests (Spring `MultipartHttpServletRequest`). There is no
`FileUpload.single(...)` — use `getFile`.

An `UploadedFile` component in a form record renders the file input and switches the form
to `multipart/form-data` on its own, so there is no enctype to remember:

```java
import java.nio.file.Path;

public record Attachment(@Form.Required String title, UploadedFile document) {}

form(Attachment.class).action("/upload").submit("Upload")

Form.Bound<Attachment> bound = Form.bind(Attachment.class, req);
if (bound.ok()) bound.value().document().saveTo(Path.of("uploads"));
```

`saveTo` writes to this machine's disk. For files the app keeps — and must find again from
another instance — hand the upload to `Storage`, below.

---

## File storage (`jweb.Storage`)

Where the app keeps the files its users give it: a key, some bytes, a content type. A
directory in development, a bucket in production, the same calls — and the app serves the
files itself at `/files/<key>` until a bucket takes over.

```java
import jweb.Storage;
import jweb.FileUpload;
import jweb.UploadedFile;
import static jweb.El.*;

record User(String id, String avatarKey) {}
User user = new User("42", "avatars/3f2a9c.png");
byte[] csv = "a,b\n".getBytes();
String exportId = "7";

// An upload, stored under a generated key in a folder: avatars/<32 hex>.png
UploadedFile file = FileUpload.getFile(req, "avatar");
Storage.Stored avatar = Storage.upload("avatars", file);
String keep = avatar.key();                                 // store this on the user

// Shown on a page: the URL the browser fetches it from
Element picture = img(src(Storage.url(user.avatarKey())), alt("Avatar"));

// Any bytes under any key; read them back; list a folder; delete
Storage.put("exports/" + exportId + ".csv", csv, "text/csv");
Optional<byte[]> bytes = Storage.bytes("exports/" + exportId + ".csv");
Optional<Storage.Stored> info = Storage.get("exports/" + exportId + ".csv");   // size, type, modified, etag
List<Storage.Stored> exports = Storage.list("exports/");
Storage.delete("exports/" + exportId + ".csv");

// A private document: a link that stops working after ten minutes
Element link = a(href(Storage.url("invoices/" + exportId + ".pdf", Duration.ofMinutes(10))), "Download");

// A route of your own, when the URL should be yours: the same response the built-in route sends
app.get("/invoices/:id", r -> Storage.download(r, "invoices/" + r.param("id") + ".pdf", "invoice-" + r.param("id") + ".pdf"));
```

`Storage.put(key, upload)` stores an upload under an exact key; `Storage.open(key)` streams a
large file instead of reading it whole; `Storage.key("avatars", "me.PNG")` is the generated
key `upload` uses (`avatars/<32 hex>.png` — the extension kept, the original name yours to
record). Keys are `/`-separated paths; `..`, backslashes and control characters are refused.

**The stores.** `Storage.Local` (the default, `jweb.storage.dir`, `storage/` by default) keeps
each key as a file under the directory; the type follows the key's extension, so keep one.
`Storage.S3` is a bucket that speaks the S3 API — Amazon S3, Cloudflare R2, MinIO, Backblaze
B2, DigitalOcean Spaces, Google Cloud Storage with HMAC keys — with requests signed by the
framework itself (Signature Version 4, checked against Amazon's published vectors in the
suite) over the JDK's HTTP client: nothing to add. `Storage.Memory` is for tests. Any
`Storage.Store` bean replaces the lot.

```yaml
jweb:
  storage:
    dir: storage                 # the local store's directory (ignored once a bucket is set)
    path: /files                 # where the app serves them
    access: public               # or signed: every URL carries an expiry and a signature
    s3:
      bucket: ${S3_BUCKET:}      # set → the S3 store
      region: us-east-1
      endpoint:                  # blank = Amazon; https://<account>.r2.cloudflarestorage.com, http://minio:9000 …
      access-key: ${S3_ACCESS_KEY:}
      secret-key: ${S3_SECRET_KEY:}
      public-url:                # a public bucket or a CDN: Storage.url(key) becomes <public-url>/<key>
```

**Serving.** For the local store the framework mounts `GET /files/*` at startup
(`jweb.storage.serve: false` to mount it yourself with `Storage.serve()`): the content type,
an ETag and a 304 when the browser has it, `Last-Modified`, `Cache-Control`
(`jweb.storage.cache-control`, an hour private by default) and byte ranges for video. It is a
route like any other — guard it: `app.guard("/files/private/*", Auth.requireAuth())`. With
`jweb.storage.access: signed`, `Storage.url(key)` signs every URL under `jweb.auth.secret`
(valid for `jweb.storage.url-ttl`, an hour) and the route serves nothing without a valid
signature — the private-bucket behaviour, on disk. For S3 `Storage.url(key)` is a presigned
URL for the same hour, or a plain one under `public-url`; `Storage.url(key, ttl)` presigns
for exactly that long (a week at most, Amazon's cap); `Storage.uploadUrl(key, type, ttl)` is
a presigned `PUT` for a browser that uploads straight to the bucket.

**What it is not.** No image transforms (`/jweb/img` does those for files it can reach by
URL), no multipart uploads to S3 (one `PUT` per file — fine up to the five gigabytes S3
allows), no metadata beyond the content type. The S3 store reads a file fully before sending
it, so an upload is held in memory once.

---

## Document → Markdown Conversion (markitdown)

`framework/markdown/Markitdown.java` wraps [Microsoft's markitdown](https://github.com/microsoft/markitdown)
CLI to convert PDF, Word, PowerPoint, Excel, HTML, CSV, images, and more into Markdown.

**Setup** (one-time, project-local — the venv lives in `.tools/`, which is gitignored):

```bash
brew install uv
uv venv .tools/markitdown --python 3.12
uv pip install --python .tools/markitdown/bin/python "markitdown[all]"
```

**Java API:**

```java
import jweb.Markitdown;
import java.nio.file.Path;

byte[] bytes = new byte[0];
String md = Markitdown.convert(Path.of("report.pdf"));   // from disk
String md2 = Markitdown.convert(bytes, "docx");          // in-memory (temp file under the hood)
Markitdown.isAvailable();                                 // CLI installed?
```

Throws `Markitdown.MarkitdownException` on missing CLI, bad input, non-zero exit, or timeout.

**REST endpoints** (`app/api/MarkitdownApi.java`):

- `GET /api/v1/markitdown/status` → `{"available": true}`
- `POST /api/v1/markitdown/convert` (multipart, field `file`) → `{"filename": ..., "markdown": ...}`
  or `{"error": ...}`

**Config** (`application.yaml`):

```yaml
jweb:
  markitdown:
    command: ${MARKITDOWN_CMD:.tools/markitdown/bin/markitdown}
    timeout-seconds: 120
```

Notes: audio transcription needs `ffmpeg` (`brew install ffmpeg`); multipart upload limit is
25 MB (`spring.servlet.multipart`). Conversions run as a subprocess per request — CPU-bound
for large PDFs.

---

## Mail (`jweb.Mail`)

A message built like an element, sent now or later. No mail library: with
`jweb.mail.smtp.host` set, the framework's own SMTP client sends it (STARTTLS on 587 by
default, implicit TLS on 465, AUTH PLAIN or LOGIN with a username); without it, the
**outbox** keeps the last two hundred messages in memory and logs each one — development and
tests read them back.

```java
import jweb.Mail;
import jweb.Jobs;
import static jweb.El.*;

record User(String email, String name) {}
User user = new User("ada@example.com", "Ada");
byte[] csv = "a,b\n".getBytes();

Mail.to(user.email())
    .subject("Welcome")
    .html(div(h1("Welcome, " + user.name()), p("Your account is ready.")))   // the HTML DSL; a text part is derived
    .send();

Mail.to(user.email()).cc("ops@example.com").from("Reports <reports@example.com>").replyTo("help@example.com")
    .subject("Your export")
    .text("Attached.")
    .attach("export.csv", csv, "text/csv")
    .header("X-Campaign", "exports")
    .later();                                 // a durable job: sent by the worker, retried, survives a restart

Mail.Message last = Mail.outbox().last();    // in a test: what was "sent"
Mail.outbox().messages();
Mail.outbox().clear();
```

```yaml
jweb:
  mail:
    from: "App <no-reply@example.com>"       # the From when a message sets none
    smtp:
      host: ${SMTP_HOST:}                    # blank = outbox (log, do not send)
      port: 587
      username: ${SMTP_USER:}
      password: ${SMTP_PASSWORD:}
      tls: starttls                          # starttls | implicit | none
```

`send()` throws `Mail.MailException` with the server's refusal (`AUTH PLAIN refused: 535 …`,
`RCPT TO x refused: 550 …`); `later()` leaves that to the job's retries. The text part, when
not given, is derived from the HTML (headings and paragraphs become lines, lists get dashes,
links keep their URL). Non-ASCII subjects are RFC 2047 encoded; bodies and attachments go
base64, so any content is safe. Any `Mail.Sender` bean replaces the sender — an HTTP API is
a few lines:

```java
import jweb.Mail;
import jweb.Fetch;

Mail.Sender resend = message -> Fetch.post("https://api.resend.com/emails")
    .header("Authorization", "Bearer " + System.getenv("RESEND_API_KEY"))
    .json(Map.of("from", message.from(), "to", message.to(), "subject", message.subject(),
        "html", message.html(), "text", message.text()))
    .send();
Mail.sender(resend);
```

## Internationalization

```java
import jweb.I18n;
import jweb.Messages;

record User(String name) {}
Request request = req;
User user = new User("Ola");

// Register bundles programmatically (no properties-file auto-loading yet)
Messages.setDefaultLocale(Locale.ENGLISH);
Messages.load("en", Map.of("greeting", "Hello, {0}!"));
Messages.load("no", Map.of("greeting", "Hei, {0}!"));

// Resolve locale: ?lang= param → session → cookie → Accept-Language → default
Locale locale = I18n.getLocale(request);
app.use(I18n.middleware());                       // sets the thread-local locale per request

String msg = I18n.t("greeting", user.name());     // uses current locale
String msg2 = Messages.get("no", "greeting", "Ola");

// Plurals: one entry per form the language has, {0} is the count
Messages.load("en", Map.of("items.one", "{0} item", "items.other", "{0} items"));
Messages.load("ru", Map.of("items.one", "{0} товар", "items.few", "{0} товара", "items.many", "{0} товаров"));
Messages.plural("items", 1);                      // "1 item"
Messages.plural("ru", "items", 22);               // "22 товара"  (few)
String msg3 = I18n.plural("items", 5);            // current locale; your args follow as {1}, {2}…
Plural.Category form = Plural.of(Locale.FRENCH, 0);   // ONE — 0 is singular in French
```

`Plural` carries the CLDR cardinal rules for English-like languages, French (0 and 1), the
East Slavic three-form languages, Polish, Czech/Slovak, Arabic, Romanian, Hebrew and the
languages without plural forms; `Plural.register("sl", n -> …)` adds one. A form the bundle
lacks falls back to `.other`, then to the bare key.

> The middleware intentionally leaves the locale set after the chain returns (the framework
> clears it at the end of the request), so lazily-rendered elements can call `I18n.t(...)`
> safely. `I18n.t(request, key, ...)` (explicit request) also works anywhere.

---

## Testing

```java
import jweb.JWebTest;
import jweb.MockRequest;
import jweb.Request;
import jweb.TestClient;

record User(String name) {}
RouteHandler handler = r -> Response.ok().build();
Element element = div();
String token = "test-token";

// Unit-test a handler with a mock request
Request req = MockRequest.get("/users/42").pathParam("id", "42").build();
JWebTest.TestResult result = JWebTest.testHandler(handler, MockRequest.get("/x"));
result.getStatus(); result.bodyContains("Hello"); result.bodyAs(User.class);

// Render assertions (they take the rendered HTML string)
JWebTest.assertContains(element.toHtml(), "Welcome");
JWebTest.assertHasClass(element.toHtml(), "card");
JWebTest.assertHasAttribute(element.toHtml(), "data-id", "42");

// Integration tests against a running server
Object newUser = Map.of("email", "john@example.com");
TestClient client = TestClient.localhost(8085).withAuth(token);
client.get("/api/v1/users")
    .assertOk()
    .assertJson()
    .assertBodyContains("john@example.com");
client.post("/api/v1/users").json(newUser).send().assertStatus(201);
```

Note: `JWebTest.test(app, mockRequest)` exercises page routes (`app.pages(...)`) and Router
routes alike — the middleware stack and lifecycle hooks run, and method mismatches return 405,
matching production dispatch.

### Server events without a browser

A page's `onClick(e -> ...)` handlers, `live(...)` regions and state bindings only come alive
over the WebSocket. `JWebTest.render(template)` gives a test that loop without the browser: the
template renders in a fresh render context that stays alive, `click` / `input` / `submit` /
`fire` run the handler an element carries through the same dispatch the WebSocket handler
uses, and the DOM patches that come back are applied to `html()` the way the runtime would
morph them in.

```java
import jweb.JWebTest;
import jweb.RenderedPage;
import jweb.state.State;
import static jweb.State.*;

class CounterPage implements Template {
    State<Integer> count;

    public Element render() {
        count = useState(0);
        return div(
            live(count, c -> p("Count: " + c)),
            button(id("inc"), onClick(e -> count.set(count.get() + 1)), "+1"),
            form(id("signup"),
                onSubmit(e -> { e.preventDefault(); count.set(Integer.parseInt(e.formData().get("n"))); }),
                input(id("n"), name("n"), onInput(e -> count.set(e.value().isBlank() ? 0 : Integer.parseInt(e.value())))))
        );
    }
}

try (RenderedPage page = JWebTest.render(new CounterPage())) {
    page.html();                                        // "...Count: 0..." with the handler attributes

    RenderedPage.Result r = page.click("inc");          // the server handler, in the page's context
    r.ok();                                             // handled, no error
    r.patches();                                        // {live_1=<p data-live="live_1">Count: 1</p>}
    r.states();                                         // {state_1=1}
    page.html();                                        // the patch applied: "...Count: 1..."

    page.input("n", "41");                              // onInput(e -> ...) with e.value()
    page.submit("signup", Map.of("n", "42"))            // onSubmit with e.formData()
        .defaultPrevented();                            // true: the handler called e.preventDefault()
    page.fire("click", "inc", e -> e.ctrlKey(true));    // any event, any field the browser would report
}
```

Elements are addressed by their `id` (a String or a `jweb.Id` handle). A missing id or an
element with no server handler for that event type throws with the reason; a `Result` whose
`error()` is set is what the browser would have been sent (an expired context, for one). Pass
a `MockRequest` as the second argument when `beforeRender` or `Csrf.token()` need the request.
`close()` clears the context, so a `try`-with-resources leaves nothing registered.

---

### In a real browser

`RenderedPage` runs the event loop without a browser. `jweb.Browser` runs the page in one:
a headless Chrome the framework finds and drives over its own DevTools protocol — no
WebDriver, no Playwright, nothing to add. The runtime's script, the WebSocket, the DOM
patches, the CSS, a real mouse click and real keystrokes, exactly as a visitor gets them.

```java
import jweb.Browser;

int port = 8085;
try (Browser browser = Browser.launch();
     Browser.Page page = browser.open("http://localhost:" + port + "/todos")) {
    page.waitFor("#new-todo");                        // every wait polls, five seconds by default
    page.type("#new-todo", "Write tests");            // real key input: the input events fire
    page.click("#add");                               // a real mouse event at the element's centre
    page.waitForText("ul", "Write tests");            // the patch arrived over the socket
    page.press("Enter");  page.fill("#new-todo", "");  page.submit("form");
    String html = page.html();  String text = page.text("#count");  boolean there = page.exists("#done");
    page.eval("document.title");                      // any expression, as JSON
    page.errors();                                    // console errors and uncaught exceptions — assert it is empty
    page.screenshot(java.nio.file.Path.of("target/todos.png"));
}
```

Gate the test on `Browser.available()` — `@EnabledIf("jweb.Browser#available")` — and it
runs wherever a Chrome or Chromium is found (`JWEB_CHROME`, the usual install locations,
the `PATH`; GitHub's runners have one) and is skipped elsewhere. Boot the app with
`@SpringBootTest(webEnvironment = RANDOM_PORT)` and open `http://localhost:` + the port.
The sample app's `BrowserTest` clicks through the streaming demo this way.

## AI Integration (`framework/ai`)

Chat completions, conversations, tools, and agent loops — **zero extra dependencies**.
JWeb speaks the OpenAI wire format directly over its own `Fetch` client, so it works with
OpenAI, Ollama, Groq, LM Studio, vLLM, or any OpenAI-compatible endpoint. (The Spring AI
starters remain optional in `pom.xml` if you prefer that stack, but nothing requires them.)

**Configure** (`application.yaml` — disabled by default):

```yaml
jweb:
  ai:
    enabled: true
    base-url: ${AI_BASE_URL:https://api.openai.com/v1}   # Ollama: http://localhost:11434/v1
    api-key: ${AI_API_KEY:}                              # blank for local providers
    model: ${AI_MODEL:gpt-4o-mini}                       # Ollama: llama3.2
```

**One-liners:**

```java
String text = "JWeb is a Java web framework.";
String answer = AI.ask("Summarize this: " + text);
```

**Conversations** (history kept between sends):

```java
var chat = AI.chat().system("You are a concise support agent");
String a = chat.send("What is JWeb?");
String b = chat.send("Show me an example");   // remembers the topic
```

**Agents with tools** — the model loops: reason → call your Java functions → see
results → repeat until it has an answer:

```java
class WeatherService { String lookup(String city) { return "sunny"; } }
class DocsIndex { String search(String query) { return ""; } }
WeatherService weatherService = new WeatherService();
DocsIndex docsIndex = new DocsIndex();

Tool weather = Tool.of("get_weather", "Get the weather for a city")
    .param("city", "The city name")
    .handler(args -> weatherService.lookup((String) args.get("city")));

Tool search = Tool.of("search_docs", "Search the documentation")
    .param("query", "Search terms")
    .handler(args -> docsIndex.search((String) args.get("query")));

String result = AI.agent()
    .system("You are a support agent for this app")
    .tools(weather, search)
    .maxSteps(8)
    .onStep((step, info) -> System.out.println("agent step " + step + ": " + info))
    .run("Should I pack an umbrella for Paris this weekend?");
```

Tool handlers receive the model's arguments as a `Map<String, Object>` and can return any
object (serialized as the tool result). Errors inside a tool are reported back to the model
as text, so the agent can recover. `maxSteps` bounds the loop (`AiException` if exceeded).

**Drop-in chat UI** — a widget plus a ready endpoint (`POST /jweb/ai/chat`, active when
`jweb.ai.enabled=true`, per-session history with a 30-minute TTL):

```java
body(
    ...,
    AiChatWidget.render("Ask JWeb")
)
```

Per-call overrides: `AI.chat().model("gpt-4o").temperature(0.2)` — same on agents.
Everything is testable without a key: see `AiModuleTest`, which scripts an
OpenAI-compatible mock with the JDK's built-in HttpServer.

<!-- ==================== END backend ==================== -->


<!-- ==================== BEGIN configuration ==================== -->

# Configuration, environment, dev tools, CLI, project structure

> Source: `readme/guides/configuration.md` · topic id: `configuration`

[← Back to README](./../README.md)

# Configuration

## Configuration files — who owns what

JWeb splits configuration across **two YAML files**:

| File | Owner | Loaded by | Contains |
|------|-------|-----------|----------|
| `src/main/resources/application.yaml` | your app | Spring Boot | port, app name, logging, `jweb.admin.*`, `jweb.data.*` |
| `src/main/resources/jweb.yaml` | framework | `@JWebApplication`'s `@PropertySource` | compression, devtools/livereload, `jweb.dev.*`, `jweb.performance.*` |

`application.yaml` values override `jweb.yaml` (standard Spring property precedence).

### `application.yaml` (the sample app's config)

```yaml
server:
  port: ${PORT:8085}
  servlet:
    session:
      cookie:
        http-only: true
        same-site: lax
        secure: ${SESSION_COOKIE_SECURE:false}   # set true in production (HTTPS)

spring:
  application:
    name: MyApp
  servlet:
    multipart:
      max-file-size: 25MB
      max-request-size: 25MB

logging:
  level:
    org.mongodb.driver: WARN

jweb:
  admin:
    token: ${JWEB_ADMIN_TOKEN:}    # empty default → admin login disabled (fails closed)
    email: ${JWEB_ADMIN_EMAIL:}
  ai:
    enabled: false                 # built-in AI module; any OpenAI-compatible API
    base-url: ${AI_BASE_URL:https://api.openai.com/v1}
    api-key: ${AI_API_KEY:}
    model: ${AI_MODEL:gpt-4o-mini}
  markitdown:
    command: ${MARKITDOWN_CMD:.tools/markitdown/bin/markitdown}
    timeout-seconds: 120
  data:
    enabled: true
    mongo:
      uri: ${MONGO_URI:mongodb://localhost:27017}
      database: ${MONGO_DB:myapp}
```

### `jweb.yaml` (framework defaults)

```yaml
server:
  compression:
    enabled: true
    mime-types: text/html,text/css,text/javascript,application/javascript,application/json,...
    min-response-size: 1024

spring:
  main:
    lazy-initialization: true
  devtools:
    restart:
      enabled: true
      poll-interval: 100ms
      quiet-period: 50ms
      additional-paths: src/main/java/com/osmig/Jweb/app
    livereload:
      enabled: true
      port: 35729

jweb:
  dev:
    hot-reload: true
    watch-paths: src/main/java/com/osmig/Jweb/app
    debounce-ms: 10          # code floor is 10; code default is 50
    debug: false
  performance:
    minify-css: true
    minify-html: false
    prefetch:
      enabled: true
      cache-ttl: 300000
      hover-delay: 300       # code default is 100; yaml overrides to 300
```

## Environment Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | 8085 |
| `JWEB_ADMIN_TOKEN` | Admin login token (empty = admin disabled) | — |
| `JWEB_ADMIN_EMAIL` | Admin email address | — |
| `JWT_SECRET` | JWT signing secret (min 32 chars; used by `Jwt.init()`) | — |
| `MONGO_URI` | MongoDB URI (via `application.yaml` placeholder) | mongodb://localhost:27017 |
| `MONGO_DB` | MongoDB database (via placeholder) | myapp |
| `AI_BASE_URL` | OpenAI-compatible API base URL for the built-in AI module | https://api.openai.com/v1 |
| `AI_API_KEY` | API key for the AI module (Ollama needs none) | — |
| `AI_MODEL` | Model name for the AI module | gpt-4o-mini |
| `MARKITDOWN_CMD` | Path to the markitdown CLI | .tools/markitdown/bin/markitdown |
| `SESSION_COOKIE_SECURE` | Mark the session cookie `Secure` (set true behind HTTPS) | false |
| `JWEB_CHROME` | The Chrome or Chromium `jweb.Browser` launches for browser tests (also `CHROME_PATH`, `CHROME_BIN`); unset, the usual install locations and the `PATH` are searched | — |
| `JWEB_TEST_REDIS_URL` | A real Redis for the `RedisPubSub` and `RedisCache` round-trip tests; unset, they are skipped and the protocol is covered by the in-process stand-ins | — |

`.env` files are supported via the `spring-dotenv` dependency.

> Note: `Mongo.connect()` (the no-arg form) reads `MONGO_URI`/`MONGO_DB` directly with a
> different database default (`test`), but the framework never calls it — the active path is the
> Spring placeholder route through `JWebConfiguration`.

> Docker note: the Dockerfile `EXPOSE`s **8085**, matching the app default.

### Framework toggles (application.yaml)

| Property | Default | Effect |
|----------|---------|--------|
| `jweb.dev.debug` | `false` | Show exception details and stack traces on error pages (dev only) |
| `jweb.dev.strict-html` | `false` | Fail a render on invalid HTML nesting (`p(div(…))`, `ul(div(…))`, `a(a(…))`, `title` outside `head`) instead of logging each pair once |
| `jweb.runtime.enabled` | `true` | Inject the JWeb client runtime (WebSocket events, state sync, DOM patching) into rendered pages |
| `jweb.runtime.expired` | `banner` | What a page does when the server no longer holds its render context: `banner` (fixed notice with a Reload button), `reload`, or `silent` (console + `jweb:expired` event only) |
| `jweb.runtime.reconnect-attempts` | `8` | WebSocket retries after a close (backoff 1s → 30s) before the connection-lost notice; coming back online or to the foreground retries at once |
| `jweb.websocket.allowed-origins` | *(blank = same-origin)* | Comma-separated origins allowed to open the `/jweb` WebSocket (`*` for dev) |
| `jweb.markitdown.command` | `.tools/markitdown/bin/markitdown` | Path to the markitdown CLI |
| `jweb.markitdown.timeout-seconds` | `120` | Max seconds per document conversion |
| `jweb.data.enabled` | `false` (code) — sample app sets `true` | Auto-connect MongoDB on startup (needs the optional `mongodb-driver-sync`) |
| `jweb.sql.migrate` | `true` | Run the pending `jweb.Migrations` when a `DataSource` bean is bound to `jweb.Sql` |
| `jweb.sql.migrations` | `classpath:db/migration` | Where the `V*__*.sql` migration files are |
| `jweb.pubsub.redis.url` | *(blank = in-memory, this instance only)* | Redis for `jweb.PubSub` across instances: `redis://[user[:password]@]host[:port]`, `rediss://` for TLS |
| `jweb.pubsub.redis.prefix` | `jweb:` | The Redis channel prefix a topic gets |
| `jweb.redis.url` | — | One Redis for both: the default for `jweb.pubsub.redis.url` and `jweb.cache.redis.url` |
| `jweb.cache.redis.url` | *(blank = memory)* | `Cache.shared`, `Cache.lock`/`once` and `RateLimit` count in this Redis across every instance; the framework speaks the protocol itself |
| `jweb.cache.redis.prefix` / `connections` | `jweb:cache:` / `4` | Every key starts with the prefix; how many connections to keep open at most |
| `jweb.jobs.enabled` | `true` | Run the durable-jobs worker on this instance (an instance can still enqueue with it off) |
| `jweb.jobs.store` | `auto` | `auto` = the `jweb_jobs` table when `Sql` is connected, else memory; `memory` forces in-memory |
| `jweb.jobs.poll` / `concurrency` | `1s` / `8` | How often the worker looks for due jobs, and how many run at once |
| `jweb.jobs.timeout` / `keep-done` | `10m` / `24h` | A job still running past the timeout is released (its worker died); done jobs are purged after keep-done |
| `jweb.mail.from` | `no-reply@localhost` | The `From` when a message sets none |
| `jweb.mail.smtp.host` / `port` | *(blank = outbox)* / `587` | SMTP server; blank keeps the outbox, which logs messages instead of sending |
| `jweb.mail.smtp.username` / `password` | — | AUTH PLAIN or LOGIN when a username is set |
| `jweb.mail.smtp.tls` | `starttls` | `starttls`, `implicit` (465), or `none` |
| `jweb.observability.request-id` | `true` | The filter that gives every request an id (`req.id()`, `X-Request-ID` both ways, the MDC) |
| `jweb.observability.log-correlation` | `true` | `[requestId traceId] ` on every log line of a request; set `logging.pattern.correlation` to override |
| `jweb.observability.tracing` | `true` | Bind `jweb.Trace` to the app's `ObservationRegistry` when it has one (the actuator) |
| `jweb.storage.dir` | `storage` | The local store's directory (`Storage.Local`); ignored once a bucket is set |
| `jweb.storage.path` | `/files` | Where the app serves stored files; `Storage.url(key)` is `path/key` |
| `jweb.storage.access` | `public` | `signed`: every `Storage.url` carries an expiry and a signature under `jweb.auth.secret`, and the route serves nothing without one |
| `jweb.storage.url-ttl` / `cache-control` | `1h` / `private, max-age=3600` | How long a signed URL lives; the `Cache-Control` the route sends |
| `jweb.storage.serve` | `true` | Mount `GET <path>/*` at startup; false to mount `Storage.serve()` yourself |
| `jweb.storage.s3.bucket` | — | Set → `Storage.S3`; with `region`, `endpoint` (blank = Amazon; R2, MinIO, B2, Spaces, GCS otherwise), `access-key`, `secret-key`, `path-style`, `public-url` |
| `jweb.auth.secret` | *(blank = generated per start, with a warning)* | HMAC secret for `Auth.token`, the remember-me cookie and signed `Storage` URLs; 32+ random characters, the same on every instance |
| `jweb.auth.remember.secure` | `false` | Mark the remember-me cookie `Secure` (set true behind HTTPS) |
| `jweb.ai.enabled` | `false` | Enable the built-in AI module (`AI.ask/chat/agent`) |
| `jweb.ai.base-url` / `api-key` / `model` | OpenAI defaults | Which OpenAI-compatible endpoint and model to use |
| `jweb.ai.temperature` / `timeout-seconds` | `0.7` / `60` | Sampling and per-request timeout for AI calls |

## Database connections

**SQL.** With `spring-boot-starter-jdbc` and a driver on the classpath and
`spring.datasource.url` set, Spring Boot creates a `DataSource`; `SqlConfiguration` binds it
to `jweb.Sql` at startup and runs the pending migrations under `db/migration` first, before
any `ApplicationRunner` of the app's own. No `DataSource` bean, nothing happens. See
[Backend → Database: SQL](./backend.md#database-sql-sql-migrations).

**MongoDB.** `MongoConfiguration` (present only when the optional driver is) registers an
`ApplicationRunner` that calls `Mongo.connect(uri, database)` when `jweb.data.enabled: true`.
This happens **after** context refresh, and applies registered `Schema` indexes on connect.
Set `jweb.data.enabled: false` to run without MongoDB (the test suite runs with it disabled).
The property on without the driver logs what to add.

## Development Tools

### Hot Reload

`DevServer` (`@ConditionalOnProperty jweb.dev.hot-reload=true`) watches
`jweb.dev.watch-paths` with a `WatchService` and exposes `/__jweb_dev/events` (SSE),
`/__jweb_dev/reload`, and `/__jweb_dev/status` via `DevController`. The sample layout includes
`DevServer.script()` in `<body>` so the browser reloads on change.

```java
// In your layout:
body(..., DevServer.script())          // recommended (SSE-based)
// Alternatives via HotReload: fast(), liveReload(), combined(),
// builder().useSSE(true).showIndicator(true).indicatorPosition("bottom-right").build()
```

### Prefetch

Auto-injected on every Element response (`Prefetch.scriptTag()`); hover-prefetches links.
Configure via `jweb.performance.prefetch.*`. For full SPA-style navigation (partial swaps, View
Transitions), additionally include `Navigation.script()` — see the architecture doc.

### CLI Scaffolding

`JWebCli` provides project and file generation:

```bash
# New project: pom, Application, Routes, HomePage, MainLayout — and a database
# (spring-boot-starter-jdbc, H2 as a file under ./data, db/migration/ for the SQL files)
java -cp Jweb.jar com.osmig.Jweb.framework.cli.JWebCli new myapp --package=com.example

# Generators: page (p), component (c), layout (l), form (f), resource (r), api
java -cp ... JWebCli generate page Pricing
java -cp ... JWebCli generate resource Product name:string! price:double inStock:boolean
```

A resource is the record (the row and the form), the migration that creates its table
(`V<next>__create_products.sql`), a list page, a form page and the `JWebRoutes` bean that
mounts them under `/products` — on `jweb.Sql`, nothing to register. Field types: `string`,
`text`, `int`, `long`, `double`, `boolean`, `date`, `datetime`, `email`, `password`; a
trailing `!` makes the field required. `Admin.mount(app, "/admin", Sql.table("products",
Product.class))` is the back office for it.

### AI-assisted development

`.claude/` is gitignored and local to each machine; it currently holds the `impeccable`
design-audit skill (see [Design Tooling](./design-tooling.md)) rather than any checked-in
agents.

## Project Structure

### Application code (`src/main/java/com/osmig/Jweb/app/`, ~122 files)

```
app/
├── api/                       # REST controllers (Spring MVC dispatch)
│   ├── AdminApi.java             # Session-based admin auth (env-configured token) + messages
│   ├── ContactApi.java           # @REST("/api/v1/contact") — saves submissions to MongoDB
│   ├── ExampleApi.java           # @REST("/api/v1/example") — demo CRUD (stub data)
│   ├── MarkitdownApi.java        # @REST("/api/v1/markitdown") — document conversion
│   └── MessageStore.java         # shared contact-message persistence
├── docs/                      # ~90 files — the /docs documentation site
│   ├── DocsPage.java             # 3-column shell (sidebar | content | on-this-page rail)
│   ├── DocContent.java           # section-id → section dispatch (21 sections)
│   ├── DocSidebar.java           # nav groups (Basics/Core/Features/Advanced/More)
│   ├── DocExamples.java          # ~101 code-sample constants (~1580 lines)
│   ├── DocComponents/DocStyles/DocsNavScript
│   └── sections/                 # 21 top-level sections + per-topic subpackages
├── forms/
│   ├── AdminLogin.java          # the admin sign-in form (a record)
│   ├── ContactForm.java         # the contact form (a record — it IS the form)
│   └── ContactStatus.java       # the status fragment the swap target receives
├── layout/
│   ├── Layout.java               # html > Head + body[Nav, main(content), Footer, DevServer.script()]
│   ├── Head.java                 # meta/title + global Stylesheet + keyframes
│   ├── Nav.java                  # sticky animated-gradient navbar
│   ├── Footer.java               # glassmorphism footer
│   └── Theme.java                # design tokens (colors, spacing, text sizes, radii)
├── pages/
│   ├── HomePage.java  AboutPage.java  ContactPage.java
│   └── admin/
│       ├── AdminLoginPage.java       # posts to /only-admin/log/in
│       └── AdminMessagesPage.java    # contact-submission grid
├── sandbox/                   # /sandbox live DSL playground (sandboxed rendering)
├── subheader/                 # scroll-synced "On This Page" rail
├── App.java                   # @JWebApplication entry point
└── Routes.java                # the single JWebRoutes implementation
```

### Framework code

~247 Java files across ~47 packages — see the package tree in
[Architecture](./architecture.md#framework-structure).

### Root-level documents

| File | Purpose | Status |
|------|---------|--------|
| `README.md` | User-facing overview (this doc set) | current |
| `readme/*.md` | The 12 detailed docs | current |
| `STANDARD.md` | Coding standards for JWeb apps (file size, DSL-only, separation) | current |
| `dsl-todos.md` | DSL improvement tracker | ⚠️ several "remaining" items actually shipped (anchor positioning, scroll snap, popover, IndexedDB, …) |
| `PLAN.md` | Original design doc (2025-12) | historical — describes aspirational WebSocket state sync |
| `JWEB_EXAMPLES.md` | 20-level DSL tutorial | ⚠️ stale imports (`Elements.*` vs `El.*`) |
| `prompt.md` | AI-agent governance prompt | stale; contradicts STANDARD.md on file-size limits |
| `src/main/java/com/osmig/Jweb/framework/docs/*.md` | 15 internal reference docs, shipped in the jar | mostly current; no doc for db/mongo or openapi |

## Dependencies (from `pom.xml`)

**Coordinates:** `com.osmig:Jweb:2.0.0` · **Parent:** `spring-boot-starter-parent:4.0.8` ·
**Java:** 21

| Dependency | Version | Purpose |
|------------|---------|---------|
| spring-boot-starter-web | (managed 4.0.8) | HTTP handling |
| spring-boot-starter-websocket | (managed) | WebSocket support |
| spring-boot-starter-test | (managed, test) | Testing |
| spring-boot-devtools | (managed, runtime, optional) | Hot reload |
| spring-dotenv | 4.0.0 | `.env` file support |
| jackson-databind | (managed) | JSON processing |
| mongodb-driver-sync / bson / mongodb-driver-core | 5.10.0 (optional) | MongoDB — add it to use `jweb.Mongo` |
| h2 | (managed, test) | The suite's in-memory database for `jweb.Sql` / `jweb.Migrations` |
| spring-security-crypto | (managed, optional) | BCrypt hashing |
| jjwt-api / jjwt-impl / jjwt-jackson | 0.12.7 (optional) | JWT support |

`jweb.Sql` is written against `javax.sql.DataSource` only: an app adds
`spring-boot-starter-jdbc` and its driver; nothing JDBC-specific ships from the framework.

**Commented out (not active):** spring-boot-starter-data-jpa, Spring AI OpenAI/Ollama
(2.0.0-M1), Testcontainers.

**Build plugins:**

- `maven-resources-plugin` — copies `framework/**/*.java|*.md` into the jar under
  `framework-src/` (used by the CLI scaffolder) and `README.md` under `readme/`.
- `spring-boot-maven-plugin` — mainClass `com.osmig.Jweb.app.App`, with
  `--add-opens java.base/sun.misc` and `java.base/java.nio` JVM args. The default build
  is the publishable **library** (sample app excluded, repackage skipped); build the
  runnable showcase with `-Pdemo`, which produces `Jweb-<version>-exec.jar` (the
  `exec` classifier keeps the plain library jar as the main artifact).

## Feature Reference (smaller packages)

| Feature | Package | Key API |
|---------|---------|---------|
| Internationalization | `i18n/` | `Messages.load(lang, map)`, `I18n.t(key, args)`, `I18n.middleware()` — see [Backend](./backend.md#internationalization) |
| File Uploads | `upload/` | `FileUpload.getFile(req, name)`, `validate(...)`, `UploadedFile.saveTo(dir)` |
| File Storage | `storage/` | `Storage.upload(folder, file)`, `put/get/bytes/open/list/delete`, `Storage.url(key[, ttl])`; `Local`, `S3`, `Memory` — see [Backend](./backend.md#file-storage-jwebstorage) |
| Background Jobs | `async/` | `Jobs.run/submit/track`, `Scheduler.cron(...)`, `Suspense` — see [State & Realtime](./state-and-realtime.md) |
| Health Checks | `health/` | `Health.register(name, check)`, `Health.setupEndpoints(app)` → `/health[/live|/ready]` |
| Metrics | `metrics/` | `Metrics.counter/gauge/timer`, `Metrics.middleware()`, `/metrics` (JSON or Prometheus) |
| Request ids & traces | `metrics/`, `server/` | `req.id()`, `X-Request-ID`, the id on every log line; `Trace.span/in`, spans around routes, SQL, jobs, PubSub, mail, Fetch, events — see [Architecture](./architecture.md#health--metrics) |
| Testing | `testing/` | `JWebTest`, `MockRequest`, `MockSession`, `TestClient` |
| Accessibility | `accessibility/` | `A11y.validate(element)` — WCAG 2.1 **auditor** (checks alt text, labels, heading order; it is not an ARIA helper library) |
| SSE | `sse/` | `SseBroadcaster`, `SseEmitter`, `SseEvent` |
| WebSockets | `websocket/` | auto-registered at `/jweb` |
| View Transitions | `transition/` | `Transition.when(show).enter(...)`, `attrs().transition()` |
| Portals | `portal/` | `Portal.to(name, el)` / `Portal.outlet(name)` |
| Caching | `cache/` | `Cache.create(ttl)`, `getOrSet`, `Cache.named(...)` in memory; `Cache.shared(name, Type.class)`, `Cache.once`, `Cache.lock` across instances (Redis under `jweb.cache.redis.url`) |
| Error Handling | `error/` | `JWebException`, `ErrorHandler.errorHandling()` middleware |
| HTTP Client | `http/` | `Fetch.get(url).bearer(t).send()` → `FetchResult` |
| Hydration | `hydration/` | `HydrationData`, `VNodeSerializer` (see State & Realtime) |
| Navigation | `navigation/` | `Link.to(...)`, `Navigation.script()` |
| UI Components | `ui/` | `UI.*` builders, `Toast.setup()` |
| Context | `context/` | `Context.key/provide/use/find` |
| CLI | `cli/` | `JWebCli` (`Templates` is package-private) |

## Testing status

The suite (107 tests across 14 classes, no MongoDB required) covers Route matching and 405
semantics (`RouterTest`), middleware ordering/glob scoping/queued headers
(`MiddlewareStackTest`), the reactive-state loop — context lifetime, scoped handlers,
live regions (`StateLoopTest`, `LiveRegionTest`) — Mongo `Schema` validation (`SchemaValidationTest`), the
DSL fixes (`DslFixesTest`), page routes end-to-end through `JWebController`
(`JWebTestPageRouteTest`), the AI module (`AiModuleTest`), streaming SSR (`StreamingTest`),
typed routes (`TypedRouteTest`), CSP nonces (`CspNonceTest`), sandbox hardening
(`SandboxSecurityTest`), and the v1.1+ import surface (`JwebShortImportsTest`,
`LegacyImportsCompatTest`), plus the Spring context smoke test (which runs with
`jweb.data.enabled=false`). Run with `./mvnw test`. Next candidates: `AdminApi.login` and
`ContactApi.submit`.

<!-- ==================== END configuration ==================== -->


<!-- ==================== BEGIN known-issues ==================== -->

# Known issues and sharp edges — the API pitfalls

> Source: `readme/guides/known-issues.md` · topic id: `known-issues`

[← Back to README](./../README.md)

# Known Issues & Sharp Edges

A verified, code-level inventory of gaps and pitfalls. Originally compiled 2026-08-08 by
auditing every framework package; **updated 2026-08-09 after a framework-wide fix pass** that
resolved the large majority of the items. Fixed items are kept (collapsed) for history; the
short list of remaining sharp edges is what still deserves attention.

Legend: ✅ fixed 2026-08-09 · 🟡 remaining pitfall (by design or deferred)

---

## 2026-10-15 — 3.0.3: the backend set, and its sharp edges

Additive release; the list is in the [changelog](../CHANGELOG.md#303--2026-10-15). Verified in
the review before the tag and left as they are, by design or deferred:

- **`Sql.Table` writes column names unquoted.** A record component named `order`, `user` or
  `group` becomes a bare identifier and the database rejects it (on Postgres `user` is a
  function). Rename the component, or write that statement with `Sql.query`.
- **`Cache.shared(...).increment` is `INCRBY` then `PEXPIRE`**, not one atomic step: a crash
  between the two leaves a counter without a TTL. Right for a rate limit, not for a ledger.
- **A PubSub message published between a page's render and its socket's `init` is lost.**
  A page that must not miss one re-reads its source when the state is first shown.
- **`Auth.Totp.verify` has no replay protection** — a code is valid for its whole window plus
  the skew. Record the last accepted counter per user if a replay matters to you.
- **`Mail` does not fold long headers** (RFC 5322 line length). Keep subjects and recipient
  lists reasonable.
- **A `Jobs` worker with no handler for a job's name still claims it** and fails it with a
  clear error, so every instance of a fleet registers every handler.
- **`Admin` relies on `Middlewares.csrf()`** (part of `recommended()`) for its POSTs; it does
  not validate the token itself. Mount it behind the recommended stack.
- **`v("name")` is still the JavaScript local in the docs sections and the Sandbox.**
  `jweb.Var` (`local("q")`) is the handle to prefer; 4.0 makes it the only form
  ([the plan](./roadmap-4.0.md)).

---

## 2026-10-08 — the 3.0.2 pass: no raw CSS or JS strings

The before → after detail is in [DSL 3.0.2](../dsl-3.0.2.md). What to know when writing code
against it:

- **`cls()` and `id()` return handles** (`jweb.Cls`, `jweb.Id`), not `Attr`. A handle is an
  element argument, an immutable `jweb.css.Selector` and a JS target, so declare it once
  (`static final Cls CARD = cls("card")`) and use it in `rule(CARD.hover(), …)`,
  `delegate(LAYOUT, "click", COPY)`, `byId(EDITOR)`. Selector chains never mutate the handle.
- **Tag and universal selectors are `Selector.type("pre")` and `Selector.any()`**, qualified —
  the `Selectors` starters (`tag`, `all`, `cls`, `id`) still collide with `El` under a wildcard.
- **`Template.scripts()` is collected like `styles()`**: every template rendered in a page
  contributes once, and the script rides fragments and patches. `inlineScript(Action)` exists
  for a one-off; `*Script` helpers return `Action`, not `String`.
- **`Middlewares.recommended()` now includes `csrf()`.** A router POST without the token is a
  403 before the handler runs. Record forms and `Csrf.tokenField()` cover HTML forms; a
  hand-written `fetch` sends the `_csrf` parameter read from `Csrf.tokenMeta()`. `/api/v*`
  controllers are outside the stack as before.
- **Pages with constructor arguments belong in the page table**:
  `app.pages("/x", req -> new XPage(req.query("q")))`. `Response.html(template)` is rendered
  as a page too (runtime, hydration, scripts, styles), with your status and headers.
- **`Csrf.tokenField()` / `tokenMeta()` / `token()` take no argument** inside a render.
- **Guards:** `jweb/AppDslHygieneTest` (no raw strings in app code; `// raw: why` marks a
  deliberate demo) and `jweb/CssSpecCoverageTest` (every listed CSS property has a camelCase
  setter, every listed keyword a constant — extend the lists when you extend the DSL).

---

## Remaining sharp edges (🟡)

By design (know them, don't "fix" them):

- **`State<T>` takes its own import.** `jweb.State` is the hooks facade (`useState`, `live`,
  `bindAttr`), so it owns the simple name `State` in `jweb`; the value type is
  `jweb.state.State<T>`. Under `import jweb.*` alone, `State<Integer> n = useState(0)` is
  "type State does not take parameters" — add `import jweb.state.State;`, which shadows the
  facade for the type position while `import static jweb.State.*` still supplies the hooks.
- **A facade wildcard cannot sit next to a wildcard on a class it re-exports.** Java reports
  "reference to X is ambiguous" for every name declared in both, and nothing but removing one
  of the two declarations fixes it (a class cannot inherit from ten modules, and deprecating a
  static does not hide it). Two decisions follow, both 2026-09-11: `jweb.Js` no longer
  re-exports the ten browser modules — `Js.*` is the page-level DSL and each `jweb.js.JS*`
  module takes its own static import beside it (`jweb/js/ModuleImportsCoexistTest` compiles
  all ten with the four wildcards); and `import static jweb.el.SVGElements.*` must not be
  combined with `El.*` — `jweb.El` now carries every SVG element *and* attribute static
  itself (`circle(cx(50), cy(50), r(40))`), so that second import is never needed and
  `jweb.el.SVGElements` remains only for code that imports it alone.
- **Two `jweb` names dodge a `jweb.api` annotation.** The typed query parameter is
  `jweb.QueryParam<T>` (not `Query` — `@Query` is the `@REST` parameter annotation) and the
  Set-Cookie builder is `jweb.http.Cookie` (`@Cookie` likewise), so a REST class that imports
  `jweb.*` and `jweb.api.*` never sees an ambiguous simple name.
- **Paths starting with `/api/v` are hard-excluded from the JWeb router** (reserved for Spring
  MVC `@REST` controllers). `app.get("/api/v1/x", ...)` will never be reached — keep REST
  controllers under `/api/v*` and router/page routes elsewhere.
- **Page routes are GET/HEAD-only** (other methods get a 405). Since 3.0 they take
  `:param` segments and a `*` wildcard (`app.pages("/users/:id", UserPage.class)`); exact
  paths still resolve by lookup and win over patterns.
- **Guards cover everything under a prefix — pages, router routes and `@REST`
  controllers** (`app.guard("/admin/**", ...)`), but **middleware still does not apply to
  `@REST` controllers**: `app.use(...)` runs only for what `JWebController` dispatches.
- **Render contexts are in-memory and per-instance** (five idle minutes, `StateManager`).
  One instance, or sticky sessions for `/jweb` and the pages that use server events, live
  regions or state bindings — a reconnect that reaches another instance
  is answered `known:false` and the page shows its expired notice. Since 2026-10-10 the
  runtime does tell the user: *This page has expired* (or *Connection lost*) with a Reload
  button, configurable with `jweb.runtime.expired` / `jweb.runtime.reconnect-attempts`; see
  [State & Realtime](./state-and-realtime.md#when-the-connection-drops-and-when-the-page-expires).
  What does cross instances: `PubSub` through Redis, the session (and the OAuth2 state in
  it) through Spring Session — the table in
  [Running more than one instance](./state-and-realtime.md#running-more-than-one-instance).
- **`Template.session()` / `Template.pathParam()` read the request in flight.** They work
  while a page route or router handler renders (streamed blocks included), not from a bare
  `toHtml()` in a test or inside a WebSocket event handler — capture what a handler needs
  at render time.
- **`Template.title()` cannot exist** — a method named `title` on `Template` would shadow the
  `title(...)` element factory inside every Template (a class member shadows a static import
  by simple name, whatever the arity). The plain-String metadata hooks are therefore
  `pageTitle()` and `description()`.
- **The four DSL wildcards coexist** (`jweb.El.*`, `jweb.Css.*`, `jweb.Js.*`,
  `jweb.Three.*`) — since 3.0 no call is ambiguous across them, and a scratch file that
  compiles the common calls under all four imports is the check to re-run when adding a
  static to any facade. The names that are still shared resolve by argument type
  (`em(1.2)` is a unit, `em("x")` an element; `span(2)` a grid span, `span("x")` an
  element). Three deliberate qualifications remain: `Three.patch(id)` (bare `patch(url)`
  is HTTP PATCH), the CSS Selector-builder starters live in `jweb.css.Selectors`, and
  `object()` with no arguments is the JS object builder, not `<object>`.
- **Under `Js.*`, `fetch(url)`, `call(fn)` and `sleep(ms)` are the page-level `Action`
  forms.** The expression-level twins are `fetch(str(url))` / `fetch(v("url"))`,
  `JS.call(fn, args...)` and `delay(ms)`.
- **There are no typed input helpers any more.** `jweb.Input` and the 23
  `textInput`/`emailInput`/`field(...)` statics were deleted in 3.0: a form is a record
  (`form(Contact.class)`, see [HTML DSL](./html-dsl.md#forms-a-record-is-the-form)), and
  anything outside a form is `input(type("text"), name("q"), id("q"))`.
- **Three attributes have no free static name**, because an element already owns it:
  `title`, `label` and `cite` are `attrs().title(...)` / `attrs().label(...)` /
  `attrs().cite(...)`. `button(title("x"), …)` compiles and nests a `<title>` element —
  the renderer reports it, as it reports every nesting the browser would repair
  (`p(div(…))`, `ul(div(…))`, `a(a(…))`): once per parent/child pair in the log, or as a
  failed render with `jweb.dev.strict-html: true` (since 2026-10-10, `HtmlNesting`).
- **Three SVG elements carry an `svg` prefix** so the four wildcard imports stay
  unambiguous: `svgText` (plain `text` is the text node), `svgLinearGradient` and
  `svgRadialGradient` (the plain names are CSS values).
- **`jweb.yaml` sets `prefetch.hover-delay: 300`** while the code default is 100 — either is
  fine, just know yaml wins.
- **Inline handler corners that stay inline on purpose.** Every handler form is CSP-safe
  inside a page render (fixed 2026-08-31, see below) — including raw
  `set("on<type>", js)`, which the serializer rewrites to delegation at render time. What
  deliberately keeps classic inline attributes: renders outside any request context
  (static export, bare `toHtml()`), elements opting out via `attrs().inlineHandlers()`
  (for content that ships without the runtime), and `on<type>` types the runtime doesn't
  delegate (SMIL `onbegin`/`onend`/`onrepeat` — rewriting those would kill handlers that
  at least work on CSP-less pages). Error pages carry no JS handlers at all — retry is an
  empty-href anchor, and `handleError` detaches the render context so custom error content
  never registers definitions nothing delivers.
- The Spring AI starters in pom.xml are commented out **by design** — AI integration ships
  built-in (`framework/ai`: `AI.ask/chat/agent`, zero extra dependencies, any
  OpenAI-compatible endpoint). Don't uncomment them unless you actually want the Spring AI
  stack as an alternative.

## 2026-09-11 — the 3.0 DSL pass, in one place

Six batches landed on `main` between `fe4e331` and the commit that carries this note; the
before → after detail is in [Migrating to 3.0](../dsl-simplification-3.md). What each one
guards itself with:

| Batch | What changed | Guard |
|---|---|---|
| Short names are the real names | every type an author can name is the real class under `jweb.*` (`Tag`, `Attributes`, `Event`, `Action`, `Val`, `Func`, `Request`, `Response`, `JWebException`, `jweb.css.*`, `jweb.three.*`…); the old FQNs are `@Deprecated` aliases | `jweb/JwebSurfaceTest`, `jweb/NoLongImportsTest` |
| CSS | page-owned stylesheets (`Template.styles()`, one nonce-stamped `<style>`), `:hover`/`:focus`/`dark`/media on inline styles via generated classes, the `row/stack/center/card/…` mixins, one custom-property `Theme`, the modern modules folded into `Css` and typed, `@starting-style` and friends | `jweb/CssMixinsTest`, `PageStyles*` tests, `DslPass3Test` |
| JS | behaviors as Actions (`copy`, `scrollSpy`, `navigate().prefetch()`, `splitPane`, `lineGutter`…), `return_()`/`preventDefault()` as statements, custom elements, `jweb.js.Pwa`, `syncState`; the app's own scripts contain no raw JavaScript | `jweb/js/GeneratedJsSyntaxTest`, `jweb/js/ModuleImportsCoexistTest` |
| State, routing, API | `live(state, s -> el)` regions, `bind(state)` renders its value, `jweb.Session`, record-bound action routes, `:param` page routes, `app.guard`, Spring-free `jweb.api` parameter annotations, no global handler fallback | `jweb/state/LiveRegionTest`, `NoSpringInAppApiTest` |
| HTML + forms | `cls`/`classes`/`classIf`, `when(c, a, b)` and `when(c).then().otherwise()`, `text()` deprecated, `.done()` gone, the whole SVG set, one record-bound `form(X.class)` | `jweb/AppDslHygieneTest` |
| Docs | every Java sample in the docs site, the readmes and the framework's topic files compiles | `jweb/DocSamplesCompileTest`, `ReadmeSamplesCompileTest` |
| What the docs test found | `app.post(path, req -> …)` unambiguous (the `Function` overload is gone); `jweb.QueryParam`, `jweb.AI`/`Chat`/`Agent`/`Tool`, `jweb.RateLimit`/`Cors`/`OAuth2`, `jweb.http.Cookie`, `jweb.MiddlewareChain`, `jweb.Health`/`HealthStatus`/`HealthCheck`, `jweb.Metrics`, `jweb.Context`/`ContextKey`, `jweb.Link`/`Navigation`; every `Style` property paired typed ↔ String; SVG attributes take numbers on `attrs()` and on `El`; the JS module re-exports removed | `jweb/JwebSurfaceTest`, `jweb/StylePropertyPairsTest`, `jweb/js/ModuleImportsCoexistTest`, `DslPass3Test` |

The rule behind the first row — and the one to keep when adding anything new — is in the
short-import section below: if the IDE would auto-import `com.osmig…` for a type an author
writes down, the job is not finished.

## The handler capability model

Server event handlers (`onClick(e -> ...)`) are not routes: nothing maps a URL to them. Each
registration mints an unguessable id (`h_<n>_<random hex>`), the element carries it, and the
browser sends it back over the WebSocket with the page's context id (`ctx_<uuid>`, also
unguessable). Holding an id **is** the permission to run that handler — a capability — so
the rules are:

- A handler registered during a page render belongs to that page's context and is evicted
  with it (5 minutes idle, or the context's explicit clear).
- A message that names a context is resolved **in that context only**. There is no fallback
  to the global registry: a handler id from another page, or one minted outside any render,
  never runs on a foreign context. A message for a context that no longer exists is logged
  and dropped (`context_expired` — the page should reload).
- The global registry serves only messages that name no context at all: handlers minted
  outside any request (static export, bare `toHtml()`), which are the author's to expose.
- Guards and middleware do not run for WebSocket messages — the `/jweb` upgrade returns from
  `JWebController` before the stack. That is by design: the page already passed its guards
  when it rendered, and the handler cannot be reached without the ids that render produced.
  What a handler may do is decided when it is written; check the principal at render time
  and render nothing for those who may not act.

## 2026-08-29 — short-import surface (`jweb.*`)

The whole user-facing DSL moved behind a new top-level `jweb` package (spark/j2html-style):
`jweb.El` (HTML: union of legacy `El`+`Elements`), `jweb.Css` (union of `CSS`+`CSSUnits`+
`CSSColors`+`CSSGrid`+`CSSAnimations`+`CSSVariables` + `media()`/`keyframes()`/`stylesheet()`),
`jweb.Js` (`JS`+`Events`+`Runtime`+`Async`), `jweb.Actions`, `jweb.State`, `jweb.UI`,
`jweb.Layout`, `jweb.Form`, `jweb.Mongo`/`Schema`/`Doc`, and the types
`jweb.Element`, `jweb.Template`, `jweb.Style`, `jweb.CSSValue`, `jweb.JWeb`, `jweb.JWebRoutes`.

- The legacy `com.osmig.Jweb.framework.*` entry points are `@Deprecated` aliases — **existing
  source keeps compiling** (`LegacyImportsCompatTest` locks this in).
- The change is source-compatible but **not binary-compatible** (several parameter types
  widened to `jweb.Element`/`jweb.CSSValue`); dependents must recompile — automatic with
  JitPack source builds; locally, run a `clean` build after pulling.
- One semantic pick in the `El` merge: `data(name, value)` = `data-*` attribute (the
  `Elements` meaning); the `<data>` element is `tag("data", ...)`.
- Internally, `CSSColors←CSSGrid←CSSAnimations←CSSVariables←CSSUnits←CSS` and
  `Async←Runtime←Events←JS` now form inheritance chains purely for static-import
  aggregation; duplicate helpers (`lightDark`, `colorMix`, `var`, `env`) resolve to the
  subclass-most declaration.
- Long-tail follow-up (same day): every specialty styles module has a `jweb.css.X`
  shell and every `JS*` browser-API module a `jweb.js.X` shell (class names unchanged,
  so migration is a pure package-prefix swap); the legacy classes are `@Deprecated`
  aliases.
- App-infrastructure sweep (pre-v1.2.0): `jweb.JWebApplication` + `jweb.api.*`
  (annotations re-declared with the same Spring meta-annotations — both spellings
  work), `jweb.Middlewares`, `jweb.Response`, `jweb.Csrf`/`Auth`, `jweb.DevServer`,
  `jweb.I18n`, `jweb.OpenApi`, `jweb.Jobs`, `jweb.Suspense`, `jweb.Streamed`,
  `jweb.FileUpload`, `jweb.TypedRoute`, `jweb.JWebTest`, `jweb.Validators`,
  `jweb.Seo`, `jweb.Cache`, `jweb.Page`, and `jweb.el.*` for the element helper
  modules. For value types the factories now hand out `jweb` subtypes so either
  name works in declarations: `jweb.state.State` (from `useState`),
  `jweb.CsrfToken` (from `Csrf.getOrCreateToken`), `jweb.FormValidator` (from
  `create()`), `jweb.SseEmitter` (from `create()`).
- Still intentionally long: `Request`, `Principal`, `UploadedFile`, `ValidationResult`,
  `Doc`-as-declared-type, `RouteHandler`, `Middleware` — types you receive or
  implement where a rename would break override/lambda matching. Handlers infer
  `req` without any import; use `var` for the rest.

2026-09-07 — the short names are the real names:

- Rule: every type an app author can name has a `jweb.*` spelling that IS the type the
  framework returns or accepts — if IntelliJ auto-imports something starting with `com.osmig`,
  the job is not done. The "still intentionally long" list above is gone: `Request`, `Response`,
  `Principal`, `UploadedFile`, `ValidationResult`, `Validator`, `Doc`, `RouteHandler`,
  `Middleware`, `Tag`, `Attributes`, `Attr`, `Event`, `Seo`, `Streamed`, `BackgroundTask`,
  `SseEvent`, `SseBroadcaster`, `Messages`, `FetchResult`, `ErrorBoundary`, `MockRequest`,
  `MockSession`, `TestClient` moved into `jweb`; `Stylesheet`, `MediaQuery`, `ContainerQuery`, `Keyframes`,
  `FontFace`, `Supports` into `jweb.css`; the whole Three package into `jweb.three` with
  `jweb.Three` as the real facade. Nested names were hoisted: `JS.Val` → `jweb.Val`,
  `JS.Func` → `jweb.Func`, `JS.Stmt` → `jweb.js.Stmt`, `Actions.Action` → `jweb.Action`,
  `Stylesheet.Rule` / `MediaQuery.Rule` / `styles.Rule` → `jweb.css.Rule`, `CSS.Selector` →
  `jweb.css.Selector` — the nested originals are deleted (a nested alias would shadow the
  short name inside every subclass). `Template.extraHead()` returns `Optional<jweb.Element>`.
- The old FQNs stay as `@Deprecated` subtype aliases (statics and `new` keep working); records
  (`Attr`, `Streamed`, `SseEvent`, `Rule`) keep only their static factories under the old name,
  and the Three node types have no aliases at all. Full table: `dsl-simplification-3.md`.
- Guards: `jweb.JwebSurfaceTest` (compile-time assignments from real framework calls under all
  four wildcard imports) and `jweb.NoLongImportsTest` (no `com.osmig…` framework names in the
  demo app, its doc samples, the readme set, or resources — allow-list inside the test).

Fixed 2026-08-31 — Actions-DSL event handlers are CSP-safe:

- ✅ `attrs().onClick(show("panel"))`, `Button.onClick(Actions.reload())` and every other
  `Action`-typed handler now render a `data-jweb-act<type>` attribute inside a page render
  (never an inline `on<type>=`, which the `Middlewares.recommended()` nonce CSP always
  refused — silently, since attribute blocking logs nothing). The JS registers in the render
  context (`ClientActions`, content-hash ids, deduped) and reaches the browser as a
  nonce-stamped definitions script: with the page for shell renders, inside the chunk's
  script for handlers born in streamed Suspense blocks, appended to the fragment for
  `swap()` targets (the runtime executes the marked tag on swap — `innerHTML` never runs
  scripts), and on the `domUpdate` WebSocket message for component re-renders. The runtime
  delegates via the same capture-phase listeners as server events, binds `this` to the
  element, passes the event as both `event` and `e`, and honors `return false`. Definition
  scripts close over the `$_`/`esc`/`fmtDate` helpers when referenced — inline attributes
  previously threw `ReferenceError: $_ is not defined` unless a page script had defined
  them globally (they were IIFE-scoped, so effectively never).
- ✅ Raw `set("on<type>", "js")` strings (and `Ref` helper snippets, and elements built
  outside a request then rendered inside one) get the same treatment: the HTML serializer —
  the same layer that stamps CSP nonces on `<script>` tags — rewrites delegatable
  `on<type>=` attributes to `data-jweb-act<type>` + a registered definition at render time.
  Per-element opt-out: `attrs().inlineHandlers()`.

Fixed in the 2026-08-09 follow-up pass:

- ✅ `app.layout(...)` can now be called before **or after** `.pages(...)` — setting the
  default layout retroactively applies to already-registered pages without one.
- ✅ Multiple `JWebRoutes` beans configure in a **deterministic order**: `@Order`/`Ordered`
  first, then alphabetical by bean name.
- ✅ `Actions` script `build()` **auto-injects the helpers** (`$_`, `esc`, `fmtDate`) when the
  generated code uses them — `withHelpers()` is optional (and idempotent).
- ✅ `Actions.promiseAll(...)` emits a true parallel `Promise.all` (strips leading `await`
  from each action); new `Actions.promiseAllThen(...)` returns a chainable
  `Async.PromiseBuilder` for `.then(...)`.
- ✅ `showMessage(...)` is themeable: it sets `jweb-msg-<type>` classes and reads
  `--jweb-msg-<type>-bg`/`-fg` CSS variables (with the old colors as fallbacks).
- ✅ `JWebTest.test(...)` exercises **page routes** too (through the middleware stack, with
  lifecycle hooks), and returns 405 for method mismatches like production dispatch.
- ✅ JWeb router handlers can **return SSE emitters** (JWeb's `SseEmitter` or Spring's) — the
  controller streams them through Spring MVC.

---

## ✅ The reactive-state loop (fixed)

The full loop — browser event → WebSocket → Java handler → state change → DOM patch — now
works end-to-end:

- `JWebRuntime.getScriptTag()` is injected into every rendered page (after the hydration
  data). Opt out with `jweb.runtime.enabled: false`.
- Render contexts survive the request: the controller only detaches the ThreadLocal; the
  registry entry lives until the TTL reaper collects it (5 min idle, refreshed on every
  WebSocket access via `touch()`).
- `live(state, s -> element)` (in `jweb.State`) registers a reactive region: its root gets a
  `data-live` attribute and re-renders on the server when its state changes during an event,
  morphed into the DOM via `domUpdate`. (`useComponent(id, supplier)` is its deprecated
  ancestor.)
- `JWeb.setState()` is now a real protocol message (`setState`) handled by the server;
  `initState` reads the page's actual context (not a dead ThreadLocal); the client populates
  `dataset` (so `Event.data(name)` works), handles `initState`/`eventHandled`/`pong`, and
  `StateBinding.bindInput` gives two-way input binding via `data-state-input`.
- Handler registration is context-scoped with unguessable IDs (`h_<n>_<random>`), and
  handlers are evicted when their context dies — no more unbounded `EventRegistry` growth.
- Context IDs are UUID-based (unguessable).
- Enter/leave `Transition` data attributes are emitted and consumed by the runtime
  (`JWeb.leave(el)` runs leave classes then removes the node).

## ✅ Security (fixed)

- **Page routes and 404s run through the middleware stack** — auth, CSRF, headers, logging,
  and metrics now see all traffic. Page routes are GET/HEAD only (405 otherwise).
- **Header middlewares work for every result type**: they queue headers on the `Request`
  (`req.responseHeader(...)`) and `JWebController` applies them to the final response —
  `Element`/`String` results included. `etag()` also hashes Element/String bodies.
- **Error pages no longer leak stack traces**: generic message by default; set
  `jweb.dev.debug: true` to see exception details. Errors log via `Log.error`.
- **`Auth.customAuth`/`Auth.bearerAuth` now reject** (401) when no principal; the old
  attach-only behavior moved to `Auth.attachPrincipal(...)`.
- **WebSocket origins default to same-origin**; allow others with
  `jweb.websocket.allowed-origins` (comma-separated, `*` for dev).
- **`Middlewares.rateLimit` evicts expired windows** (bounded memory).
- **`Context`/`Portal`/`I18n` thread-locals are cleared per request** by `JWebController`
  (`Context.clear()`/`Portal.clear()` now `remove()` the ThreadLocal).
- **AdminApi** uses constant-time comparison (`MessageDigest.isEqual`) and per-IP login
  rate-limiting (5 failures / 15 min).
- **Hydration JSON escapes `<`** so `</script>` in state values can't break out of the
  script tag (both `HydrationData.toScriptTag` and `VNodeSerializer`).

## ✅ API bugs (fixed)

| Where | Fix |
|-------|-----|
| `FetchResult.asList(Class<T>)` | Resolves the element type at runtime (`Json.parseList(json, type)`) — returns real typed objects. |
| `MongoQuery.where("id", ...)` | Maps `id` → `_id` with ObjectId conversion, matching the update/delete builders. |
| `Suspense.timeout(n, unit)` | Blocking mode now enforces the timeout (error element on expiry). |
| `Style.prop(String)` | New 1-arg overload splits `"property:value"` strings — the six property-string CSS modules' examples now compile. |
| `PageRegistry.extractTitle` | Trailing slashes tolerated (no more `StringIndexOutOfBoundsException`). |
| `Request.body()` | Reads raw chars — newlines preserved. |
| `Request.queryInt/queryLong` (no-default) | Return null on bad input instead of throwing (consistent with `param*`). |
| `Transition` leave animations | Leave classes/durations emitted as data attributes; runtime consumes them. |
| `I18n.middleware()` | No longer clears the locale before lazy elements render (framework clears at end of request). |
| `Prefetch.setCacheTtl/setHoverDelay` | Invalidate the cached script. |
| `Cache.getOrSet` | Atomic per key (`compute`) — no thundering herd. |
| `PopoverElements.autoPopover/manualPopover` | Single-pass creation — `Attr` children are kept. |
| `Navigation.minifiedJs()` | Line-based conservative stripping that never touches lines containing quotes. |

## ✅ Formerly-phantom subsystems (wired)

- **Mongo `Schema`** — `register()` registers with `Mongo` (public API), constraints
  (`required`, `min`/`max`, `minLength`/`maxLength`, `pattern`, `enum_`, defaults, embedded)
  are validated on `Mongo.save`/`insert` (throws `Schema.ValidationException`), and declared
  indexes + unique fields are created on connect (`Mongo.ensureIndexes`).
- **`Template` lifecycle hooks** — `beforeRender`/`afterRender` run for page routes and for
  Templates returned from Router handlers; `pageTitle` overrides the layout title and the
  `<title>` tag; `metaDescription`/`extraHead` inject into the head; `scripts`/`onMount`/
  `onUnmount` inject before `</body>`; `cacheable()`/`cacheDuration()` drive Cache-Control.
- **`@Page(path=...)`** — `app.scanPages("com.example.pages")` scans a package and registers
  annotated Templates at their declared paths.
- **`OpenApi.scan(package)`** — real classpath scan for `@REST` classes.
- **Javadocs corrected** — `JWeb`/`JWebAutoConfiguration` (`@REST` et al.), `Csrf`
  (`Middlewares.csrf()`), `Jwt` path scoping, `Toast.toastScript()`, `Ref` (`inputRef.focus()`),
  `Form.bind`, `ErrorHandler` real method names.

## ✅ Routing / dispatch (fixed)

- Router method mismatch → **405 with an `Allow` header** (`Router.allowedMethods`).
- **HEAD is served by GET handlers**; `PATCH` routes supported (`app router .patch(...)`).
- **Middleware path scoping understands globs**: `"/api/**"`, `"/files/*.png"`, plus plain
  prefixes.
- `processResult`: POJOs are serialized with Jackson (real JSON); JSON-shaped strings are
  served as `application/json`.
- Metrics/logging see page-route and 404 traffic (middleware runs for them).

## ✅ DSL (fixed)

- `El` now re-exports: conditionals (`when(cond, x)`; `match`/`cond`/`otherwise` and the
  `when(cond).then(...)` chain are deprecated since 3.0), `errorBoundary`, `hr`,
  `video`/`audio`/`canvas`/`iframe`/`track`, popover members, `srcset`.
- `option("Chrome")` is text, which the browser also uses as the value; `option(value("v"), "text")`
  sets both. (3.0: the old `option(value, text)` two-String form is gone — a String is text.)
- VDOM attribute values escape `'` as well as `"`.

## ✅ Dead code & config (cleaned)

- Deleted: `state/ComponentRegistry` (duplicate of `StateContext.registerComponent`),
  `server/JWebEventController` (transport with no client), the six orphaned `Style*` mixin
  interfaces, `Route`'s duplicate `/`-branch, unused imports (`Middlewares`, `Link`).
- `state/StateBinding` and `hydration/*` are no longer dead — the runtime consumes them.
- `JWebSocketHandler.sessionContextMap` is used as a contextId fallback for events.
- Dockerfile `EXPOSE 8085` (matches the app default).
- `jweb.api.base` removed from `application.yaml` (nothing read it).
- Stale `AutoConfiguration.imports` comment fixed; configuration metadata now covers
  `jweb.admin.*`, `jweb.data.mongo.*`, `jweb.dev.debug`, `jweb.runtime.enabled`,
  `jweb.websocket.allowed-origins`, `jweb.markitdown.*`.
- Inert `.gitignore` rules for tracked docs replaced with an explanatory note.
- The test suite runs without MongoDB (`jweb.data.enabled=false` for `contextLoads`), and
  covers Route matching, middleware ordering/scoping/headers, the state loop
  (context lifetime, scoped handlers, live regions), Schema validation, and the DSL fixes
  (the full suite now stands at 107 tests, including the AI module and
  `LegacyImportsCompatTest`).

## Companion documents (cleaned 2026-08-09)

- `dsl-todos.md` — the 12 shipped modules (6 CSS, 6 HTML) checked off with their commit.
- `JWEB_EXAMPLES.md` — imports switched to the `El` facade (matching app code); broken
  inline-image heading removed.
- `prompt.md` — file-size limit aligned with STANDARD.md (100–200 lines).
- `framework/MODERN_ELEMENTS.md` — phantom `attrs().onclick(...)`/`oninput(...)` calls
  replaced with the real `attrs().set("onclick", ...)` API.

<!-- ==================== END known-issues ==================== -->


<!-- ==================== BEGIN ref-templates ==================== -->

# Reference: templates

> Source: `framework-src/docs/templates.md` · topic id: `ref-templates`

# Templates & Components

Templates are the foundation of JWeb's component system.

## Basic Template

```java
import jweb.Template;
import jweb.Element;
import static jweb.El.*;

public class Card implements Template {
    private final String title;
    private final String content;

    public Card(String title, String content) {
        this.title = title;
        this.content = content;
    }

    @Override
    public Element render() {
        return div(class_("card"),
            h3(title),
            p(content)
        );
    }
}

// Usage
div(class_("container"),
    new Card("Welcome", "Hello World!"),
    new Card("About", "Learn more...")
)
```

---

## Level 1: Simple Components

```java
// Stateless component
public class Badge implements Template {
    private final String text;

    public Badge(String text) {
        this.text = text;
    }

    @Override
    public Element render() {
        return span(class_("badge"), text);
    }
}

// Usage
div(
    new Badge("New"),
    new Badge("Featured")
)
```

---

## Level 2: Components with Children

```java
public class Card implements Template {
    private final String title;
    private final Element[] children;

    public Card(String title, Element... children) {
        this.title = title;
        this.children = children;
    }

    @Override
    public Element render() {
        return div(class_("card"),
            h3(class_("card-title"), title),
            div(class_("card-body"),
                fragment(children)
            )
        );
    }
}

// Usage
new Card("User Profile",
    p("Name: John Doe"),
    p("Email: john@example.com"),
    button("Edit Profile")
)
```

---

## Level 3: Factory Methods

```java
public record Alert(String message, String type) implements Template {

    public static Alert success(String message) {
        return new Alert(message, "success");
    }

    public static Alert error(String message) {
        return new Alert(message, "error");
    }

    public static Alert warning(String message) {
        return new Alert(message, "warning");
    }

    @Override
    public Element render() {
        return div(class_("alert alert-" + type),
            span(message)
        );
    }
}

// Usage
Alert.success("Operation completed!")
Alert.error("Something went wrong")
```

---

## Level 4: Lifecycle Hooks

Templates support lifecycle hooks for data loading and setup:

```java
record User(String name, String bio) {
    String getName() { return name; }
    String getBio() { return bio; }
}
record Post(String title) {}
record PostCard(Post post) implements Template {
    @Override
    public Element render() { return div(post.title()); }
}
interface UserService {
    User findById(int id);
    List<Post> getPostsForUser(int id);
}

public class UserPage implements Template {
    private final UserService userService;
    private User user;
    private List<Post> posts;

    public UserPage(UserService userService) {
        this.userService = userService;
    }

    // Called BEFORE render() - load data here
    @Override
    public void beforeRender(Request request) {
        int userId = request.requireParamInt("id");
        this.user = userService.findById(userId);
        this.posts = userService.getPostsForUser(userId);
    }

    // Called AFTER render() - cleanup here
    @Override
    public void afterRender(Request request) {
        // Optional: logging, cleanup, etc.
    }

    @Override
    public Element render() {
        return div(class_("user-profile"),
            h1(user.getName()),
            p(user.getBio()),
            div(class_("posts"),
                each(posts, post -> new PostCard(post))
            )
        );
    }
}
```

---

## Level 5: Page Title & Meta

```java
record Product(String name, String description) {
    String getName() { return name; }
    String getDescription() { return description; }
}
interface ProductService {
    Product findById(int id);
}

public class ProductPage implements Template {
    private final ProductService productService = id -> new Product("Widget", "A very useful widget.".repeat(10));
    private Product product;

    @Override
    public void beforeRender(Request request) {
        product = productService.findById(request.requireParamInt("id"));
    }

    // Dynamic page title
    @Override
    public String pageTitle() {
        return product.getName() + " | MyStore";
    }

    // SEO meta description
    @Override
    public String description() {
        return product.getDescription().substring(0, 150);
    }

    @Override
    public Element render() {
        return div(class_("product"),
            h1(product.getName()),
            p(product.getDescription())
        );
    }
}
```

---

## Level 6: Extra Head Elements

```java
public class AnalyticsPage implements Template {

    // Add custom elements to <head>
    @Override
    public Optional<Element> extraHead() {
        return Optional.of(fragment(
            // Open Graph tags
            meta(name("og:title"), attrs().content(getTitle())),
            meta(name("og:image"), attrs().content(getImageUrl())),
            meta(name("og:description"), attrs().content(getDescription())),

            // Preconnect for performance
            link(attrs().rel("preconnect"), href("https://fonts.googleapis.com")),

            // Page-specific CSS
            link(attrs().rel("stylesheet"), href("/css/analytics.css")),

            // Inline critical CSS
            style(criticalCss())
        ));
    }

    @Override
    public Element render() {
        // Dashboard content...
        return div(class_("analytics-dashboard"));
    }

    private String getTitle() { return "Analytics Dashboard"; }
    private String getImageUrl() { return "/og-image.png"; }
    private String getDescription() { return "Real-time analytics dashboard"; }
    private String criticalCss() { return "body{margin:0}"; }
}
```

---

## Level 7: Page Scripts

```java
public class InteractivePage implements Template {

    // Inline scripts at end of body
    @Override
    public Optional<Action> scripts() {
        return Optional.of(
            actions()
                .add(onClick("toggle-btn").then(toggle("panel")))
                .add(onSubmit("form")
                    .post("/api/submit")
                    .ok(showMessage("status").success("Saved!")))
        );
    }

    @Override
    public Element render() {
        return div(
            button(id("toggle-btn"), "Toggle Panel"),
            div(id("panel"), "Panel content..."),
            form(id("form"),
                input(type("text"), name("field"))
            ),
            div(id("status"))
        );
    }
}
```

---

## Level 8: Client-Side Lifecycle

```java
public class DashboardPage implements Template {

    // An Action to run when the DOM is ready (wrapped in DOMContentLoaded for you)
    @Override
    public Action onMount() {
        return all(call("initCharts"), call("setupWebSocket"), call("startAutoRefresh"));
    }

    // Cleanup on page unload / SPA navigation
    @Override
    public Action onUnmount() {
        return all(call("stopAutoRefresh"), call("closeWebSocket"), call("saveScrollPosition"));
    }

    @Override
    public Element render() {
        return div(class_("dashboard"),
            div(id("charts")),
            div(id("live-data"))
        );
    }
}
```

---

## Level 9: Caching

```java
public class StaticPage implements Template {

    // Enable/disable caching
    @Override
    public boolean cacheable() {
        return true;  // This page can be cached
    }

    // Cache duration in seconds
    @Override
    public int cacheDuration() {
        return 3600;  // Cache for 1 hour
    }

    @Override
    public Element render() {
        return div(class_("static-content"),
            h1("About Us"),
            p("Static content that rarely changes...")
        );
    }
}

// Dynamic page - no caching
record DashboardUser(String name) {
    String getName() { return name; }
}

public class UserDashboard implements Template {
    private final DashboardUser user;

    public UserDashboard(DashboardUser user) {
        this.user = user;
    }

    @Override
    public boolean cacheable() {
        return false;  // Never cache user-specific content
    }

    @Override
    public Element render() {
        return div(
            h1("Welcome, " + user.getName())
        );
    }
}
```

---

## Level 10: Full Page Template

```java
record Author(String name) {
    String getName() { return name; }
}
record Comment(Author author, String content, java.time.Instant createdAt) {
    Author getAuthor() { return author; }
    String getContent() { return content; }
    java.time.Instant getCreatedAt() { return createdAt; }
}
record BlogPost(String id, String title, String slug, String excerpt, String coverImage,
                 String htmlContent, Author author, java.time.Instant publishedAt) {
    String getId() { return id; }
    String getTitle() { return title; }
    String getSlug() { return slug; }
    String getExcerpt() { return excerpt; }
    String getCoverImage() { return coverImage; }
    String getHtmlContent() { return htmlContent; }
    Author getAuthor() { return author; }
    java.time.Instant getPublishedAt() { return publishedAt; }
}
interface BlogService {
    BlogPost findBySlug(String slug);
    List<Comment> getComments(String postId);
    void incrementViewCount(String postId);
}

public class BlogPostPage implements Template {
    private final BlogService blogService;
    private BlogPost post;
    private List<Comment> comments;
    private Principal currentUser;

    public BlogPostPage(BlogService blogService) {
        this.blogService = blogService;
    }

    @Override
    public void beforeRender(Request request) {
        String slug = request.requireParam("slug");
        post = blogService.findBySlug(slug);
        comments = blogService.getComments(post.getId());
        currentUser = Auth.isAuthenticated(request)
            ? Auth.getPrincipal(request)
            : null;
    }

    @Override
    public void afterRender(Request request) {
        blogService.incrementViewCount(post.getId());
    }

    @Override
    public String pageTitle() {
        return post.getTitle() + " | My Blog";
    }

    @Override
    public String description() {
        return post.getExcerpt();
    }

    @Override
    public Optional<Element> extraHead() {
        return Optional.of(fragment(
            meta(name("og:title"), attrs().content(post.getTitle())),
            meta(name("og:image"), attrs().content(post.getCoverImage())),
            meta(name("article:published_time"), attrs().content(post.getPublishedAt().toString())),
            link(attrs().rel("canonical"), href("https://myblog.com/posts/" + post.getSlug()))
        ));
    }

    @Override
    public Action onMount() {
        return all(call("initSyntaxHighlighting"), call("setupCommentForm"));
    }

    @Override
    public Optional<Action> scripts() {
        return Optional.of(
            actions()
                .add(onSubmit("comment-form")
                    .loading("Posting...")
                    .post("/api/posts/" + post.getId() + "/comments")
                    .ok(all(
                        resetForm("comment-form"),
                        call("refreshComments")
                    ))
                    .fail(responseError("comment-error")))
        );
    }

    @Override
    public boolean cacheable() {
        return currentUser == null;  // Only cache for anonymous users
    }

    @Override
    public int cacheDuration() {
        return 300;  // 5 minutes
    }

    @Override
    public Element render() {
        return article(class_("blog-post"),
            header(
                h1(post.getTitle()),
                div(class_("meta"),
                    span("By " + post.getAuthor().getName()),
                    span(formatDate(post.getPublishedAt()))
                )
            ),
            div(class_("content"),
                raw(post.getHtmlContent())
            ),
            section(class_("comments"),
                h2("Comments (" + comments.size() + ")"),
                each(comments, this::renderComment),
                when(currentUser != null, this::renderCommentForm)
            )
        );
    }

    private Element renderComment(Comment comment) {
        return div(class_("comment"),
            div(class_("comment-header"),
                strong(comment.getAuthor().getName()),
                span(class_("date"), formatDate(comment.getCreatedAt()))
            ),
            p(comment.getContent())
        );
    }

    private Element renderCommentForm() {
        return form(id("comment-form"),
            div(id("comment-error"), class_("error")),
            textarea(name("content"), placeholder("Write a comment..."), required()),
            button(type("submit"), "Post Comment")
        );
    }

    private String formatDate(java.time.Instant date) {
        return date.toString();
    }
}
```

---

## Level 11: Layouts

```java
public class MainLayout implements Template {
    private final String title;
    private final Element content;

    public MainLayout(String title, Element content) {
        this.title = title;
        this.content = content;
    }

    @Override
    public Element render() {
        return html(
            head(
                meta(attr("charset", "UTF-8")),
                meta(name("viewport"), attrs().content("width=device-width, initial-scale=1")),
                title(title),
                link(attrs().rel("stylesheet"), href("/css/main.css"))
            ),
            body(
                header(class_("site-header"),
                    nav(
                        a(href("/"), "Home"),
                        a(href("/about"), "About"),
                        a(href("/contact"), "Contact")
                    )
                ),
                main(class_("content"),
                    content
                ),
                footer(class_("site-footer"),
                    p("Copyright 2024")
                )
            )
        );
    }
}

// Usage
public class HomePage implements Template {
    @Override
    public Element render() {
        return new MainLayout("Home",
            div(class_("hero"),
                h1("Welcome to Our Site"),
                p("Build amazing things with JWeb")
            )
        );
    }
}
```

---

## Level 12: Partials

Small reusable components:

```java
// partials/Nav.java
public class Nav implements Template {
    private final String currentPath;

    public Nav(String currentPath) {
        this.currentPath = currentPath;
    }

    @Override
    public Element render() {
        return nav(class_("main-nav"),
            navLink("/", "Home"),
            navLink("/products", "Products"),
            navLink("/about", "About"),
            navLink("/contact", "Contact")
        );
    }

    private Element navLink(String path, String label) {
        boolean active = currentPath.equals(path);
        return a(
            href(path),
            classes("nav-link", when(active, "active")),
            label
        );
    }
}

// partials/Pagination.java
public class Pagination implements Template {
    private final int currentPage;
    private final int totalPages;
    private final String baseUrl;

    public Pagination(int currentPage, int totalPages, String baseUrl) {
        this.currentPage = currentPage;
        this.totalPages = totalPages;
        this.baseUrl = baseUrl;
    }

    @Override
    public Element render() {
        return div(class_("pagination"),
            when(currentPage > 1, () ->
                a(href(baseUrl + "?page=" + (currentPage - 1)), "Previous")
            ),
            span(currentPage + " of " + totalPages),
            when(currentPage < totalPages, () ->
                a(href(baseUrl + "?page=" + (currentPage + 1)), "Next")
            )
        );
    }
}
```

---

## Lifecycle Hook Summary

| Hook | When Called | Use For |
|------|-------------|---------|
| `beforeRender(Request)` | Before `render()` | Data loading, validation, setup |
| `afterRender(Request)` | After `render()` | Cleanup, logging, analytics |
| `onMount()` | DOM ready (client) | An `Action` to run — initialize, bind events |
| `onUnmount()` | Page leave (client) | An `Action` to run — cleanup timers, save state |
| `pageTitle()` | Head generation | Set `<title>` tag |
| `metaDescription()` | Head generation | SEO meta tags |
| `extraHead()` | Head generation | Custom meta, CSS, scripts |
| `scripts()` | Body end | Page-specific JavaScript |
| `cacheable()` | Response headers | Enable/disable caching |
| `cacheDuration()` | Response headers | Cache-Control max-age |

---

## Best Practices

### 1. Keep Templates Focused

```java
// Good: Single responsibility
public class ProductCard implements Template { ... }
public class ProductList implements Template { ... }
public class ProductPage implements Template { ... }

// Bad: Too much in one template
public class ProductEverything implements Template { ... }
```

### 2. Extract Reusable Parts

```java
// Extract common UI patterns
public class FormField implements Template {
    private final String label;
    private final Element input;
    private final String error;

    public FormField(String label, Element input, String error) {
        this.label = label;
        this.input = input;
        this.error = error;
    }

    @Override
    public Element render() {
        return div(class_("field"),
            label(for_(label), label),
            input,
            when(error != null, () -> span(class_("error"), error))
        );
    }
}

Map<String, String> errors = Map.of("email", "Required", "password", "Too short");

// Use in multiple forms
new FormField("Email", input(type("email"), name("email"), id("email")), errors.get("email"))
new FormField("Password", input(type("password"), name("password"), id("password")), errors.get("password"))
```

For a whole form, prefer the record-based form system over hand-rolled fields
like this one — see [validation.md](./validation.md).

### 3. Use beforeRender for Data

```java
// Good: Load data in beforeRender
@Override
public void beforeRender(Request request) {
    this.user = userService.find(request.paramInt("id"));
}

// Bad: Load data in render
@Override
public Element render() {
    User user = userService.find(...);  // Don't do this
}
```

### 4. Keep Files Under 100 Lines

Split large templates into smaller components per STANDARD.md.

<!-- ==================== END ref-templates ==================== -->


<!-- ==================== BEGIN ref-elements ==================== -->

# Reference: elements

> Source: `framework-src/docs/elements.md` · topic id: `ref-elements`

# Elements (HTML DSL)

JWeb provides a type-safe DSL for building HTML.

## Basic Usage

```java
import static jweb.El.*;

// Simple elements
div()
h1("Hello World")
p("Some text")

// With attributes
div(class_("container"), id("main"))
a(href("/about"), "About Us")
img(src("/logo.png"), alt("Logo"))
```

## Nesting Elements

```java
div(class_("card"),
    h2("Card Title"),
    p("Card content goes here"),
    button(class_("btn"), "Click Me")
)
```

## Attributes

### Common Attributes

```java
div(
    id("myDiv"),
    class_("container"),
    style("color: red"),
    title("Tooltip text")
)
```

### Form Attributes

```java
input(
    type("text"),
    name("username"),
    placeholder("Enter username"),
    required(),
    disabled(),
    readonly()
)
```

### Data Attributes

```java
div(
    data("user-id", "123"),
    data("role", "admin")
)
// Renders: <div data-user-id="123" data-role="admin"></div>
```

### ARIA Attributes

```java
button(
    aria("label", "Close dialog"),
    aria("expanded", "false")
)
```

## Event Handlers

### String-based Handlers

```java
button(
    onClick("handleClick()"),
    onChange("handleChange(event)"),
    onSubmit("return validateForm()")
)
```

### Action-based Handlers

Using the Actions DSL for type-safe event handling:

```java
import static jweb.Js.*;

// Click with action
button(
    onClick(call("doSomething")),
    "Click Me"
)

// Multiple actions
button(
    onClick(all(
        call("showLoading"),
        fetch("/api/data").ok(call("updateUI")),
        call("hideLoading")
    )),
    "Load Data"
)

// Toggle visibility
button(
    onClick(toggle("menu-panel")),
    "Toggle Menu"
)

// Form submission with validation
form(
    onSubmit(fetch("/api/submit").post().ok(call("onSuccess"))),
    input(type("text"), name("field"))
)
```

## Available Elements

### Document Structure
- `html()`, `head()`, `body()`, `title()`, `meta()`, `link()`, `script()`, `style()`

### Semantic Elements
- `header()`, `nav()`, `main()`, `section()`, `article()`, `aside()`, `footer()`

### Headings
- `h1()`, `h2()`, `h3()`, `h4()`, `h5()`, `h6()`

### Text Elements
- `p()`, `span()`, `div()`, `a()`, `strong()`, `em()`, `code()`, `pre()`, `blockquote()`

### Lists
- `ul()`, `ol()`, `li()`, `dl()`, `dt()`, `dd()`

### Tables
- `table()`, `thead()`, `tbody()`, `tfoot()`, `tr()`, `th()`, `td()`

### Forms
- `form()`, `input()`, `textarea()`, `select()`, `option()`, `button()`, `label()`, `fieldset()`, `legend()`

## Forms

A record is the form: it declares the fields, their types and their rules,
and the same record renders the form and binds the submission back to a typed
value. See [templates.md](./templates.md) and
[validation.md](./validation.md) for the full picture; in short:

```java
public record SignupForm(
    @Form.Required @Form.Email String email,
    @Form.Required @Form.Password @Form.Length(min = 8) String password) {}

form(SignupForm.class)
    .action("/signup")
    .submit("Sign up")

// app.post("/signup", req -> { Form.Bound<SignupForm> bound = Form.bind(SignupForm.class, req); ... });
```

An input outside a `<form>` is just the element with its attributes — no
builder needed:

```java
String token = "csrf-token-value";

input(type("email"), name("email"), id("email"), placeholder("you@example.com"), required())
input(type("checkbox"), name("remember"), id("remember"), value("yes")), label(for_("remember"), "Remember me")
input(type("radio"), name("plan"), id("plan-basic"), value("basic")), label(for_("plan-basic"), "Basic Plan")
input(type("hidden"), name("csrf"), value(token))
input(type("range"), name("volume"), attrs().min("0"), attrs().max("100"))
```

## Batch Class Application

Apply multiple classes at once using `classes()`, which joins parts and skips
nulls, blanks, and non-matching `when(...)` branches:

```java
boolean isActive = true;
boolean isPrimary = false;
boolean isFeatured = true;
boolean isDisabled = false;

// Multiple classes
div(classes("card", "featured", "animate"),
    h2("Featured Item")
)

// Conditional classes — a part may be a String, null, or a when(...) branch
div(classes("btn", when(isActive, "active"), when(isPrimary, "primary")),
    "Click me"
)

// The same, built with attrs().classIf(condition, name) — condition first
div(
    attrs().cls("card").classIf(isFeatured, "featured").classIf(isDisabled, "disabled"),
    "Card content"
)
```

### Media
- `img()`, `video()`, `audio()`, `source()`, `iframe()`

## Conditional Rendering

```java
boolean isAdmin = true;
boolean isLoggedIn = false;
String username = "Ada";

// Using when()
div(
    h1("Dashboard"),
    when(isAdmin, () -> button("Admin Panel"))
)

// Either/or, both branches lazy
div(
    when(isLoggedIn,
        () -> span("Welcome, " + username),
        () -> a(href("/login"), "Sign In")
    )
)

// The chained form, for a branch too long to read as one expression
when(isLoggedIn)
    .then(span("Welcome, " + username))
    .otherwise(a(href("/login"), "Sign In"))
```

## Loops

```java
record User(String name) {
    String getName() { return name; }
}
List<User> users = List.of(new User("Ada"), new User("Grace"));
List<User> items = users;

// Render a list of items
ul(
    each(users, user -> li(user.getName()))
)

// With index
List<Element> rows = new ArrayList<>();
for (int i = 0; i < items.size(); i++) {
    rows.add(li(class_(i % 2 == 0 ? "even" : "odd"), items.get(i).getName()));
}
ul(fragment(rows.toArray()))
```

## Raw HTML

For trusted HTML content:

```java
div(
    raw("<strong>Bold text</strong>")
)
```

**Warning:** Only use `raw()` with trusted content to prevent XSS.

## Text Content

A bare String child is escaped text — there is no separate function to call:

```java
// Escaped text (safe)
p("User input: <script>alert('xss')</script>")
// Renders: <p>User input: &lt;script&gt;alert('xss')&lt;/script&gt;</p>

// Simple text
p("Hello World")
```

## Components (Templates)

Create reusable components:

```java
public class Card implements Template {
    private final String title;
    private final String content;

    public Card(String title, String content) {
        this.title = title;
        this.content = content;
    }

    @Override
    public Element render() {
        return div(class_("card"),
            h3(class_("card-title"), title),
            p(class_("card-content"), content)
        );
    }
}

// Usage
div(
    new Card("Welcome", "Hello World!"),
    new Card("About", "Learn more...")
)
```

## Fragments

Group elements without a wrapper:

```java
fragment(
    h1("Title"),
    p("Paragraph 1"),
    p("Paragraph 2")
)
```

<!-- ==================== END ref-elements ==================== -->


<!-- ==================== BEGIN ref-css ==================== -->

# Reference: css

> Source: `framework-src/docs/css.md` · topic id: `ref-css`

# CSS DSL

JWeb provides a type-safe DSL for writing CSS in Java.

## Basic Usage

```java
import static jweb.Css.*;

// Single rule
String css = rule(".btn")
    .padding(px(10), px(20))
    .backgroundColor(blue)
    .color(white)
    .borderRadius(px(4))
    .build();
```

## Multiple Rules

```java
String css = styles(
    rule("*")
        .boxSizing("border-box"),
    rule("body")
        .margin(zero)
        .fontFamily("system-ui, sans-serif"),
    rule(".container")
        .maxWidth(px(1200))
        .margin(zero, auto)
        .padding(px(20))
);
```

## Units

```java
// Pixels
px(10)          // 10px

// Relative units
rem(1.5)        // 1.5rem
em(1)           // 1em
percent(100)    // 100%

// Viewport units
vh(100)         // 100vh
vw(50)          // 50vw
vmin(10)        // 10vmin
vmax(10)        // 10vmax

// Time
ms(200)         // 200ms
s(1)            // 1s

// Zero
zero            // 0
```

## Colors

### Named Colors

```java
red, blue, green, yellow, orange, purple, pink
white, black, gray, darkGray, lightGray
transparent, currentColor
```

### Custom Colors

```java
hex("#ff5733")           // Hex color
rgb(255, 87, 51)         // RGB
rgba(255, 87, 51, 0.5)   // RGBA with alpha
hsl(14, 100, 60)         // HSL
hsla(14, 100, 60, 0.5)   // HSLA with alpha
```

## Common Properties

### Layout

```java
rule(".container")
    .display("flex")
    .flexDirection("column")
    .justifyContent("center")
    .alignItems("center")
    .gap(px(20))
```

### Spacing

```java
rule(".box")
    .margin(px(10))                    // all sides
    .margin(px(10), px(20))            // vertical, horizontal
    .margin(px(10), px(20), px(30))    // top, horizontal, bottom
    .margin(px(10), px(20), px(30), px(40))  // top, right, bottom, left
    .padding(px(20))
```

### Typography

```java
rule("p")
    .fontSize(px(16))
    .fontWeight("bold")
    .lineHeight("1.5")
    .textAlign("center")
    .color(gray)
```

### Borders

```java
rule(".card")
    .border(px(1), solid, gray)
    .borderRadius(px(8))
    .boxShadow("0 2px 4px rgba(0,0,0,0.1)")
```

### Sizing

```java
rule(".box")
    .width(percent(100))
    .maxWidth(px(600))
    .height(vh(100))
    .minHeight(px(300))
```

### Positioning

```java
rule(".overlay")
    .position("fixed")
    .top(zero)
    .left(zero)
    .right(zero)
    .bottom(zero)
    .zIndex(1000)
```

### SVG Presentation

CSS properties, not attributes — they can live in a stylesheet, respond to `:hover`,
and (unlike the `fill(...)`/`stroke(...)` attributes) participate in transitions:

```java
rule("path.route")
    .fill("none")
    .stroke("currentColor")
    .strokeWidth(px(2))
    .strokeLinecap("round")
    .strokeLinejoin("round")
    .strokeDasharray("240")
    .strokeDashoffset("240")   // animate to "0" for the classic "line draw" effect
    .transformBox("fill-box")  // so transform-origin pivots on the shape, not the SVG viewport
```

### Escape Hatch

For anything with no typed method yet — vendor-prefixed, brand-new, or otherwise uncovered:

```java
rule(".card").property("interpolate-size", "allow-keywords")
```

`property(name, value)` is the general escape hatch (`var(name, value)` is only for
`--custom-properties`).

## Media Queries

```java
Stylesheet sheet = stylesheet()
    .add(Rule.of(".container", style().padding(px(10))))
    .add(media().minWidth(px(768)).rule(".container", style().padding(px(20))))
    .add(media().minWidth(px(1024)).rule(".container", style().padding(px(40))));

String css = sheet.build();
```

### Predefined Breakpoints

```java
MediaQuery.mobile()      // max-width: 767px
MediaQuery.tablet()      // min-width: 768px
MediaQuery.desktop()     // min-width: 1024px
MediaQuery.xl()          // min-width: 1200px
```

## Keyframes (Animations)

```java
String animation = keyframes("fadeIn")
    .from(style().opacity("0").transform(CSSValue.of("translateY(-10px)")))
    .to(style().opacity("1").transform(CSSValue.of("translateY(0)")))
    .build();

String css = animation + "\n" + styles(
    rule(".fade-in")
        .animation("fadeIn 0.3s ease-out")
);
```

## View Transitions

Opt a document into cross-document View Transitions (a plain multi-page navigation,
not the SPA-style `document.startViewTransition()`):

```java
import jweb.css.Stylesheet;
import static jweb.Css.*;

Stylesheet.stylesheet().add(viewTransitions())
// @view-transition{navigation:auto}
```

## Pseudo-selectors

```java
String css = styles(
    rule("a")
        .color(blue)
        .textDecoration("none"),
    rule("a:hover")
        .textDecoration("underline"),
    rule("a:focus")
        .outline("2px solid blue"),
    rule("button:disabled")
        .opacity("0.5")
        .cursor("not-allowed")
);
```

## Pseudo-elements

```java
String css = styles(
    rule(".required::before")
        .content("'*'")
        .color(red)
        .marginRight(px(4)),
    rule(".clearfix::after")
        .content("''")
        .display("table")
        .clear("both")
);
```

## Inline Styles

For element-specific styles:

```java
div(
    style().backgroundColor(blue).color(white).padding(px(10)),
    "Styled div"
)
```

## Stylesheet

Combine multiple rules into a stylesheet:

```java
Stylesheet sheet = stylesheet()
    .add(Rule.of("body", style().margin(zero)))
    .add(Rule.of(".container", style().maxWidth(px(1200))))
    .add(media().minWidth(px(768)).rule(".container", style().padding(px(40))));

String css = sheet.build();
```

## Feature Queries (@supports)

Use `@supports` for progressive enhancement:

```java
import static jweb.css.Supports.*;

// Simple property check
String css1 = supports("display", "grid")
    .rule(".container", style().display(grid))
    .build();

// Multiple conditions
String css2 = supports()
    .property("display", "grid")
    .and()
    .property("gap", "1rem")
    .rule(".grid", style().display(grid).gap(rem(1)))
    .build();

// NOT condition (fallback)
String css3 = supports()
    .not()
    .property("display", "grid")
    .rule(".fallback", style().display(flex))
    .build();

// Selector support check
String css4 = supportsSelector(":has(> img)")
    .rule(".card:has(> img)", style().padding(zero))
    .build();

// Convenience methods
supportsGrid()              // display: grid
supportsFlexbox()           // display: flex
supportsCustomProperties()  // CSS variables
supportsBackdropFilter()    // backdrop-filter
supportsHasSelector()       // :has() selector
supportsContainerQueries()  // container queries
supportsSticky()            // position: sticky
supportsClamp()             // clamp() function
```

## Nested CSS

Build CSS with native nesting syntax:

Kept qualified (`CSSNested.rule()`) because it clashes with `Css.rule()`. Child
rules open with `.nest("...")` — use `&` for the parent — and close with
`.parent()` (or `.root()` to jump all the way back to the top):

```java
import jweb.css.CSSNested;

String css = CSSNested.nest(".card")
    .prop("padding", "1rem")
    .prop("background", "#fff")

    .nest("&:hover")
        .prop("box-shadow", "0 4px 12px rgba(0,0,0,0.15)")
    .parent()

    .nest("&:focus")
        .prop("outline", "2px solid blue")
    .parent()

    .nest("& .title")                  // & .title
        .prop("font-size", "1.5rem")
    .parent()

    .nest("& > .icon")                 // & > .icon
        .prop("width", "24px")
    .parent()

    .nest("&.active")                  // &.active
        .prop("border-color", "green")
    .parent()

    .build();
```

## Using in Templates

```java
public class Layout implements Template {
    private final Element children;

    public Layout(Element children) {
        this.children = children;
    }

    @Override
    public Element render() {
        return html(
            head(
                style(getStyles())
            ),
            body(
                div(class_("container"),
                    children
                )
            )
        );
    }

    private String getStyles() {
        // Qualified: Template declares its own no-arg styles(), which shadows
        // the free Css.styles(StyleBuilder...) combiner by simple name here.
        return Css.styles(
            rule("body")
                .margin(zero)
                .fontFamily("system-ui"),
            rule(".container")
                .maxWidth(px(1200))
                .margin(zero, auto)
        );
    }
}
```

<!-- ==================== END ref-css ==================== -->


<!-- ==================== BEGIN ref-javascript ==================== -->

# Reference: javascript

> Source: `framework-src/docs/javascript.md` · topic id: `ref-javascript`

# JavaScript DSL

JWeb provides a type-safe DSL for generating JavaScript without writing raw JS.

## Basic Usage

```java
// Simple script
String js = actions()
    .add(onClick("btn").then(toggle("panel")))
    .build();
```

---

## Level 1: Simple Actions

```java
// Toggle visibility
toggle("element-id")

// Show/hide elements
show("panel")
hide("modal")
show("menu", "flex")  // With display mode

// Set text content
setText("status", "Loading...")

// Add/remove CSS classes
addClass("card", "active")
removeClass("card", "loading")
toggleClass("menu", "open")
```

---

## Level 2: Click Handlers

```java
// Simple click handler
onClick("btn-submit").then(toggle("panel"))

// Multiple actions on click
onClick("save-btn").then(all(
    hide("form"),
    show("success-message"),
    call("saveData")
))

// Click with function call
onClick("delete-btn").then(call("deleteItem", "itemId"))

void handleDoubleClick() {}

// Double-click — same two flavours (server handler / Action) everywhere onClick
// appears: attrs()/element handler attrs, the El facade, and Button.
button(onDblClick(e -> handleDoubleClick()), "Zoom")
button(onDblClick(toggle("fullscreen-panel")), "Zoom")
```

---

## Level 3: Form Handlers

```java
// Basic form submission
onSubmit("contact-form")
    .post("/api/contact")
    .ok(showMessage("status").success("Message sent!"))
    .fail(showMessage("status").error("Failed to send"))

// With loading state
onSubmit("login-form")
    .loading("Signing in...")
    .post("/api/login")
    .ok(navigateTo("/dashboard"))
    .fail(responseError("error-message")
        .on401("Invalid credentials")
        .otherwise("Server error"))

// With before/after actions
onSubmit("register-form")
    .loading("Creating account...")
    .before(all(
        hide("error-message"),
        getInputValue("email").storeTo("userEmail")
    ))
    .post("/api/register")
    .ok(all(
        showMessage("status").success("Account created!"),
        resetForm("register-form"),
        navigateTo("/welcome")
    ))
    .fail(responseError("error-message"))
```

---

## Level 4: Script Builder

```java
// Complete script with helpers and state
String js = actions()
    .withHelpers()  // Add $_, show, hide, toggle helpers

    // Define state variables
    .state(state()
        .var("isLoading", false)
        .var("currentUser", "null")
        .array("items")
        .var("selectedId", "''"))

    // Define DOM references
    .refs(refs()
        .add("container", "main-container")
        .add("modal", "modal-overlay")
        .add("form", "data-form"))

    // Add handlers
    .add(onClick("open-modal").then(show("modal-overlay", "flex")))
    .add(onClick("close-modal").then(hide("modal-overlay")))
    .add(onSubmit("data-form")
        .loading("Saving...")
        .post("/api/data")
        .ok(call("refreshData")))

    .build();
```

---

## Level 5: Functions

```java
// Define a function
define("handleClick")
    .does(
        toggle("panel"),
        call("logEvent", "'click'")
    )

// Function with parameters
define("showUser")
    .params("userId", "userName")
    .does(
        setText("user-id", "userId"),
        setText("user-name", "userName"),
        show("user-panel")
    )

// Window-scoped function (globally accessible)
windowFunc("doSomething")
    .params("data")
    .does(
        call("processData", "data"),
        show("result")
    )

// Async function
asyncFunc("loadData")
    .does(
        show("loading"),
        await(fetch("/api/data").ok(assignVar("data", "_data"))),
        hide("loading"),
        call("renderData")
    )
```

---

## Level 6: Fetch Builder

```java
// GET request
fetch("/api/users")
    .ok(assignVar("users", "_data"))
    .fail(showMessage("error").error("Failed to load"))

// POST with JSON
fetch("/api/users")
    .post()
    .body("{name: userName, email: userEmail}")
    .ok(call("onUserCreated"))

// Dynamic URL from variable
fetch("/api/items/").appendVar("itemId")
    .ok(call("showItem", "_data"))

// URL from expression
fetch("").urlFromVar("apiEndpoint + '/search?q=' + query")
    .ok(assignVar("results", "_data"))

Action processResponse() { return noop(); }

// Headers from variables
fetch("/api/protected")
    .headerFromVar("Authorization", "authToken")
    .headerFromVar("X-Request-ID", "requestId")
    .ok(processResponse())

Action processData() { return noop(); }

// Handle specific status codes
fetch("/api/data")
    .onStatus(401, navigateTo("/login"))
    .onStatus(404, showMessage("error").error("Not found"))
    .onStatus(500, showMessage("error").error("Server error"))
    .ok(processData())

// Change HTTP method
fetch("/api/resource").post()
fetch("/api/resource").put()
fetch("/api/resource").delete()
```

---

## Level 7: Async/Await

```java
Action processData() { return noop(); }

// Simple await
await(fetch("/api/data").ok(processData()))

// Async function with await
asyncFunc("loadDashboard")
    .does(
        assignVar("isLoading", "true"),
        await(fetch("/api/user").ok(assignVar("user", "_data"))),
        await(fetch("/api/stats").ok(assignVar("stats", "_data"))),
        assignVar("isLoading", "false"),
        call("renderDashboard")
    )

// Try-catch-finally — the error is bound as '_err' inside catch_
asyncTry(
    await(fetch("/api/data").ok(processData()))
)
.catch_(showMessage("status").error("Failed to load"))
.finally_(all(
    hide("loading"),
    assignVar("isLoading", "false")
))

// Promise.all for parallel requests
promiseAll(
    fetch("/api/users").ok(assignVar("users", "_data")),
    fetch("/api/posts").ok(assignVar("posts", "_data")),
    fetch("/api/comments").ok(assignVar("comments", "_data"))
)

// Sleep/delay
sleep(1000)  // Wait 1 second
sleep(500)   // Wait 500ms
```

---

## Level 8: DOM Queries

```java
// Single element query
dom("#status")
    .setText("Updated!")
    .addClass("success")

// Query with attribute selector
dom("[data-active='true']")
    .removeClass("hidden")
    .addClass("visible")

// Query all matching elements
domAll(".notification")
    .addClass("fade-out")

// Chained operations
dom("#user-panel")
    .removeClass("loading")
    .addClass("loaded")
    .attr("data-ready", "true")
    .show()
```

---

## Level 9: Event Handlers

```java
// Element event
onEvent("click").on("close-btn").then(hideModal("modal"))

// Document event
onEvent("keydown").onDocument().when("e.key==='Escape'").then(hideModal("modal"))

// Window event (the default target is already "window")
onEvent("resize").then(call("handleResize"))

// Conditional event
onEvent("click").on("logout-btn")
    .when("isLoggedIn")
    .then(call("logout"))

// Multiple event listeners
actions()
    .add(onEvent("click").on("modal-overlay").then(hideOnBackdropClick("modal-overlay")))
    .add(onEvent("popstate").when("isLoggedIn").then(call("clearSession")))
    .add(onEvent("visibilitychange").onDocument().when("document.hidden").then(call("pauseUpdates")))
    .build()
```

---

## Level 10: Modals and Dialogs

```java
// Show modal
showModal("modal-overlay")

// Hide modal
hideModal("modal-overlay")

// Hide on backdrop click
hideOnBackdropClick("modal-overlay")

// Confirm dialog
confirmDialog("confirm-modal")
    .message("Are you sure?")
    .onConfirm(call("doDelete"))

// Danger confirm (styled differently)
confirmDialog("delete-modal")
    .message("Delete this item permanently?")
    .danger()
    .onConfirm(fetch("/api/items/").appendVar("id").delete()
        .ok(call("refreshList")))

// Alert modal with content
alertModal("modal-overlay", "modal-body")
    .success("Operation completed!")
    .detail("Your changes have been saved.")
    .button("Close", "hideModal('modal-overlay')", "#6366f1")
```

---

## Level 11: State Management

```java
// Define state variables
state()
    .var("isLoading", false)
    .var("currentUser", "null")
    .var("theme", "\"light\"")
    .array("items")
    .array("selectedIds")

// Assign to variable
assignVar("isLoading", "true")
assignVar("user", "_data.user")
assignVar("items", "_data.items || []")

// Reset variables
resetVars()
    .var("isLoading", false)
    .var("currentUser", "null")
    .array("items")
    .resetForm("myForm")

// Get input value and store
getInputValue("email-input").storeTo("userEmail")
getInputValue("password-input").storeTo("password")
```

---

## Level 12: Rendering lists — on the server

The client-side template engine (`template(...)`, `field(...)`, `renderList(...)`) was
removed in 3.0. Rendering belongs on the server: build the markup with the HTML DSL,
return it as a fragment, and swap it in — no JavaScript written:

```java
record Item(int id, String name, String description) {}
List<Item> items() { return List.of(new Item(1, "Widget", "A basic widget")); }

// Route returning a fragment
app.get("/items/list", req -> ul(each(items(), item ->
    li(strong(item.name()), " — ", item.description(),
       button(onClick(call("selectItem", String.valueOf(item.id()))), "Select")))));

// Any element can pull it in; the JWeb runtime does the fetch + swap
button(swap("/items/list", "#items-container"), "Refresh")
```

---

## Level 13: Tabs

```java
// Tab switching
tabs(".tab-btn")
    .storesIn("currentTab")
    .onSwitch(call("loadTabContent"))

// In your HTML
div(class_("tabs"),
    button(class_("tab-btn"), data("tab", "overview"), "Overview"),
    button(class_("tab-btn"), data("tab", "details"), "Details"),
    button(class_("tab-btn"), data("tab", "settings"), "Settings")
)
```

---

## Level 14: External Services

```java
// Call external service (e.g., EmailJS)
externalService("emailjs")
    .call("send", "'service_id'", "'template_id'", "{email: userEmail}")
    .ok(showMessage("status").success("Email sent!"))
    .notAvailable(showMessage("status").error("Service not available"))
    .fail(showMessage("status").error("Failed to send"))
```

---

## Level 15: Combining Everything

```java
public class AdminScripts {
    public static String handlers() {
        return actions()

            .state(state()
                .var("isLoggedIn", false)
                .var("currentUser", "null"))

            .refs(refs()
                .add("container", "items-container")
                .add("modal", "modal-overlay")
                .add("form", "item-form"))

            // Load items: the server renders the list fragment, the client swaps it in
            .add(asyncFunc("loadItems")
                .does(
                    show("loading"),
                    fetch("/items/list")
                        .headerFromVar("Authorization", "authToken")
                        .ok(setInnerHtml("items-container").fromVar("_data"))
                        .fail(showMessage("error").error("Failed to load")),
                    hide("loading")
                ))

            // Delete with confirmation
            .add(windowFunc("deleteItem")
                .params("id")
                .does(
                    confirmDialog("modal-overlay")
                        .message("Delete this item?")
                        .danger()
                        .onConfirm(
                            fetch("/api/items/").appendVar("id").delete()
                                .headerFromVar("Authorization", "authToken")
                                .ok(call("loadItems"))
                                .fail(alertModal("modal-overlay", "modal-body")
                                    .error("Failed to delete"))
                        )
                ))

            // Form submission
            .add(onSubmit("item-form")
                .loading("Saving...")
                .before(hide("form-error"))
                .post("/api/items")
                .headerFrom("Authorization", "authToken")
                .ok(all(
                    hideModal("modal-overlay"),
                    resetForm("item-form"),
                    call("loadItems"),
                    showMessage("status").success("Item saved!")
                ))
                .fail(responseError("form-error")))

            // Event handlers
            .add(onEvent("click").on("modal-overlay").then(hideOnBackdropClick("modal-overlay")))
            .add(onEvent("keydown").onDocument().when("e.key==='Escape'").then(hideModal("modal-overlay")))

            .build();
    }
}
```

---

## Using in Templates

```java
class AdminScripts {
    static String handlers() { return ""; }
}

public class AdminPage implements Template {
    private String getStyles() { return ""; }

    @Override
    public Element render() {
        return html(
            head(
                title("Admin"),
                style(getStyles())
            ),
            body(
                div(id("container"),
                    // Page content goes here
                    p("Welcome")
                ),
                div(id("modal-overlay"), class_("modal"),
                    div(id("modal-body"))
                ),
                script(AdminScripts.handlers())
            )
        );
    }
}
```

---

## Viewport, Media Queries & Web Audio

```java
// Viewport & media queries (Val expressions)
matchMedia("(min-width: 768px)")   // window.matchMedia(query)
reducedMotion()                    // window.matchMedia('(prefers-reduced-motion: reduce)').matches
viewportWidth(); viewportHeight(); // window.innerWidth / window.innerHeight

// Web Audio synthesis — JSMedia is a separate module, not re-exported from jweb.Js.
import static jweb.js.JSMedia.*;

Val ctx = audioContext();
Val gain = createGain(ctx);
setValueAtTime(gain.dot("gain"), 0, 0);
linearRampToValueAtTime(gain.dot("gain"), 1, 0.05);
exponentialRampToValueAtTime(gain.dot("gain"), 0.0001, 0.4);
resume(ctx);          // ctx.resume() — call from a click/keydown handler
audioState(ctx);      // ctx.state

Val buffer = createBuffer(ctx, 1, 44100, 44100);
Val source = createBufferSource(ctx);
```

---

## Action Reference

| Action | Description |
|--------|-------------|
| `show(id)` | Show element |
| `show(id, display)` | Show with display mode |
| `hide(id)` | Hide element |
| `toggle(id)` | Toggle visibility |
| `setText(id, text)` | Set text content |
| `addClass(id, class)` | Add CSS class |
| `removeClass(id, class)` | Remove CSS class |
| `toggleClass(id, class)` | Toggle CSS class |
| `call(func, args...)` | Call function |
| `assignVar(name, value)` | Assign to variable |
| `navigateTo(url)` | Navigate to URL |
| `showModal(id)` | Show modal |
| `hideModal(id)` | Hide modal |
| `resetForm(id)` | Reset form |
| `focus(id)` | Focus element |
| `all(actions...)` | Combine actions |
| `raw(js)` | Raw JavaScript |
| `noop()` | No operation |

<!-- ==================== END ref-javascript ==================== -->


<!-- ==================== BEGIN ref-routing ==================== -->

# Reference: routing

> Source: `framework-src/docs/routing.md` · topic id: `ref-routing`

# Routing

JWeb provides a fluent routing API for defining HTTP endpoints.

## Basic Routes

```java
record HomePage() implements Template {
    public Element render() { return div(h1("Home")); }
}
record AboutPage() implements Template {
    public Element render() { return div(h1("About")); }
}

@Component
public class Routes implements JWebRoutes {
    @Override
    public void configure(JWeb app) {
        app.get("/", req -> new HomePage())
           .get("/about", req -> new AboutPage())
           .post("/login", this::handleLogin);
    }

    private Object handleLogin(Request req) {
        return Response.redirect("/dashboard");
    }
}
```

## HTTP Methods

```java
RouteHandler handler = req -> "ok";

// PATCH has no app.patch(...) yet — GET/POST/PUT/DELETE only
app.get("/users", handler)      // GET
   .post("/users", handler)     // POST
   .put("/users/:id", handler)  // PUT
   .delete("/users/:id", handler); // DELETE
```

## Path Parameters

Use `:paramName` syntax for dynamic segments:

```java
record UserPage(String userId) implements Template {
    public Element render() { return div("User " + userId); }
}
record CommentPage(String postId, String commentId) implements Template {
    public Element render() { return div("Comment " + commentId + " on post " + postId); }
}

app.get("/users/:id", req -> {
    String userId = req.param("id");
    return new UserPage(userId);
});

app.get("/posts/:postId/comments/:commentId", req -> {
    String postId = req.param("postId");
    String commentId = req.param("commentId");
    return new CommentPage(postId, commentId);
});
```

## Typed Route Parameters

Type-safe parameter extraction with automatic conversion and validation:

```java
record UserPage(int userId) implements Template {
    public Element render() { return div("User " + userId); }
}
record ProductPage(long productId) implements Template {
    public Element render() { return div("Product " + productId); }
}
record PriceFilterPage(double price) implements Template {
    public Element render() { return div("Under $" + price); }
}
record PostsPage(boolean published) implements Template {
    public Element render() { return div(published ? "Published" : "Drafts"); }
}
record OrderPage(UUID orderId) implements Template {
    public Element render() { return div("Order " + orderId); }
}

app.get("/users/:id", req -> {
    // Integer parameters
    int userId = req.paramInt("id");                             // null (NPEs on unboxing) if missing/invalid
    Optional<Integer> userIdOpt = req.paramIntOpt("id");         // empty if missing/invalid
    int page = req.paramInt("id", 1);                            // default value if missing/invalid

    return new UserPage(userId);
});

app.get("/products/:id", req -> {
    // Long parameters (for large IDs)
    long productId = req.paramLong("id");
    Optional<Long> productIdOpt = req.paramLongOpt("id");

    return new ProductPage(productId);
});

app.get("/items/:price", req -> {
    // Double parameters — there is no paramDoubleOpt(...) yet
    double price = req.paramDouble("price");
    Optional<Double> priceOpt = Optional.ofNullable(req.paramDouble("price"));

    return new PriceFilterPage(price);
});

app.get("/posts/:published", req -> {
    // Boolean parameters — "true"/"1"/"yes"/"on" parse as true, anything else as false;
    // there is no paramBoolOpt(...) yet
    boolean published = req.paramBool("published");
    Optional<Boolean> publishedOpt = Optional.ofNullable(req.paramBool("published"));

    return new PostsPage(published);
});

app.get("/orders/:uuid", req -> {
    // UUID parameters
    UUID orderId = req.paramUUID("uuid");
    Optional<UUID> orderIdOpt = req.paramUUIDOpt("uuid");

    return new OrderPage(orderId);
});
```

### Required Parameters (with Validation)

```java
record UserPage(int userId) implements Template {
    public Element render() { return div("User " + userId); }
}

app.get("/users/:id", req -> {
    // requireParamInt/Long/UUID throw IllegalArgumentException if missing or invalid.
    // double and boolean have no require* variant yet — Optional::orElseThrow gets there.
    int id = req.requireParamInt("id");
    long bigId = req.requireParamLong("id");
    UUID uuid = req.requireParamUUID("uuid");
    double amount = Optional.ofNullable(req.paramDouble("amount"))
        .orElseThrow(() -> new IllegalArgumentException("amount is required"));
    boolean active = Optional.ofNullable(req.paramBool("active"))
        .orElseThrow(() -> new IllegalArgumentException("active is required"));

    return new UserPage(id);
});
```

## Query Parameters

```java
record SearchPage(String query, int page, long limit) implements Template {
    public Element render() { return div("Results for " + query); }
}

app.get("/search", req -> {
    String query = req.query("q");                  // ?q=hello
    int page = req.queryInt("page", 1);              // ?page=2 (default: 1)
    Long limitParam = req.queryLong("limit");        // ?limit=50 — no default-value overload yet
    long limit = limitParam != null ? limitParam : 10;
    return new SearchPage(query, page, limit);
});
```

## Request Body

```java
record User(String id, String name) {}
class UserService { User save(User u) { return u; } }
UserService userService = new UserService();
com.fasterxml.jackson.databind.ObjectMapper json = new com.fasterxml.jackson.databind.ObjectMapper();

// JSON body — Request has no typed bodyAs(...) yet, so parse the raw body
// yourself (a @REST class with @Body does bind a type for you)
app.post("/api/users", req -> {
    try {
        User user = json.readValue(req.body(), User.class);
        return Response.json(userService.save(user));
    } catch (Exception e) {
        return Response.error(400, "Invalid JSON");
    }
});

// Raw body
app.post("/webhook", req -> {
    String payload = req.body();
    return Response.ok().build();
});

// Form data
app.post("/contact", req -> {
    String name = req.formParam("name");
    String email = req.formParam("email");
    return Response.redirect("/thank-you");
});
```

## Headers

```java
Map<String, String> data = Map.of("status", "ok");

app.get("/api/data", req -> {
    String auth = req.header("Authorization");
    String contentType = req.contentType();
    return Response.json(data);
});
```

## Response Types

### HTML Response (Templates)

```java
record HomePage() implements Template {
    public Element render() { return div(h1("Home")); }
}

app.get("/", req -> new HomePage());  // Returns Element
app.get("/about", req -> div(h1("About")));  // Inline Element
```

### JSON Response

```java
List<String> users = List.of("ada", "linus");

app.get("/api/users", req -> Response.json(users));

app.get("/api/status", req -> Response.json()
    .put("status", "ok")
    .put("version", "1.0.0")
    .build());
```

### Redirects

```java
app.post("/login", req -> Response.redirect("/dashboard"));
app.post("/form", req -> Response.seeOther("/success"));  // 303 after POST
```

### Error Responses

```java
record User(String id, String name) {}
class UserService { User find(String id) { return null; } }
UserService userService = new UserService();

app.get("/api/users/:id", req -> {
    User user = userService.find(req.param("id"));
    if (user == null) {
        return Response.notFound("User not found");
    }
    return Response.json(user);
});
```

## Handler Return Types

Handlers can return:

| Return Type | Result |
|-------------|--------|
| `Element` / `Template` | Rendered as HTML |
| `ResponseEntity` | Used directly |
| `String` | Plain text response |
| `Object` | Serialized as JSON |

## Route Organization

For larger applications, organize routes by feature:

```java
@Component
class UserRoutes implements JWebRoutes {
    @Override
    public void configure(JWeb app) {
        app.get("/users", req -> "users");
    }
}
@Component
class ApiRoutes implements JWebRoutes {
    @Override
    public void configure(JWeb app) {
        app.get("/api/status", req -> "ok");
    }
}

@Component
public class Routes implements JWebRoutes {
    private final UserRoutes userRoutes;
    private final ApiRoutes apiRoutes;

    public Routes(UserRoutes userRoutes, ApiRoutes apiRoutes) {
        this.userRoutes = userRoutes;
        this.apiRoutes = apiRoutes;
    }

    @Override
    public void configure(JWeb app) {
        userRoutes.configure(app);
        apiRoutes.configure(app);
    }
}
```

<!-- ==================== END ref-routing ==================== -->


<!-- ==================== BEGIN ref-state ==================== -->

# Reference: state

> Source: `framework-src/docs/state.md` · topic id: `ref-state`

# State Management

JWeb provides React-like reactive state management with `State<T>`.

## Basic Usage

```java
import jweb.state.State;
import static jweb.State.*;

public class Counter implements Template {
    private final State<Integer> count = useState(0);

    @Override
    public Element render() {
        return div(
            h1("Count: " + count.get()),
            button(onClick(e -> count.update(c -> c + 1)), "Increment")
        );
    }
}
```

## State Operations

### Get Value

```java
State<Integer> count = useState(0);
int value = count.get();  // Returns 0
```

### Set Value

```java
State<Integer> count = useState(0);
count.set(5);  // Sets to 5
```

### Update Based on Current Value

```java
State<Integer> count = useState(0);
count.update(c -> c + 1);  // Increment by 1
count.update(c -> c * 2);  // Double the value
```

## State Types

### Primitive States

```java
State<Integer> count = useState(0);
State<String> name = useState("John");
State<Boolean> isVisible = useState(false);
State<Double> price = useState(9.99);
```

### Object States

```java
record User(String name, String email) {}

State<User> currentUser = useState(new User("John", "john@example.com"));

// Update object
currentUser.set(new User("Jane", "jane@example.com"));

// Access properties
String email = currentUser.get().email();
```

### List States

```java
State<List<String>> items = useState(new ArrayList<>());

// Add item
items.update(list -> {
    list.add("New Item");
    return list;
});

// Remove item
items.update(list -> {
    list.remove("Old Item");
    return list;
});
```

### Map States

```java
State<Map<String, Object>> settings = useState(new HashMap<>());

settings.update(map -> {
    map.put("theme", "dark");
    return map;
});
```

## Subscribers

React to state changes:

```java
State<Integer> count = useState(0);

count.subscribe(newValue -> {
    System.out.println("Count changed to: " + newValue);
});

count.set(5);  // Prints: "Count changed to: 5"
```

### Unsubscribe

```java
State<Integer> count = useState(0);

Consumer<Integer> listener = value -> System.out.println(value);
count.subscribe(listener);

// Later...
count.unsubscribe(listener);
```

## State in Components

```java
public class TodoList implements Template {
    private final State<List<String>> todos = useState(new ArrayList<>());
    private final State<String> newTodo = useState("");

    @Override
    public Element render() {
        return div(
            h1("Todo List"),

            // Input for new todo
            form(onSubmit(e -> { e.preventDefault(); addTodo(); }),
                input(
                    type("text"),
                    value(newTodo.get()),
                    onInput(e -> newTodo.set(e.value()))
                ),
                button(type("submit"), "Add")
            ),

            // List of todos
            ul(
                each(todos.get(), todo ->
                    li(
                        span(todo),
                        button(onClick(e -> removeTodo(todo)), "Remove")
                    )
                )
            )
        );
    }

    public void addTodo() {
        if (!newTodo.get().isEmpty()) {
            todos.update(list -> {
                list.add(newTodo.get());
                return list;
            });
            newTodo.set("");
        }
    }

    public void removeTodo(String todo) {
        todos.update(list -> {
            list.remove(todo);
            return list;
        });
    }
}
```

## State Manager

The `StateManager` handles state lifecycle:

```java
// Create named state
State<Integer> count = StateManager.createState("counter", 0);

// Get all states for serialization
var allStates = StateManager.getContext().getStates();
```

## State Serialization

States can be serialized to JSON for hydration:

```java
record User(String name, String email) {}

State<User> user = useState(new User("John", "john@example.com"));

// Serialize to JSON
String json = user.toJson();
// {"id":"state_1","value":{"name":"John","email":"john@example.com"}}
```

## Dirty Tracking

Check if state has changed since last render:

```java
State<Integer> count = useState(0);

count.isDirty();  // false
count.set(5);
count.isDirty();  // true
```

## Best Practices

### 1. Initialize State at Field Level

```java
public class MyComponent implements Template {
    // Good: Initialize at field level
    private final State<Integer> count = useState(0);

    @Override
    public Element render() {
        return div(h1("Count: " + count.get()));
    }
}
```

### 2. Use Immutable Updates for Objects

```java
class User {
    private String name;
    private final String email;
    User(String name, String email) { this.name = name; this.email = email; }
    String getName() { return name; }
    void setName(String name) { this.name = name; }
    String getEmail() { return email; }
}

State<User> user = useState(new User("John", "john@example.com"));

// Instead of mutating...
user.get().setName("Jane");  // Bad: no change detection

// Create new object
user.set(new User("Jane", user.get().getEmail()));  // Good
```

### 3. Keep State Minimal

```java
List<String> items = List.of("a", "b");

// Store only what you need
State<String> userId = useState("123");  // Good: just the ID

// Avoid storing derived data
State<Boolean> hasItems = useState(items.size() > 0);  // Bad: derive from items
```

### 4. Group Related State

```java
record FormData(String firstName, String lastName, String email) {}

// Instead of multiple states...
State<String> firstName = useState("");
State<String> lastName = useState("");
State<String> email = useState("");

// Use an object
State<FormData> form = useState(new FormData("", "", ""));
```

## PubSub: One Publish, Every Page

`jweb.PubSub` is the server-started half of the event loop: publish a message to a topic and
every page that subscribed re-renders what the message touched. A subscription made during a
render belongs to that page's context — the handler runs in it, the live regions reading the
states it changed are re-rendered, and the patches go down the page's socket. It ends with
the context. Subscribe once per page, in `render()`, not inside a `live` body.

```java
import jweb.PubSub;
import static jweb.El.*;
import static jweb.State.*;

record Post(String author, String text) {}
String room = "42";

State<List<Post>> posts = useState(new ArrayList<>());
PubSub.subscribe("room:" + room, Post.class, post -> posts.mutate(list -> list.add(post)));
Element wall = live(posts, list -> ul(each(list, p -> li(p.author() + ": " + p.text()))));

PubSub.publish("room:" + room, new Post("ada", "hello"));   // from anywhere
```

Outside a render a subscription is a plain listener until `close()`. Messages are JSON on
the wire and arrive parsed to the class asked for; `subscribe(topic, Consumer<String>)` gets
the JSON text. Delivery runs on one thread in publish order; a handler that throws is logged.

```java
import jweb.PubSub;

record Order(long id, String status) {}

PubSub.Subscription s = PubSub.subscribe("orders", Order.class, o -> System.out.println(o.id()));
s.close();
int n = PubSub.subscribers("orders");
```

`PubSub.Memory` (default) reaches this instance's pages. `jweb.RedisPubSub` reaches every
instance through Redis with no client library: set `jweb.pubsub.redis.url`
(`redis://[user[:password]@]host[:port]`, `rediss://` for TLS; `jweb.pubsub.redis.prefix`,
default `jweb:`). A `PubSub.Backend` bean replaces both. In a test, `RenderedPage.awaitPush()`
returns what a publish sent the page.

## Shared Cache and Locks

`Cache.create`/`Cache.named` live in this instance's memory. `Cache.shared(name, Type.class)`
is what every instance sees — a Redis when `jweb.cache.redis.url` (or `jweb.redis.url`) is
set, spoken by the framework itself; memory otherwise, same semantics.

```java
import jweb.Cache;

record Price(String sku, long cents) {}
static Price lookup(String sku) { return new Price(sku, 100); }
static void sendDigest() {}

Cache.Shared<Price> prices = Cache.shared("prices", Price.class, Duration.ofMinutes(10));
Price p = prices.getOrSet("sku-9", () -> lookup("sku-9"));
prices.set("sku-9", p);  prices.get("sku-9");  prices.delete("sku-9");  prices.clear();
Cache.shared("counts", Long.class).increment("signups", 1, Duration.ofDays(1));

boolean ranHere = Cache.once("digest", Duration.ofMinutes(5), () -> sendDigest());   // one instance per five minutes
try (Cache.Lock lock = Cache.lock("import", Duration.ofMinutes(1)).orElseThrow()) {   // held until closed or expired
    // ...
}
```

Values travel as JSON (a record, a bean, a String, a Number). A store that cannot be reached
is a miss, a no-op, or "not acquired" — logged, never a 500. `RateLimit` counts in the shared
store on its own once one is configured (`.shared(false)` keeps a limiter local).

```yaml
jweb:
  redis:
    url: ${REDIS_URL:}          # cache and PubSub together; or jweb.cache.redis.url / jweb.pubsub.redis.url apart
  cache:
    redis:
      prefix: "jweb:cache:"
      connections: 4
```

<!-- ==================== END ref-state ==================== -->


<!-- ==================== BEGIN ref-http ==================== -->

# Reference: http

> Source: `framework-src/docs/http.md` · topic id: `ref-http`

# HTTP Client (Fetch)

JWeb provides a fluent HTTP client for consuming external APIs.

## Basic Usage

```java
import jweb.Fetch;
import jweb.FetchResult;

record User(String name, String email) {}

// Simple GET request
FetchResult result = Fetch.get("https://api.example.com/users").send();
List<User> users = result.asList(User.class);
```

## HTTP Methods

```java
Object user = Map.of("name", "Ada");
Object updates = Map.of("name", "New Name");

// GET
Fetch.get("https://api.example.com/users").send();

// POST
Fetch.post("https://api.example.com/users").json(user).send();

// PUT
Fetch.put("https://api.example.com/users/1").json(user).send();

// PATCH
Fetch.patch("https://api.example.com/users/1").json(updates).send();

// DELETE
Fetch.delete("https://api.example.com/users/1").send();
```

## Headers

```java
FetchResult result = Fetch.get("https://api.example.com/data")
    .header("Authorization", "Bearer token123")
    .header("Accept", "application/json")
    .header("X-Custom-Header", "value")
    .send();
```

### Common Header Shortcuts

```java
String url = "https://api.example.com/data";

// Bearer token authentication
Fetch.get(url).bearer("your-jwt-token").send();

// Basic authentication
Fetch.get(url).basicAuth("username", "password").send();

// Content-Type
Fetch.post(url).contentType("application/json").send();

// Accept header
Fetch.get(url).accept("application/json").send();
```

## Request Body

### JSON Body

```java
record User(String name, String email) {}

User user = new User("John", "john@example.com");

FetchResult result = Fetch.post("https://api.example.com/users")
    .json(user)  // Automatically serializes to JSON
    .send();
```

### Form Data

```java
FetchResult result = Fetch.post("https://api.example.com/login")
    .form(Map.of(
        "username", "john",
        "password", "secret"
    ))
    .send();
```

### Raw Body

```java
FetchResult result = Fetch.post("https://api.example.com/data")
    .body("{\"key\": \"value\"}")
    .contentType("application/json")
    .send();
```

## Timeout

```java
import java.time.Duration;

FetchResult result = Fetch.get("https://api.example.com/slow")
    .timeout(Duration.ofSeconds(60))  // 60 second timeout
    .send();
```

## Response Handling

### FetchResult Methods

```java
String url = "https://api.example.com/users";
FetchResult result = Fetch.get(url).send();

// Status code
int status = result.status();

// Status checks
result.isOk();          // 2xx
result.isError();       // 4xx or 5xx
result.isClientError(); // 4xx
result.isServerError(); // 5xx

// Raw body
String body = result.body();

// Headers
Map<String, List<String>> headers = result.headers();
Optional<String> contentType = result.header("Content-Type");
```

### Parse JSON Response

```java
record User(String name, String email) {}
FetchResult result = Fetch.get("https://api.example.com/users/1").send();

// Parse as object
User user = result.as(User.class);

// Parse as list
List<User> users = result.asList(User.class);

// Parse as map
Map<String, Object> data = result.asMap();

// Safe parsing (returns Optional)
Optional<User> maybeUser = result.tryAs(User.class);
```

## Error Handling

```java
record User(String name, String email) {}
record ErrorResponse(String message) {}

FetchResult result = Fetch.get("https://api.example.com/users/123").send();

if (result.isOk()) {
    User user = result.as(User.class);
    // Process user...
} else if (result.status() == 404) {
    // User not found
} else if (result.isClientError()) {
    // Handle 4xx error
    ErrorResponse error = result.as(ErrorResponse.class);
} else if (result.isServerError()) {
    // Handle 5xx error
}
```

### Exception Handling

```java
try {
    FetchResult result = Fetch.get("https://api.example.com/data").send();
} catch (Fetch.FetchException e) {
    // Network error, timeout, invalid URL, etc.
    System.err.println("Request failed: " + e.getMessage());
}
```

## Async Requests

```java
import java.util.concurrent.CompletableFuture;

record User(String name, String email) {}

CompletableFuture<FetchResult> future = Fetch.get("https://api.example.com/users")
    .sendAsync();

future.thenAccept(result -> {
    if (result.isOk()) {
        List<User> users = result.asList(User.class);
        // Process users...
    }
});
```

### Multiple Async Requests

```java
import java.util.concurrent.CompletableFuture;

record User(String name, String email) {}
record Post(String title) {}
String baseUrl = "https://api.example.com";

CompletableFuture<FetchResult> users = Fetch.get(baseUrl + "/users").sendAsync();
CompletableFuture<FetchResult> posts = Fetch.get(baseUrl + "/posts").sendAsync();

CompletableFuture.allOf(users, posts).thenRun(() -> {
    List<User> userList = users.join().asList(User.class);
    List<Post> postList = posts.join().asList(Post.class);
    // Combine results...
});
```

## Complete Examples

### REST API Client

```java
record User(String name, String email) {}

public class UserApiClient {
    private final String baseUrl = "https://api.example.com";
    private final String token;

    public UserApiClient(String token) {
        this.token = token;
    }

    public List<User> getUsers() {
        return Fetch.get(baseUrl + "/users")
            .bearer(token)
            .send()
            .asList(User.class);
    }

    public User getUser(String id) {
        FetchResult result = Fetch.get(baseUrl + "/users/" + id)
            .bearer(token)
            .send();

        if (result.status() == 404) {
            return null;
        }
        return result.as(User.class);
    }

    public User createUser(User user) {
        return Fetch.post(baseUrl + "/users")
            .bearer(token)
            .json(user)
            .send()
            .as(User.class);
    }

    public User updateUser(String id, User user) {
        return Fetch.put(baseUrl + "/users/" + id)
            .bearer(token)
            .json(user)
            .send()
            .as(User.class);
    }

    public void deleteUser(String id) {
        Fetch.delete(baseUrl + "/users/" + id)
            .bearer(token)
            .send();
    }
}
```

### Using in Routes

```java
@Component
public class Routes implements JWebRoutes {
    @Override
    public void configure(JWeb app) {
        app.get("/weather", req -> {
            String city = req.query("city");
            if (city == null || city.isBlank()) city = "London";

            FetchResult result = Fetch.get("https://api.weather.com/v1/current")
                .header("API-Key", System.getenv("WEATHER_API_KEY"))
                .send();

            if (result.isOk()) {
                return new WeatherPage(result.asMap());
            }
            return new ErrorPage("Could not fetch weather data");
        });
    }
}

record WeatherPage(Map<String, Object> data) implements Template {
    public Element render() { return div(data.toString()); }
}

record ErrorPage(String message) implements Template {
    public Element render() { return div(message); }
}
```

<!-- ==================== END ref-http ==================== -->


<!-- ==================== BEGIN ref-middleware ==================== -->

# Reference: middleware

> Source: `framework-src/docs/middleware.md` · topic id: `ref-middleware`

# Middleware

Middleware provides a way to process requests before they reach your route handlers.

## Basic Usage

```java
record HomePage() implements Template {
    @Override
    public Element render() { return div("Home"); }
}

@Component
public class Routes implements JWebRoutes {
    @Override
    public void configure(JWeb app) {
        // Add middleware
        app.use(Middlewares.logging())
           .use(Middlewares.cors())
           .use(Middlewares.csrf());

        // Routes
        app.get("/", req -> new HomePage());
    }
}
```

## Built-in Middleware

### Logging

Logs all incoming requests:

```java
app.use(Middlewares.logging());
// Output: GET /users 200 45ms
```

### CORS

Enables Cross-Origin Resource Sharing:

```java
app.use(Middlewares.cors());

// With custom origins (comma-separated)
app.use(Middlewares.cors("https://example.com,https://api.example.com"));
```

### CSRF Protection

Protects against Cross-Site Request Forgery:

```java
app.use(Middlewares.csrf());
```

### Rate Limiting

Limits requests per time window:

```java
// 100 requests per minute
app.use(Middlewares.rateLimit(100, 60000));
```

### Security Headers

Adds common security headers:

```java
app.use(Middlewares.securityHeaders());
// Adds: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, etc.
```

### Timing

Adds response timing header:

```java
app.use(Middlewares.timing());
// Adds: X-Response-Time: 45ms
```

### Request ID

Every request already has an id — the framework's filter gives it one before any route runs,
echoes it in `X-Request-ID` and keeps it in the logging context; `req.id()` reads it. This
middleware repeats that for a stack run without the filter (a test, an embedded router):

```java
app.use(Middlewares.requestId());
// Adds: X-Request-ID: <the request's id>
```

`Middlewares.logging()` writes one line per request — `GET /orders/9 200 12ms` — with the id
in front like every other line of the request.

### Cache Control

Sets cache headers:

```java
// Cache for 1 hour
app.use("/api", Middlewares.cacheControl(3600));

// Private cache with revalidation
app.use("/user", Middlewares.cacheControl(3600, true, true));

// No cache
app.use("/admin", Middlewares.noCache());

// Static assets (1 year, immutable)
app.use("/assets", Middlewares.staticCache());
```

### ETag

Enables ETag caching:

```java
app.use(Middlewares.etag());
```

### Compression Headers

Indicates compression support:

```java
app.use(Middlewares.compressionHeaders());
```

## Path-Specific Middleware

Apply middleware only to specific paths:

```java
// Only /admin routes
app.use("/admin", Auth.requireRole("admin"));

// Only /api routes
app.use("/api", Middlewares.cors());
```

## Method-Specific Middleware

Apply middleware for specific HTTP methods:

```java
// Only POST, PUT, DELETE
app.useForMethods(List.of("POST", "PUT", "DELETE"), Middlewares.csrf());
```

## Conditional Middleware

```java
app.use(Middleware.when(
    req -> req.path().startsWith("/api"),
    Middlewares.cors()
));
```

## Custom Middleware

Create your own middleware:

```java
Middleware timing = (req, chain) -> {
    long start = System.currentTimeMillis();
    Object result = chain.next();
    long duration = System.currentTimeMillis() - start;
    System.out.println("Request took " + duration + "ms");
    return result;
};

app.use(timing);
```

### Before/After Helpers

```java
// Run code before handler
app.use(Middleware.before(req -> {
    System.out.println("Before: " + req.path());
}));

// Run code after handler
app.use(Middleware.after((req, response) -> {
    System.out.println("After: " + req.path());
}));
```

### Error Handling Middleware

```java
Middleware errorHandler = (req, chain) -> {
    try {
        return chain.next();
    } catch (Exception e) {
        return Response.serverError(e.getMessage());
    }
};

app.use(errorHandler);
```

## Middleware Order

Middleware executes in the order it's added:

```java
app.use(Middlewares.logging())      // 1. Logs request
   .use(Middlewares.cors())         // 2. Adds CORS headers
   .use(Auth.requireAuth())         // 3. Checks authentication
   .use(Middlewares.csrf());        // 4. Validates CSRF token
```

## Composing Middleware

Combine multiple middleware:

```java
Middleware security = Middlewares.securityHeaders()
    .then(Middlewares.csrf())
    .then(Auth.requireAuth());

app.use("/secure", security);
```

## Middleware Chain

The `chain.next()` call passes control to the next middleware or handler:

```java
Middleware example = (req, chain) -> {
    // Before handler
    System.out.println("Before");

    Object result = chain.next();  // Call next middleware/handler

    // After handler
    System.out.println("After");

    return result;
};
```

## Short-Circuit Responses

Return early without calling `chain.next()`:

```java
Middleware authCheck = (req, chain) -> {
    if (!Auth.isAuthenticated(req)) {
        return Response.unauthorized();  // Short-circuit
    }
    return chain.next();  // Continue to handler
};
```

<!-- ==================== END ref-middleware ==================== -->


<!-- ==================== BEGIN ref-validation ==================== -->

# Reference: validation

> Source: `framework-src/docs/validation.md` · topic id: `ref-validation`

# Validation

JWeb provides a fluent validation API for validating form data and request inputs.

For a whole HTML form, prefer the record-based form system
(`jweb.Form`, `@Form.Required`, `Form.bind()` — see
[templates.md](./templates.md)), which validates and binds in one step. Use
`FormValidator` directly for one-off fields, JSON bodies, or rules that don't
map to a single record.

## Basic Usage

```java
import jweb.FormValidator;
import jweb.ValidationResult;
import jweb.Validator;
import jweb.Validators;

Object registerHandler(Request req) {
    ValidationResult result = FormValidator.create()
        .field("email", req.formParam("email"))
            .required()
            .email()
        .field("password", req.formParam("password"))
            .required()
            .minLength(8)
        .validate();

    if (result.hasErrors()) {
        // Handle validation errors
        return Response.json(400, result.getAllErrors());
    }

    return Response.redirect("/welcome");
}
```

## Built-in Validators

### Presence

```java
String value = "example";

FormValidator.create().field("x", value)
    .required();       // Field must have a value

Validators.notNull();  // Field must not be null — a Validator<String>, pair with .custom(...)

FormValidator.create().field("x", value)
    .optional();        // Skip validation if empty
```

### String Length

```java
String value = "example";

FormValidator.create().field("x", value)
    .minLength(8)              // Minimum 8 characters
    .maxLength(100)            // Maximum 100 characters
    .lengthBetween(8, 100);    // Between 8 and 100 characters
```

### Format

```java
String value = "example";

FormValidator.create().field("x", value)
    .email()         // Valid email format
    .url()           // Valid URL format
    .phone();        // Valid phone number
```

### Character Types

```java
String value = "example";

FormValidator.create().field("x", value)
    .numeric()       // Only digits
    .alpha()         // Only letters
    .alphanumeric(); // Letters and digits only
```

### Custom Patterns

```java
String value = "example";

FormValidator.create().field("x", value)
    .pattern("^[A-Z]{2}\\d{4}$", "Must be 2 letters followed by 4 digits");
```

### Numeric Ranges

```java
// FormValidator's field chain validates Strings and has no numeric-range
// methods; min/max/range/positive/negative are Validator<Number> factories
// on Validators instead — compose them with .and(...) (see "Composing
// Validators" below) or hand one to .custom(...) once a field's value is
// parsed to a number.
Validators.min(0);          // Minimum value
Validators.max(100);        // Maximum value
Validators.range(1, 10);    // Between 1 and 10
Validators.positive();      // Greater than 0
Validators.negative();      // Less than 0
```

## Validation Result

```java
// var here — the lambda-configured field(...) overload currently returns
// FormValidator's internal supertype, not the jweb.FormValidator create() gave you
var validator = FormValidator.create()
    .field("email", req.formParam("email"), f -> f.required().email());
ValidationResult result = validator.validate();

// Check if valid
if (result.isValid()) {
    // Proceed
}

// Check for errors
if (result.hasErrors()) {
    // Get errors for specific field
    List<String> emailErrors = result.getErrors("email");

    // Get all errors
    Map<String, List<String>> allErrors = result.getAllErrors();

    // Get first error for a field
    String firstError = result.getFirstError("email");
}
```

## Form Validation Example

record RegisterPage(Map<String, List<String>> errors) implements Template {
    public Element render() { return div("Register"); }
}

app.post("/register", req -> {
    ValidationResult result = FormValidator.create()
        .field("username", req.formParam("username"))
            .required()
            .minLength(3)
            .maxLength(20)
            .alphanumeric()
        .field("email", req.formParam("email"))
            .required()
            .email()
        .field("password", req.formParam("password"))
            .required()
            .minLength(8)
        .field("confirmPassword", req.formParam("confirmPassword"))
            .required()
            .check(val -> val.equals(req.formParam("password")),
                "Passwords must match")
        .field("age", req.formParam("age"))
            .optional()
            .numeric()
            .check(val -> Integer.parseInt(val) >= 18, "Must be 18 or older")
        .validate();

    if (result.hasErrors()) {
        return new RegisterPage(result.getAllErrors());
    }

    // Create user...
    return Response.redirect("/welcome");
});
```

## Custom Validators

### Inline Custom Validation

```java
boolean isUnique(String value) { return true; }

// custom(...) takes a Validator<String> directly; a predicate + message goes
// through check(...) instead
FormValidator.create().field("username", req.formParam("username"))
    .check(value -> isUnique(value), "Username already taken");
```

### Reusable Validators

```java
Validator<String> usernameValidator = Validator.of(
    value -> value.matches("^[a-z][a-z0-9_]+$"),
    "Username must start with a letter and contain only lowercase letters, numbers, and underscores"
);

FormValidator.create()
    .field("username", req.formParam("username"))
        .custom(usernameValidator)
```

### Composing Validators

```java
Validator<String> strongPassword = Validators.required()
    .and(Validators.minLength(8))
    .and(Validator.of(
        s -> s.matches(".*[A-Z].*"),
        "Must contain uppercase letter"
    ))
    .and(Validator.of(
        s -> s.matches(".*[0-9].*"),
        "Must contain a number"
    ));
```

## Failing fast with an exception

For an API route, throwing carries the messages to the error middleware, which
turns them into a 422 with the field errors in the body:

```java
import jweb.ValidationException;

app.post("/api/users", req -> {
    ValidationResult result = FormValidator.create()
        .field("email", req.formParam("email"), f -> f.required().email())
        .validate();

    if (result.hasErrors()) throw new ValidationException(result);

    // Process valid data...
    return Response.json(Map.of("status", "created"));
});
```

For an HTML form, prefer `Form.bind(...)` and re-render with
`form(X.class).errors(bound)` — the user sees the messages next to the fields
instead of an error page.

## JSON API Validation

```java
record User(String email, String name) {}
class UserService { User save(User u) { return u; } }
UserService userService = new UserService();
com.fasterxml.jackson.databind.ObjectMapper json = new com.fasterxml.jackson.databind.ObjectMapper();

// Request has no typed bodyAs(...) yet, so parse the raw body yourself
app.post("/api/users", req -> {
    User user;
    try {
        user = json.readValue(req.body(), User.class);
    } catch (Exception e) {
        return Response.error(400, "Invalid JSON");
    }

    ValidationResult result = FormValidator.create()
        .field("email", user.email())
            .required()
            .email()
        .field("name", user.name())
            .required()
            .minLength(2)
        .validate();

    if (result.hasErrors()) {
        return Response.json(400, Map.of(
            "error", "Validation failed",
            "fields", result.getAllErrors()
        ));
    }

    return Response.json(userService.save(user));
});
```

## Displaying Errors in Templates

A record form renders its own messages — pass the binding result and every field
gets its message, `aria-invalid`, a summary, and the value the user typed:

```java
public record Register(@Form.Required @Form.Email String email) {}

Form.Bound<Register> bound = Form.bind(Register.class, req);
form(Register.class).action("/register").errors(bound).submit("Register")
```

Hand-built markup reads the `ValidationResult` itself:

```java
public class RegisterPage implements Template {
    private final ValidationResult errors;

    public RegisterPage(ValidationResult errors) {
        this.errors = errors != null ? errors : ValidationResult.valid();
    }

    @Override
    public Element render() {
        return form(method("post"), action("/register"),
            div(cls("field"),
                label(for_("email"), "Email"),
                input(type("email"), name("email"), id("email"),
                    attrs().aria("invalid", errors.hasErrors("email") ? "true" : "false")),
                when(errors.hasErrors("email"),
                    span(cls("error"), errors.getFirstError("email")))
            ),
            button(type("submit"), "Register")
        );
    }
}
```

<!-- ==================== END ref-validation ==================== -->


<!-- ==================== BEGIN ref-security ==================== -->

# Reference: security

> Source: `framework-src/docs/security.md` · topic id: `ref-security`

# Security

JWeb provides built-in security features for authentication, authorization, and CSRF protection.

## CSRF Protection

### Enabling CSRF

```java
app.use(Middlewares.csrf());
```

### Form Protection

Add the CSRF token to forms:

```java
form(method("post"), action("/submit"),
    Csrf.tokenField(req),  // Hidden CSRF token field
    input(type("text"), name("message")),
    button("Submit")
)
```

### Validating CSRF Token

```java
app.post("/submit", req -> {
    Csrf.validate(req);  // Throws CsrfException if invalid
    // Process form...
    return Response.redirect("/success");
});
```

### AJAX Requests

For JavaScript requests, include the token in headers:

```java
// Get token for JS
String token = Csrf.getToken(req).getValue();
```

```javascript
fetch('/api/data', {
    method: 'POST',
    headers: {
        'X-CSRF-Token': token
    },
    body: JSON.stringify(data)
});
```

## Authentication

### Session-Based Auth

```java
record User(String id, String email, String[] roles) {}
class UserService {
    User authenticate(String email, String password) { return new User("1", email, new String[]{"user"}); }
}
UserService userService = new UserService();

// Login
app.post("/login", req -> {
    String email = req.formParam("email");
    String password = req.formParam("password");

    User user = userService.authenticate(email, password);
    if (user == null) {
        return Response.unauthorized("Invalid credentials");
    }

    // Create principal and login
    Principal principal = Principal.of(user.id(), user.email(), user.roles());
    Auth.login(req, principal);

    return Response.redirect("/dashboard");
});
```

### Logout

```java
app.post("/logout", req -> {
    Auth.logout(req);
    return Response.redirect("/");
});
```

### Check Authentication

```java
record ProfilePage(Principal user) implements Template {
    public Element render() { return div("Profile: " + user.getName()); }
}

app.get("/profile", req -> {
    if (!Auth.isAuthenticated(req)) {
        return Response.redirect("/login");
    }

    Principal user = Auth.getPrincipal(req);
    return new ProfilePage(user);
});
```

## Authorization

### Require Authentication Middleware

```java
// Protect all /dashboard routes
app.use("/dashboard", Auth.requireAuth());
```

### Role-Based Access

```java
// Require specific role
app.use("/admin", Auth.requireRole("admin"));

// Require any of several roles
app.use("/manage", Auth.requireAnyRole("admin", "manager"));
```

### Check Roles in Handlers

```java
record AdminPage() implements Template {
    public Element render() { return div("Admin"); }
}

app.get("/admin", req -> {
    Principal user = Auth.getPrincipal(req);

    if (!user.hasRole("admin")) {
        return Response.forbidden("Admin access required");
    }

    return new AdminPage();
});
```

## Principal

The `Principal` represents an authenticated user:

```java
// Create principal
Principal user = Principal.of("user123", "john@example.com", "user", "admin");

// Access properties
String id = user.getId();
String email = user.getName();  // the second Principal.of argument (often an email)
Set<String> roles = user.getRoles();

// Check roles
boolean isAdmin = user.hasRole("admin");
boolean canManage = user.hasAnyRole("admin", "manager");
```

## Bearer Token Authentication

For API authentication:

```java
record User(String id, String email, String[] roles) {}
class TokenService {
    User validateToken(String token) { return new User("1", "user@example.com", new String[]{"user"}); }
}
TokenService tokenService = new TokenService();

app.use("/api", Auth.bearerAuth(token -> {
    // Validate token and return Principal
    User user = tokenService.validateToken(token);
    if (user == null) {
        return null;  // Returns 401
    }
    return Principal.of(user.id(), user.email(), user.roles());
}));
```

### Making Authenticated API Requests

```java
// Using Fetch utility
FetchResult result = Fetch.get("https://api.example.com/data")
    .bearer("your-jwt-token")
    .send();
```

## Security Headers Middleware

Add common security headers:

```java
app.use(Middlewares.securityHeaders());
```

This adds:
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: DENY`
- `X-XSS-Protection: 1; mode=block`
- `Referrer-Policy: strict-origin-when-cross-origin`

## Password Hashing

For password storage, use Spring Security's `PasswordEncoder` or BCrypt:

```java
String rawPassword = "correct horse battery staple";

// Hash password
String hashed = Password.hash(rawPassword);

// Verify password
boolean matches = Password.verify(rawPassword, hashed);
```

## The Flows

Signed tokens, remember me, log out everywhere and two-factor — stateless or on a shared
store, so they work on a cluster. Set `jweb.auth.secret` (32+ random characters).

### Password reset and email verification

```java
import jweb.Auth;

String passwordHash = "$2a$12$...";
String email = "ada@example.com";

// bound to the password hash: once the password changes the link is dead
String reset = Auth.token("reset", "42", Duration.ofHours(1), passwordHash);
Optional<String> id = Auth.subject(reset);                                // "42", unverified: load the user by it
Optional<String> userId = Auth.verify("reset", reset, passwordHash);     // Optional.of("42"), verified against that user's hash

// bound to the email: verifies that address and no other
String verify = Auth.token("verify", "42", Duration.ofDays(1), email);
Optional<String> verified = Auth.verify("verify", verify, email);
Optional<Instant> expires = Auth.expiry(verify);
```

### Remember me

```java
import jweb.Auth;
import jweb.Principal;

Principal principal = Principal.of("42", "ada@example.com", "user");
Function<String, Principal> userById = id -> principal;

// on login
Auth.login(req, principal);
Response.Redirect home = Response.redirect("/").cookie(Auth.rememberCookie(principal, Duration.ofDays(30)));

// once, for returning visitors
app.use(Auth.remember(userById));

// on logout
Response.redirect("/").cookie(Auth.forgetCookie());
```

### Log out everywhere

```java
import jweb.Auth;

Auth.logoutEverywhere("42");     // every session and remember-me cookie of the user, on every instance
Auth.logoutEverywhere(req);      // the request's user, and this session with it
long version = Auth.version("42");
```

Versions are in memory, or in `jweb_auth_versions` when `jweb.Sql` is connected (reads cached
ten seconds); a `Auth.Versions` bean replaces both.

### Two-factor (TOTP)

```java
import jweb.Auth;
import jweb.Password;

String secret = Auth.Totp.secret();                               // store on the user
String uri = Auth.Totp.uri("MyApp", "ada@example.com", secret);   // render as a QR code
boolean ok = Auth.Totp.verify(secret, req.formParam("code"));     // one period of skew either side
List<String> recovery = Auth.recoveryCodes(8);                    // show once; store Password.hash(code) each
String stored = Password.hash(recovery.get(0));
```

## Complete Auth Example

```java
record User(String id, String email, String[] roles) {}

class UserService {
    User authenticate(String email, String password) { return new User("1", email, new String[]{"user"}); }
}

class TokenService {
    Principal validate(String token) { return Principal.of("1", "user@example.com", "user"); }
}

record HomePage() implements Template {
    public Element render() { return div("Home"); }
}

record LoginPage(String error) implements Template {
    LoginPage() { this(null); }
    public Element render() { return div("Login", error == null ? "" : error); }
}

record DashboardPage(Principal user) implements Template {
    public Element render() { return div("Dashboard: " + user.getName()); }
}

record AdminPage() implements Template {
    public Element render() { return div("Admin"); }
}

@Component
public class Routes implements JWebRoutes {

    private final UserService userService = new UserService();
    private final TokenService tokenService = new TokenService();

    @Override
    public void configure(JWeb app) {
        // Public routes
        app.get("/", req -> new HomePage())
           .get("/login", req -> new LoginPage())
           .post("/login", this::handleLogin);

        // Protected routes
        app.use("/dashboard", Auth.requireAuth());
        app.get("/dashboard", req -> new DashboardPage(Auth.getPrincipal(req)));

        // Admin routes
        app.use("/admin", Auth.requireRole("admin"));
        app.get("/admin", req -> new AdminPage());

        // API with bearer auth
        app.use("/api", Auth.bearerAuth(this::validateToken));
        app.get("/api/me", req -> Response.json(Auth.getPrincipal(req)));
    }

    private Object handleLogin(Request req) {
        String email = req.formParam("email");
        String password = req.formParam("password");

        User user = userService.authenticate(email, password);
        if (user == null) {
            return new LoginPage("Invalid credentials");
        }

        Auth.login(req, Principal.of(user.id(), email, user.roles()));
        return Response.redirect("/dashboard");
    }

    private Principal validateToken(String token) {
        return tokenService.validate(token);
    }
}
```

<!-- ==================== END ref-security ==================== -->


<!-- ==================== BEGIN ref-file-upload ==================== -->

# Reference: file upload

> Source: `framework-src/docs/file-upload.md` · topic id: `ref-file-upload`

# File Upload

JWeb provides utilities for handling file uploads.

## Basic Usage

```java
import jweb.FileUpload;
import jweb.UploadedFile;
import java.io.IOException;
import java.nio.file.Path;

app.post("/upload", req -> {
    UploadedFile file = FileUpload.getFile(req, "document");

    if (file.isEmpty()) {
        return Response.badRequest("No file provided");
    }

    try {
        Path saved = file.saveTo(Path.of("uploads"));
        return Response.json(Map.of("path", saved.toString()));
    } catch (IOException e) {
        return Response.serverError("Could not save file");
    }
});
```

## HTML Form

```java
form(
    action("/upload"),
    method("post"),
    enctype("multipart/form-data"),

    input(type("file"), name("document")),
    button(type("submit"), "Upload")
)
```

## Getting Files

### Single File

```java
UploadedFile file = FileUpload.getFile(req, "document");

// Or with Optional
Optional<UploadedFile> maybeFile = FileUpload.getFileOptional(req, "document");
```

### Multiple Files

```java
import java.nio.file.Path;

List<UploadedFile> files = FileUpload.getFiles(req, "images");

for (UploadedFile file : files) {
    file.saveTo(Path.of("uploads/images"));
}
```

### All Files

```java
List<UploadedFile> allFiles = FileUpload.getAllFiles(req);
```

## File Properties

```java
import java.io.IOException;
import java.io.InputStream;

app.post("/upload", req -> {
    UploadedFile file = FileUpload.getFile(req, "document");

    // Check if empty
    if (file.isEmpty()) {
        return Response.badRequest("No file");
    }

    // File info
    String name = file.getFilename();           // "report.pdf"
    String contentType = file.getContentType(); // "application/pdf"
    long size = file.getSize();                 // bytes

    try {
        // Get content
        byte[] bytes = file.getBytes();
        InputStream stream = file.getInputStream();
        return Response.ok().body(bytes);
    } catch (IOException e) {
        return Response.serverError();
    }
});
```

## File Type Checks

```java
UploadedFile file = FileUpload.getFile(req, "image");

// Check content type
if (file.isImage()) {
    // PNG, JPG, GIF, etc.
}

// Check extension
if (file.hasExtension("pdf", "doc", "docx")) {
    // Document file
}
```

## Saving Files

```java
import java.nio.file.Path;

UploadedFile file = FileUpload.getFile(req, "document");

// Save to directory with a generated, collision-proof filename
Path saved = file.saveTo(Path.of("uploads"));
// Result: uploads/<uuid>.pdf

// Save preserving the original filename (sanitized)
Path savedOriginal = file.saveWithOriginalName(Path.of("uploads"));
// Result: uploads/report.pdf

// Save with a specific filename
Path savedNamed = file.saveTo(Path.of("uploads"), "custom-name.pdf");
```

`saveTo` writes to this machine's disk. For a file the app keeps — and serves, and must find
again from another instance — hand the upload to `jweb.Storage` instead
(`Storage.upload("avatars", file)`, then `Storage.url(key)`); see the storage reference.

## Validation

```java
Object handleUpload(Request req) {
    UploadedFile file = FileUpload.getFile(req, "image");

    FileUpload.FileValidation validation = FileUpload.validate(file)
        .required()
        .maxSizeMB(10)
        .imagesOnly();

    if (!validation.isValid()) {
        return Response.badRequest(validation.getFirstError());
    }
    return Response.ok().build();
}
```

### Validation Rules

```java
UploadedFile file = FileUpload.getFile(req, "document");

FileUpload.validate(file)
    .required()                               // File must be present
    .maxSize(5 * 1024 * 1024)                 // Max 5MB (in bytes)
    .maxSizeMB(10)                            // Max 10MB
    .imagesOnly()                             // PNG, JPG, GIF only
    .allowedExtensions("pdf", "doc", "docx"); // Specific extensions
```

### Getting Errors

```java
UploadedFile file = FileUpload.getFile(req, "document");

FileUpload.FileValidation validation = FileUpload.validate(file)
    .required()
    .maxSizeMB(5);

if (!validation.isValid()) {
    List<String> errors = validation.getErrors();
    String firstError = validation.getFirstError();
}
```

## Check Multipart Request

```java
Object handleUpload(Request req) {
    if (FileUpload.isMultipart(req)) {
        // Handle file upload
        return Response.ok().build();
    } else {
        return Response.badRequest("Expected multipart request");
    }
}
```

## Configuration

In `application.properties`:

```properties
# Maximum file size
spring.servlet.multipart.max-file-size=10MB

# Maximum request size (for multiple files)
spring.servlet.multipart.max-request-size=50MB

# Temp directory for uploads
spring.servlet.multipart.location=/tmp/uploads
```

## Complete Example

```java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

@Component
public class UploadRoutes implements JWebRoutes {
    private static final Path UPLOAD_DIR = Path.of("uploads");

    @Override
    public void configure(JWeb app) {
        // Upload form
        app.get("/upload", req -> uploadForm(null, null));

        // Handle upload
        app.post("/upload", req -> {
            if (!FileUpload.isMultipart(req)) {
                return uploadForm("Please select a file", null);
            }

            UploadedFile file = FileUpload.getFile(req, "file");

            // Validate
            var validation = FileUpload.validate(file)
                .required()
                .maxSizeMB(10)
                .allowedExtensions("jpg", "png", "gif", "pdf");

            if (!validation.isValid()) {
                return uploadForm(validation.getFirstError(), null);
            }

            try {
                // Save with unique name
                String filename = UUID.randomUUID() + "_" + file.getFilename();
                Path saved = file.saveTo(UPLOAD_DIR, filename);

                return uploadForm(null, "File uploaded: " + saved.getFileName());
            } catch (IOException e) {
                return uploadForm("Could not save file", null);
            }
        });

        // Serve uploaded files
        app.get("/uploads/:filename", req -> {
            String filename = req.param("filename");
            Path file = UPLOAD_DIR.resolve(filename);

            if (!Files.exists(file)) {
                return Response.notFound();
            }

            try {
                byte[] content = Files.readAllBytes(file);
                String contentType = Files.probeContentType(file);

                return Response.ok()
                    .contentType(contentType)
                    .body(content);
            } catch (IOException e) {
                return Response.serverError();
            }
        });
    }

    private Element uploadForm(String error, String success) {
        return div(class_("container"),
            h1("File Upload"),

            when(error != null, () -> div(class_("error"), error)),
            when(success != null, () -> div(class_("success"), success)),

            form(
                action("/upload"),
                method("post"),
                enctype("multipart/form-data"),

                div(class_("field"),
                    label(for_("file"), "Select file:"),
                    input(type("file"), name("file"), id("file"))
                ),

                button(type("submit"), "Upload")
            )
        );
    }
}
```

## Multiple File Upload

```java
import java.io.IOException;
import java.nio.file.Path;

app.post("/gallery/upload", req -> {
    List<UploadedFile> images = FileUpload.getFiles(req, "images");

    if (images.isEmpty()) {
        return Response.badRequest("No images provided");
    }

    List<String> uploaded = new ArrayList<>();

    for (UploadedFile image : images) {
        // Validate each file
        var validation = FileUpload.validate(image)
            .maxSizeMB(5)
            .imagesOnly();

        if (validation.isValid()) {
            try {
                String filename = UUID.randomUUID() + "_" + image.getFilename();
                image.saveTo(Path.of("uploads/gallery"), filename);
                uploaded.add(filename);
            } catch (IOException e) {
                // skip this file
            }
        }
    }

    return Response.json(Map.of(
        "uploaded", uploaded.size(),
        "files", uploaded
    ));
});
```

```html
<form action="/gallery/upload" method="post" enctype="multipart/form-data">
    <input type="file" name="images" multiple accept="image/*">
    <button type="submit">Upload Images</button>
</form>
```

<!-- ==================== END ref-file-upload ==================== -->


<!-- ==================== BEGIN ref-storage ==================== -->

# Reference: file storage — local, S3-compatible, served and signed URLs

> Source: `framework-src/docs/storage.md` · topic id: `ref-storage`

# File Storage

`jweb.Storage` keeps the files an app's users give it — a key, bytes, a content type — in a
directory in development and in a bucket in production, with the same calls. The app serves
the local store's files itself at `/files/<key>`.

## Storing and reading

```java
import jweb.Storage;
import jweb.FileUpload;
import jweb.UploadedFile;

byte[] csv = "a,b\n".getBytes();

// An upload under a generated key: avatars/<32 hex>.png
UploadedFile file = FileUpload.getFile(req, "avatar");
Storage.Stored avatar = Storage.upload("avatars", file);
String key = avatar.key();

// Exact keys
Storage.put("users/42/avatar.png", file);
Storage.put("exports/7.csv", csv, "text/csv");
Storage.put("docs/report.pdf", java.nio.file.Path.of("report.pdf"));

Optional<Storage.Stored> info = Storage.get("exports/7.csv");    // size, contentType, modified, etag
boolean there = Storage.exists("exports/7.csv");
Optional<byte[]> bytes = Storage.bytes("exports/7.csv");
try (Storage.Blob blob = Storage.open("docs/report.pdf").orElseThrow()) {   // streams; close it
    long size = blob.info().size();
    java.io.InputStream in = blob.in();
}
List<Storage.Stored> exports = Storage.list("exports/");
boolean removed = Storage.delete("exports/7.csv");
```

Keys are `/`-separated paths without a leading slash. `..`, `\`, `//` and control characters
are refused with `IllegalArgumentException`. A store failure is `Storage.StorageException`.

## URLs

```java
import jweb.Storage;
import static jweb.El.*;

String key = "avatars/3f2a9c.png";

Element picture = img(src(Storage.url(key)), alt("Avatar"));                  // /files/avatars/3f2a9c.png, or the bucket's URL
Element link = a(href(Storage.url("invoices/7.pdf", Duration.ofMinutes(10))), "Download");   // signed, expires
Optional<String> direct = Storage.uploadUrl("in/new.png", "image/png", Duration.ofMinutes(5));   // a presigned PUT (S3)
```

| Store | `url(key)` | `url(key, ttl)` |
|---|---|---|
| Local, `access: public` | `/files/<key>` | `/files/<key>?exp=…&sig=…` under `jweb.auth.secret` |
| Local, `access: signed` | signed for `jweb.storage.url-ttl` (an hour) | signed for `ttl` |
| S3 | presigned for an hour, or `<public-url>/<key>` when set | presigned for `ttl` (a week at most) |

## Serving

The framework mounts `GET /files/*` at startup for the app-served stores
(`jweb.storage.serve: false` to mount `Storage.serve()` yourself). The route sends the content
type, an ETag (304 on `If-None-Match`), `Last-Modified`, `Cache-Control`
(`jweb.storage.cache-control`) and byte ranges. It is a route: guard it like one. With
`access: signed` it serves nothing without a valid signature.

```java
import jweb.Storage;
import jweb.Auth;

app.guard("/files/private/*", Auth.requireAuth());

// Your own URL for a file: the same response, as a page response or a download
app.get("/avatar/:id", r -> Storage.response(r, "avatars/" + r.param("id") + ".png"));
app.get("/invoice/:id", r -> Storage.download(r, "invoices/" + r.param("id") + ".pdf", "invoice.pdf"));
```

## The stores

```java
import jweb.Storage;

Storage.store(new Storage.Memory());                                   // tests
Storage.store(new Storage.Local("storage"));                           // a directory; the type follows the key's extension
Storage.store(new Storage.S3("", "eu-west-1", "my-bucket", "AKIA…", "secret"));                   // Amazon
Storage.store(new Storage.S3("https://<account>.r2.cloudflarestorage.com", "auto", "my-bucket", "key", "secret"));   // R2
Storage.store(new Storage.S3("http://localhost:9000", "us-east-1", "my-bucket", "minio", "minio123"));   // MinIO
Storage.store(new Storage.S3("", "us-east-1", "my-bucket", "key", "secret").publicUrl("https://cdn.example.com"));
```

`Storage.S3` signs its own requests (Signature Version 4) over the JDK's HTTP client; a custom
endpoint is addressed path-style (`endpoint/bucket/key`), Amazon's virtual-hosted
(`bucket.s3.region.amazonaws.com/key`); `jweb.storage.s3.path-style` overrides. A `PUT`
sends the file in one request, read fully first. Any `Storage.Store` bean replaces the store;
the interface is `put`, `head`, `open`, `delete`, `list`, and optionally `url` and `uploadUrl`.

## Configuration

```yaml
jweb:
  storage:
    dir: storage
    path: /files
    access: public            # or signed
    url-ttl: 1h
    cache-control: "private, max-age=3600"
    serve: true
    s3:
      bucket:                 # set → S3
      region: us-east-1
      endpoint:
      access-key:
      secret-key:
      path-style:             # default: true with an endpoint, false on Amazon
      public-url:
```

## In a test

```java
import jweb.Storage;

Storage.store(new Storage.Memory());
Storage.put("docs/a.txt", "hello".getBytes(), "text/plain");
Object response = Storage.serve().handle(MockRequest.get("/files/docs/a.txt").build());
Storage.reset();   // back to an empty memory store and the defaults
```

<!-- ==================== END ref-storage ==================== -->


<!-- ==================== BEGIN ref-sql ==================== -->

# Reference: SQL and migrations

> Source: `framework-src/docs/sql.md` · topic id: `ref-sql`

# SQL and Migrations

`jweb.Sql` runs SQL against the app's `DataSource` and maps rows to records; `jweb.Migrations`
applies versioned `.sql` files once each. Both are written against `javax.sql.DataSource`
only — an app adds `spring-boot-starter-jdbc` and a driver and sets `spring.datasource.url`,
and the `DataSource` bean is bound at startup.

## Queries

```java
import jweb.Sql;

record User(Long id, String name, String email, LocalDateTime createdAt) {}

List<User> all = Sql.query("select * from users order by name").list(User.class);
Optional<User> byId = Sql.query("select * from users where id = ?", 7).first(User.class);
User one = Sql.query("select * from users where email = ?", "ada@example.com").one(User.class);
long count = Sql.query("select count(*) from users").scalar(Long.class);
boolean any = Sql.query("select 1 from users where email = ?", "ada@example.com").exists();
List<String> names = Sql.query("select name from users").list(String.class);
List<String> labels = Sql.query("select id, name from users").list(row -> row.getLong("id") + " " + row.getString("name"));
```

A row maps to a record by column name, ignoring case and underscores: `created_at` fills
`createdAt`. A column the row lacks leaves the component `null` (zero / `false` for a
primitive). Numbers, `String`, `boolean`, `BigDecimal`, `LocalDate`, `LocalDateTime`,
`LocalTime`, `Instant`, `UUID`, enums by name and `byte[]` convert.

## Parameters

```java
Sql.query("select * from users where age > ? and role = ?", 18, "admin");                 // positional
Sql.query("select * from users where age > :age and role = :role", Map.of("age", 18, "role", "admin"));   // named
Sql.query("select :id::text", Map.of("id", 7));                                           // :: is a cast, not a parameter
```

## Updates and inserts

```java
int changed = Sql.update("update users set name = ? where id = ?", "Ada", 7);
long id = Sql.insert("insert into users (name) values (?)", "Ada").key(Long.class);
Sql.execute("create index users_name on users (name)");
int[] counts = Sql.batch("insert into users (name) values (?)", List.of(new Object[]{"a"}, new Object[]{"b"}));
```

## Transactions

```java
long from = 1, to = 2;

Sql.transaction(() -> {
    Sql.update("update accounts set balance = balance - ? where id = ?", 50, from);
    Sql.update("update accounts set balance = balance + ? where id = ?", 50, to);
});
String receipt = Sql.transaction(() -> {
    Sql.update("insert into audit (note) values (?)", "moved 50");
    return "ok";
});
```

Commit at the end, rollback on any exception; every `Sql` call on the thread inside the
block joins it, and a nested `transaction` joins the outer one.

## A table through a record

```java
record Todo(Long id, String title, boolean done) {}

Sql.Table<Todo> todos = Sql.table("todos", Todo.class);

Todo t = todos.insert(new Todo(null, "Write docs", false));   // id generated and filled in
todos.update(new Todo(t.id(), t.title(), true));
todos.save(t);                                                // insert when id is null, else update
Optional<Todo> found = todos.find(t.id());
Todo must = todos.get(t.id());
List<Todo> open = todos.where("done = ? order by id", false);
long left = todos.count("done = ?", false);
todos.delete(t.id());
todos.deleteWhere("done = ?", true);
```

Columns are the components in `snake_case`; the component named `id` is the key.

## Another database

```java
import javax.sql.DataSource;

DataSource reportsDataSource = Sql.dataSource();
Sql.Database reports = Sql.of(reportsDataSource);
long rows = reports.query("select count(*) from events").scalar(Long.class);
```

Every static `Sql` method is an instance method on `Sql.Database`. `Sql.connect(dataSource)`
binds the default by hand (a test, a `main`).

## Migrations

Files named `V<version>__<name>.sql` under `src/main/resources/db/migration` run at startup
in version order, once each, recorded in `jweb_migrations` with a checksum:

```
db/migration/V1__create_users.sql
db/migration/V2__add_users_email_index.sql
```

```java
import jweb.Migrations;
import javax.sql.DataSource;

DataSource dataSource = Sql.dataSource();

Migrations.Report report = Migrations.migrate(dataSource);
List<Migrations.Migration> pending = Migrations.pending(dataSource);
List<Migrations.Applied> history = Migrations.applied(dataSource);
int repaired = Migrations.repair(dataSource);   // accept edited checksums, when the edit was deliberate
```

- Each file runs in one transaction where DDL is transactional (PostgreSQL, H2, SQLite).
- A file edited after it was applied fails the next start, naming the version.
- Several instances starting at once take turns on a lock row.
- Statements split on `;` outside quotes, comments and `$$` bodies; `-- jweb:whole` on the
  first line sends the file as one statement.
- `jweb.sql.migrate: false` turns the startup run off; `jweb.sql.migrations` moves the
  location. With Flyway or Liquibase on the classpath, JWeb's migrator steps aside.

## Errors

`Sql.SqlException` (unchecked) carries the message and the SQL that failed;
`Migrations.MigrationException` names the file and the statement. Calling `Sql` with no
database bound says what to add.

## Paging and search

```java
import jweb.Sql;

record Todo(Long id, String title, boolean done) {}
Sql.Table<Todo> todos = Sql.table("todos", Todo.class);

List<Todo> first = todos.page(1, 50);                      // by id
List<Todo> hits = todos.search("docs", 1, 50);             // the text columns, case insensitive
List<String> fields = todos.fields();                      // id, title, done
Object id = todos.idOf(first.get(0));
boolean addressable = todos.hasId();
```

## The admin

```java
import jweb.Admin;
import jweb.Auth;
import jweb.Sql;

record Todo(Long id, @Form.Required String title, boolean done) {}
Sql.Table<Todo> todos = Sql.table("todos", Todo.class);

Admin.mount(app, "/admin", todos).title("Back office").guard(Auth.requireRole("admin"));
```

A page per table under the path — list, search, pages of fifty, add, edit, delete — with
the record as the form. Read-only for a record without an `id` component.

## Scaffolding

`jweb generate resource Todo title:string! done:boolean` writes the record, the migration
(`V<next>__create_todos.sql`), a list page, a form page and their routes. `jweb new` gives a
project H2 under `./data`, `spring.datasource.url` and the `db/migration` folder.

<!-- ==================== END ref-sql ==================== -->


<!-- ==================== BEGIN ref-sse ==================== -->

# Reference: server-sent events

> Source: `framework-src/docs/sse.md` · topic id: `ref-sse`

# Server-Sent Events (SSE)

JWeb provides SSE support for real-time server-to-client streaming.

## Basic Usage

```java
app.get("/events", req -> {
    SseEmitter emitter = SseEmitter.create();

    // Send initial event
    emitter.send("Connected!");

    // Return the emitter as response
    return emitter.toResponse();
});
```

## Creating Emitters

```java
// Default timeout (30 seconds)
SseEmitter emitter = SseEmitter.create();

// Custom timeout (0 = no timeout)
SseEmitter emitterWithTimeout = SseEmitter.create(60000);  // 60 seconds
SseEmitter emitterNoTimeout = SseEmitter.create(0);        // No timeout
```

## Sending Events

### Simple Text

```java
SseEmitter emitter = SseEmitter.create();
emitter.send("Hello, World!");
```

### Structured Events

```java
SseEmitter emitter = SseEmitter.create();

SseEvent simpleEvent = SseEvent.of("New message received");
emitter.send(simpleEvent);

// With event name
SseEvent namedEvent = SseEvent.create()
    .name("message")
    .data("Hello!")
    .build();
emitter.send(namedEvent);

// With ID and retry
SseEvent fullEvent = SseEvent.create()
    .id("123")
    .name("update")
    .data("{\"count\": 42}")
    .retry(3000)  // Retry after 3 seconds on disconnect
    .build();
emitter.send(fullEvent);
```

### JSON Data

```java
SseEmitter emitter = SseEmitter.create();

Map<String, Object> data = Map.of(
    "type", "notification",
    "message", "You have a new message"
);
emitter.send(SseEvent.json(data));
```

### Keep-Alive Comments

```java
SseEmitter emitter = SseEmitter.create();

// Send a comment (doesn't trigger event on client)
emitter.sendComment("keep-alive");
```

## Completing Emitters

```java
SseEmitter emitter = SseEmitter.create();

// Normal completion
emitter.complete();

// Complete with error
emitter.completeWithError(new RuntimeException("Connection lost"));

// Check if completed
if (emitter.isCompleted()) {
    // Emitter is done
}
```

## Event Callbacks

```java
SseEmitter emitter = SseEmitter.create();
emitter.onComplete(e -> {
    System.out.println("Client disconnected");
    // Cleanup resources
});
emitter.onError(error -> {
    System.err.println("Error: " + error.getMessage());
});
```

## Broadcasting

Use `SseBroadcaster` to send events to multiple clients:

```java
// Create a broadcaster
SseBroadcaster broadcaster = new SseBroadcaster();

// Add clients
app.get("/events", req -> {
    SseEmitter emitter = SseEmitter.create();
    broadcaster.subscribe(emitter);
    return emitter.toResponse();
});

// Broadcast to all clients
broadcaster.broadcast("New update available!");
broadcaster.broadcast(SseEvent.create()
    .name("notification")
    .data("{\"message\": \"Hello everyone!\"}")
    .build()
);
```

## Real-Time Notifications Example

```java
@Component
public class NotificationRoutes implements JWebRoutes {
    private final SseBroadcaster notifications = new SseBroadcaster();

    @Override
    public void configure(JWeb app) {
        // SSE endpoint for receiving notifications
        app.get("/notifications/stream", req -> {
            SseEmitter emitter = SseEmitter.create(0);  // No timeout

            String userId = Auth.getPrincipal(req).getId();
            emitter.onComplete(e -> {
                System.out.println("User " + userId + " disconnected");
            });

            notifications.subscribe(emitter);
            emitter.send("Connected to notification stream");

            return emitter.toResponse();
        });

        // Endpoint to trigger a notification
        app.post("/notifications/send", req -> {
            String message = req.formParam("message");
            notifications.broadcast(SseEvent.create()
                .name("notification")
                .data(Map.of(
                    "message", message,
                    "timestamp", System.currentTimeMillis()
                ))
                .build()
            );
            return Response.json(Map.of("sent", true));
        });
    }
}
```

## Client-Side JavaScript

```javascript
// Connect to SSE endpoint
const eventSource = new EventSource('/events');

// Listen for messages
eventSource.onmessage = (event) => {
    console.log('Received:', event.data);
};

// Listen for specific event types
eventSource.addEventListener('notification', (event) => {
    const data = JSON.parse(event.data);
    showNotification(data.message);
});

// Handle errors
eventSource.onerror = (error) => {
    console.error('SSE error:', error);
};

// Close connection
eventSource.close();
```

## Live Updates Example

```java
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

@Component
public class LiveStatsRoutes implements JWebRoutes {
    private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

    @Override
    public void configure(JWeb app) {
        app.get("/stats/live", req -> {
            SseEmitter emitter = SseEmitter.create(0);

            // Send stats every 5 seconds
            ScheduledFuture<?> task = scheduler.scheduleAtFixedRate(() -> {
                if (!emitter.isCompleted()) {
                    Map<String, Object> stats = Map.of(
                        "activeUsers", getActiveUserCount(),
                        "requestsPerSecond", getRequestRate(),
                        "timestamp", System.currentTimeMillis()
                    );
                    emitter.send(SseEvent.create()
                        .name("stats")
                        .data(stats)
                        .build()
                    );
                }
            }, 0, 5, TimeUnit.SECONDS);

            // Cleanup on disconnect
            emitter.onComplete(e -> task.cancel(true));

            return emitter.toResponse();
        });
    }

    private int getActiveUserCount() { return 0; }
    private double getRequestRate() { return 0; }
}
```

## Progress Updates Example

```java
app.post("/upload", req -> {
    SseEmitter emitter = SseEmitter.create(300000);  // 5 min timeout

    // Process upload in background
    Jobs.run(() -> {
        try {
            for (int i = 0; i <= 100; i += 10) {
                emitter.send(SseEvent.create()
                    .name("progress")
                    .data(String.valueOf(i))
                    .build()
                );
                Thread.sleep(500);  // Simulate processing
            }
            emitter.send(SseEvent.create()
                .name("complete")
                .data("{\"status\": \"success\"}")
                .build()
            );
        } catch (Exception e) {
            emitter.completeWithError(e);
        } finally {
            emitter.complete();
        }
    });

    return emitter.toResponse();
});
```

<!-- ==================== END ref-sse ==================== -->


<!-- ==================== BEGIN ref-jobs ==================== -->

# Reference: background jobs, durable jobs

> Source: `framework-src/docs/jobs.md` · topic id: `ref-jobs`

# Background Jobs

JWeb provides utilities for running background tasks and scheduled jobs.

## Basic Usage

```java
import jweb.Jobs;
import java.util.concurrent.CompletableFuture;

record User(String name) {}
record Report(String content) {}

void sendEmail(User user) {}
Report generateReport() { return new Report("Report contents"); }
void saveReport(Report report) {}

User user = new User("Ada");

// Fire and forget
Jobs.run(() -> sendEmail(user));

// With result
CompletableFuture<Report> future = Jobs.submit(() -> generateReport());
future.thenAccept(report -> saveReport(report));
```

## One-Time Execution

### Fire and Forget

```java
record User(String name) {}
void sendNotification(User user) {}
void updateAnalytics() {}

User user = new User("Ada");

Jobs.run(() -> {
    // This runs in background
    sendNotification(user);
    updateAnalytics();
});
```

### With CompletableFuture

```java
import java.util.concurrent.CompletableFuture;

record User(String name) {
    String getName() { return name; }
}
interface UserService {
    User fetchUser(String id);
}
UserService userService = id -> new User("Ada");
String userId = "42";

CompletableFuture<User> future = Jobs.submit(() -> {
    return userService.fetchUser(userId);
});

// Chain operations
future.thenAccept(user -> {
    System.out.println("Fetched: " + user.getName());
});

// Handle errors
future.exceptionally(error -> {
    System.err.println("Failed: " + error.getMessage());
    return null;
});
```

## Delayed Execution

Run a task after a delay:

```java
import java.time.Duration;
import java.util.concurrent.ScheduledFuture;

record User(String name) {}
void sendReminderEmail(User user) {}
void cleanupTempFiles() {}

User user = new User("Ada");

// Run after 30 seconds
Jobs.delay(Duration.ofSeconds(30), () -> {
    sendReminderEmail(user);
});

// Run after 5 minutes
ScheduledFuture<?> future = Jobs.delay(Duration.ofMinutes(5), () -> {
    cleanupTempFiles();
});

// Cancel if needed
future.cancel(false);
```

## Scheduled Jobs

Run recurring tasks:

```java
void cleanupExpiredSessions() {}
void syncDataWithExternalService() {}

// Run every 5 minutes
Jobs.schedule("cleanup", Duration.ofMinutes(5), () -> {
    cleanupExpiredSessions();
});

// Run every hour with initial delay
Jobs.schedule("sync", Duration.ofMinutes(10), Duration.ofHours(1), () -> {
    syncDataWithExternalService();
});

// Check if job is scheduled
if (Jobs.isScheduled("cleanup")) {
    System.out.println("Cleanup job is running");
}

// Cancel a scheduled job
Jobs.cancel("cleanup");
```

## Tracked Tasks

Track long-running tasks:

```java
import jweb.BackgroundTask;

record Report(String content) {}
Report generateLargeReport() { return new Report("Report contents"); }

BackgroundTask<Report> task = Jobs.track("Generate Report", () -> {
    return generateLargeReport();
});

// Get task ID for status checks
String taskId = task.getId();

// Check status later
Jobs.getTask(taskId).ifPresent(t -> {
    System.out.println("Status: " + t.getStatus());
    if (t.isDone()) {
        Report report = (Report) t.getResult();
    }
});
```

## Progress Tracking

Track progress for long operations:

```java
record ImportRow(String data) {}
List<ImportRow> loadRecords() { return List.of(new ImportRow("a"), new ImportRow("b")); }
void processRecord(ImportRow record) {}

BackgroundTask<Integer> task = Jobs.trackWithProgress("Import Data", progress -> {
    List<ImportRow> records = loadRecords();
    int processed = 0;

    for (int i = 0; i < records.size(); i++) {
        processRecord(records.get(i));
        processed++;

        // Update progress (0-100)
        int percent = (i + 1) * 100 / records.size();
        progress.update(percent, "Processing record " + (i + 1));
    }

    return processed;
});

// Check progress
Jobs.getTask(task.getId()).ifPresent(t -> {
    System.out.println("Progress: " + t.getProgress() + "%");
    System.out.println("Message: " + t.getProgressMessage());
});
```

## Task Status API

Expose task status via API:

```java
record Report(String content) {}
interface ReportService { Report generate(String type); }
ReportService reportService = type -> new Report("Report for " + type);

app.post("/api/reports/generate", req -> {
    BackgroundTask<Report> task = Jobs.track("Generate Report", () -> {
        return reportService.generate(req.query("type"));
    });

    return Response.json(Map.of(
        "taskId", task.getId(),
        "status", "started"
    ));
});

app.get("/api/tasks/:id", req -> {
    String taskId = req.param("id");

    return Jobs.getTask(taskId)
        .map(task -> Response.json(Map.of(
            "id", task.getId(),
            "name", task.getName(),
            "status", task.getStatus(),
            "progress", task.getProgress(),
            "message", task.getProgressMessage(),
            "done", task.isDone()
        )))
        .orElse(Response.notFound("Task not found"));
});
```

## Cleanup

Clean up completed tasks:

```java
// Remove all completed tasks from tracking
Jobs.cleanupCompletedTasks();

// Schedule automatic cleanup
Jobs.schedule("task-cleanup", Duration.ofHours(1), () -> {
    Jobs.cleanupCompletedTasks();
});
```

## Graceful Shutdown

Shutdown job executors when application stops:

```java
import jakarta.annotation.PreDestroy;

@PreDestroy
public void onShutdown() {
    Jobs.shutdown();
}
```

## Complete Example

```java
interface ReportService {
    Object fetchData(String type);
    Object process(Object data);
    String savePdf(Object report);
}

@Component
public class ReportRoutes implements JWebRoutes {
    private final ReportService reportService;

    public ReportRoutes(ReportService reportService) {
        this.reportService = reportService;
    }

    @Override
    public void configure(JWeb app) {
        // Start report generation
        app.post("/reports/generate", req -> {
            String type = req.formParam("type");

            BackgroundTask<String> task = Jobs.trackWithProgress(
                "Generate " + type + " Report",
                progress -> {
                    progress.update(0, "Initializing...");

                    // Fetch data
                    progress.update(20, "Fetching data...");
                    var data = reportService.fetchData(type);

                    // Process data
                    progress.update(50, "Processing...");
                    var report = reportService.process(data);

                    // Generate PDF
                    progress.update(80, "Generating PDF...");
                    String path = reportService.savePdf(report);

                    progress.update(100, "Complete!");
                    return path;
                }
            );

            return Response.json(Map.of("taskId", task.getId()));
        });

        // Check task status
        app.get("/reports/status/:taskId", req -> {
            String taskId = req.param("taskId");

            return Jobs.getTask(taskId)
                .map(task -> Response.json(Map.of(
                    "status", task.getStatus(),
                    "progress", task.getProgress(),
                    "message", task.getProgressMessage(),
                    "done", task.isDone(),
                    "result", task.isDone() ? task.getResult() : null
                )))
                .orElse(Response.notFound());
        });
    }
}
```

## Client-Side Polling

```javascript
async function generateReport(type) {
    // Start generation
    const response = await fetch('/reports/generate', {
        method: 'POST',
        body: new URLSearchParams({ type })
    });
    const { taskId } = await response.json();

    // Poll for status
    const pollStatus = async () => {
        const statusRes = await fetch(`/reports/status/${taskId}`);
        const status = await statusRes.json();

        updateProgressBar(status.progress);
        updateStatusMessage(status.message);

        if (status.done) {
            downloadReport(status.result);
        } else {
            setTimeout(pollStatus, 1000);
        }
    };

    pollStatus();
}
```

## Durable Jobs

Everything above runs in this JVM and is gone on restart. A durable job is named, carries a
payload, is stored, retried with backoff, dead-lettered after the last attempt, and on the
SQL store survives a restart and is shared by every instance on the database.

```java
import jweb.Jobs;

record Welcome(long userId, String email) {}

// at startup: what the job does
Jobs.handle("welcome", Welcome.class, w -> System.out.println("welcome " + w.email()));
Jobs.handle("reindex", () -> System.out.println("reindexing"));

// anywhere: enqueue
Jobs.Job job = Jobs.enqueue("welcome", new Welcome(42, "ada@example.com"));
Jobs.enqueue("welcome", new Welcome(43, "bob@example.com"), Jobs.options().after(Duration.ofMinutes(5)).retries(5));
Jobs.enqueue("reindex", null, Jobs.options().key("reindex"));   // one pending at a time

// looking after them
Optional<Jobs.Job> found = Jobs.find(job.id());
List<Jobs.Job> pending = Jobs.pending();
List<Jobs.Job> failed = Jobs.failed();
boolean requeued = Jobs.retry(job.id());
Map<Jobs.Status, Long> counts = Jobs.stats();

// in a test: run what is due, now, on this thread
int ran = Jobs.runPending();
```

- `Jobs.Memory` (default) or `Jobs.SqlStore` (`jweb_jobs`, installed automatically when
  `jweb.Sql` is connected); any `Jobs.Store` bean replaces them.
- The worker polls every `jweb.jobs.poll` (1s), runs `jweb.jobs.concurrency` (8) jobs at
  once, retries at 2, 4, 8… seconds up to an hour, releases a job stuck `RUNNING` for
  `jweb.jobs.timeout` (10m), purges done jobs after `jweb.jobs.keep-done` (24h).
  `jweb.jobs.enabled: false` turns the worker off on an instance.
- A job's error, attempts and next run are on `Jobs.Job`; `Jobs.retry(id)` puts a failed
  one back.

<!-- ==================== END ref-jobs ==================== -->


<!-- ==================== BEGIN ref-mail ==================== -->

# Reference: mail

> Source: `framework-src/docs/mail.md` · topic id: `ref-mail`

# Mail

`jweb.Mail` builds a message like an element and sends it now or later. No mail library:
the framework speaks SMTP itself when `jweb.mail.smtp.host` is set; otherwise the outbox
keeps the last two hundred messages in memory and logs each one.

## Sending

```java
import jweb.Mail;
import static jweb.El.*;

byte[] csv = "a,b\n".getBytes();

Mail.to("ada@example.com")
    .subject("Welcome")
    .html(div(h1("Welcome, Ada"), p("Your account is ready.")))   // a text part is derived from the HTML
    .send();

Mail.to("ada@example.com", "bob@example.com").cc("ops@example.com").bcc("audit@example.com")
    .from("Reports <reports@example.com>").replyTo("help@example.com")
    .subject("Your export").text("Attached.")
    .attach("export.csv", csv, "text/csv")
    .header("X-Campaign", "exports")
    .send();
```

## Later: a durable job

```java
import jweb.Mail;
import jweb.Jobs;

Jobs.Job queued = Mail.to("ada@example.com").subject("Digest").text("...").later();
```

`later()` enqueues the message as the `jweb.mail` job: the `Jobs` worker sends it, retries
with backoff when the server is down, and on the SQL store it survives a restart.

## The outbox, in a test

```java
import jweb.Mail;

Mail.to("ada@example.com").subject("Hi").text("there").send();
Mail.Message last = Mail.outbox().last();
List<Mail.Message> all = Mail.outbox().messages();
Mail.outbox().clear();
```

## Configuration

```yaml
jweb:
  mail:
    from: "App <no-reply@example.com>"
    smtp:
      host: ${SMTP_HOST:}        # blank = outbox
      port: 587
      username: ${SMTP_USER:}
      password: ${SMTP_PASSWORD:}
      tls: starttls              # starttls | implicit | none
```

STARTTLS on 587 by default, implicit TLS on 465, AUTH PLAIN or LOGIN with a username.
`send()` throws `Mail.MailException` with the server's refusal. Non-ASCII subjects are
RFC 2047 encoded; bodies and attachments travel base64.

## Another sender

```java
import jweb.Mail;
import jweb.Fetch;

Mail.Sender resend = message -> Fetch.post("https://api.resend.com/emails")
    .header("Authorization", "Bearer " + System.getenv("RESEND_API_KEY"))
    .json(Map.of("from", message.from(), "to", message.to(), "subject", message.subject(),
        "html", message.html(), "text", message.text()))
    .send();
Mail.sender(resend);
```

A `Mail.Sender` bean is picked up at startup.

<!-- ==================== END ref-mail ==================== -->


<!-- ==================== BEGIN ref-i18n ==================== -->

# Reference: i18n

> Source: `framework-src/docs/i18n.md` · topic id: `ref-i18n`

# Internationalization (i18n)

JWeb provides i18n support for building multilingual applications.

## Basic Usage

```java
import jweb.I18n;
import jweb.Messages;

String username = "John";

// Get translated message
String title = I18n.t("page.title");

// With parameters
String greeting = I18n.t("welcome", username);
// "welcome" = "Hello, {0}!" → "Hello, John!"
```

## Setting Up Messages

Create message files in `src/main/resources/messages/`:

**messages_en.properties:**
```properties
page.title=Welcome
page.description=This is our application
nav.home=Home
nav.about=About
nav.contact=Contact
welcome=Hello, {0}!
errors.required=This field is required
errors.email=Please enter a valid email
```

**messages_es.properties:**
```properties
page.title=Bienvenido
page.description=Esta es nuestra aplicación
nav.home=Inicio
nav.about=Acerca de
nav.contact=Contacto
welcome=¡Hola, {0}!
errors.required=Este campo es obligatorio
errors.email=Por favor ingrese un email válido
```

## Locale Detection

I18n detects the user's locale from (in order):

1. Query parameter: `?lang=es`
2. Session attribute: `lang`
3. Cookie: `lang`
4. Accept-Language header
5. Default locale

```java
record HomePage(String title) implements Template {
    @Override
    public Element render() { return div(h1(title)); }
}

app.get("/", req -> {
    Locale locale = I18n.getLocale(req);
    String title = Messages.get(locale, "page.title");
    return new HomePage(title);
});
```

## I18n Middleware

Automatically set locale for each request:

```java
record HomePage(String title) implements Template {
    @Override
    public Element render() { return div(h1(title)); }
}

app.use(I18n.middleware());

// Now I18n.current() works anywhere
app.get("/", req -> {
    String title = I18n.t("page.title");  // Uses current locale
    return new HomePage(title);
});
```

## Using in Templates

```java
public class HomePage implements Template {
    @Override
    public Element render() {
        return div(
            h1(I18n.t("page.title")),
            p(I18n.t("page.description")),
            nav(
                a(href("/"), I18n.t("nav.home")),
                a(href("/about"), I18n.t("nav.about")),
                a(href("/contact"), I18n.t("nav.contact"))
            )
        );
    }
}
```

## Message Parameters

```java
String orderId = "123";
int itemCount = 5;
double total = 49.99;

// Single parameter
I18n.t("welcome", "John");  // "Hello, John!"

// Multiple parameters
I18n.t("order.summary", orderId, itemCount, total);
// "order.summary" = "Order #{0}: {1} items, total ${2}"
// → "Order #123: 5 items, total $49.99"
```

## Request-Specific Locale

```java
record HomePage(String title) implements Template {
    @Override
    public Element render() { return div(h1(title)); }
}

app.get("/", req -> {
    // Get locale for this specific request
    String title = I18n.t(req, "page.title");
    return new HomePage(title);
});
```

## Language Picker

Get list of supported locales for a language picker:

```java
record SettingsPage(List<I18n.LocaleInfo> locales) implements Template {
    @Override
    public Element render() { return div("Settings"); }
}

app.get("/settings", req -> {
    List<I18n.LocaleInfo> locales = I18n.getSupportedLocales();
    // Each has: code, nativeName, englishName
    // e.g., {code: "es", nativeName: "Español", englishName: "Spanish"}

    return new SettingsPage(locales);
});
```

```java
List<I18n.LocaleInfo> locales = I18n.getSupportedLocales();

// In template
ul(class_("language-picker"),
    each(locales, locale ->
        li(
            a(href("?lang=" + locale.code()),
                locale.nativeName()  // Shows "Español" for Spanish
            )
        )
    )
)
```

## Changing Locale

### Via Query Parameter

```
/page?lang=es
```

### Via Session

```java
app.post("/language", req -> {
    String lang = req.formParam("lang");
    req.session().setAttribute("lang", lang);
    return Response.redirect(req.header("Referer"));
});
```

### Via Cookie

```java
import jweb.http.Cookie;

app.post("/language", req -> {
    String lang = req.formParam("lang");
    Response.Redirect redirect = Response.redirect("/");
    redirect.getHeaders().add("Set-Cookie",
        Cookie.of("lang", lang).maxAge(Duration.ofDays(365)).path("/").toHeaderValue());
    return redirect;
});
```

## Default Locale

Set the default locale:

```java
Messages.setDefaultLocale(Locale.ENGLISH);
```

## Available Locales

Register available locales:

```java
Properties es = new Properties();
es.setProperty("page.title", "Bienvenido");
Messages.loadProperties("es", es);

Set<String> available = Messages.getAvailableLocales();
```

## Parsing Accept-Language

```java
String header = "en-US,en;q=0.9,es;q=0.8";
Locale locale = I18n.parseAcceptLanguage(header);
// Returns Locale.US or first matching supported locale
```

## Complete Example

```java
record HomePage() implements Template {
    @Override
    public Element render() { return div(h1(I18n.t("page.title"))); }
}

record AboutPage() implements Template {
    @Override
    public Element render() { return div(h1(I18n.t("page.title"))); }
}

@Component
public class Routes implements JWebRoutes {
    @Override
    public void configure(JWeb app) {
        // Add i18n middleware
        app.use(I18n.middleware());

        // Pages use I18n.t() for translations
        app.get("/", req -> new HomePage());
        app.get("/about", req -> new AboutPage());

        // Language switch
        app.post("/language", req -> {
            String lang = req.formParam("lang");
            req.session().setAttribute("lang", lang);

            String referer = req.header("Referer");
            return Response.redirect(referer != null ? referer : "/");
        });
    }
}

public class Layout implements Template {
    private final Element content;

    public Layout(Element content) {
        this.content = content;
    }

    @Override
    public Element render() {
        return html(attrs().lang(I18n.current().getLanguage()),
            head(
                title(I18n.t("page.title"))
            ),
            body(
                header(
                    nav(
                        a(href("/"), I18n.t("nav.home")),
                        a(href("/about"), I18n.t("nav.about"))
                    ),
                    languagePicker()
                ),
                main(content)
            )
        );
    }

    private Element languagePicker() {
        return form(method("post"), action("/language"),
            select(name("lang"), onChange("this.form.submit()"),
                each(I18n.getSupportedLocales(), locale ->
                    option(
                        value(locale.code()),
                        attrs().selected(locale.code().equals(I18n.current().getLanguage())),
                        locale.nativeName()
                    )
                )
            )
        );
    }
}
```

<!-- ==================== END ref-i18n ==================== -->


<!-- ==================== BEGIN ref-observability ==================== -->

# Reference: observability — request ids, logs, metrics, traces

> Source: `framework-src/docs/observability.md` · topic id: `ref-observability`

# Observability

Request ids on every line, structured logs when you want them, metrics, and traces of what
the framework did for a request — with nothing to add until you want the exports.

## Request ids

Every request gets an id before anything sees it: the caller's `X-Request-ID` when it sends
one (a gateway's, a mobile client's), a fresh one otherwise. The response carries it back in
`X-Request-ID`; every log line written during the request shows it; the error page and the
JSON error quote it.

```java
import jweb.Request;

String id = req.id();                      // the same for the whole request
```

```text
2026-10-14T20:11:03.412-07:00  INFO 4711 --- [MyApp] [nio-8080-exec-3] [7f3a9c21b4d8e05f6a12] jweb.Sql : …
```

The line pattern is Spring Boot's own with `logging.pattern.correlation` set by the framework
to `[requestId traceId] ` — the trace id appears when tracing is on, nothing on lines outside
a request. Set `logging.pattern.correlation` yourself to override, or
`jweb.observability.log-correlation: false` to leave Boot's default. A browser event or a
PubSub push runs with `contextId` (the page's render context) in the logging context instead.

## Structured logs

Spring Boot writes JSON when asked; the request id rides along as a field because it lives in
the MDC:

```yaml
logging:
  structured:
    format:
      console: ecs        # or logstash, gelf
```

## Access log

```java
import jweb.Middlewares;

app.use(Middlewares.logging());           // GET /orders/9 200 12ms, at INFO, with the request id
```

## Metrics

`Metrics.counter/gauge/timer` and `Metrics.middleware()`, mirrored into Micrometer when the
app has a `MeterRegistry` (the actuator). The framework records `http.requests.*`,
`jweb.jobs.*`, `jweb.mail.*`; see the architecture guide.

## Traces (`jweb.Trace`)

A span is a named, timed piece of work with tags and the error that ended it. The framework
opens one around:

| Span | Low-cardinality tags | High-cardinality tags |
|---|---|---|
| `jweb.route` — a route or page render | `http.method`, `route` (the pattern) | — |
| `jweb.sql` — one statement | `db.operation` (select, insert…) | `db.statement` |
| `jweb.job` — one job run | `job` (the name) | `job.id`, `job.attempt` |
| `jweb.pubsub.deliver` — one publish, delivered | `topic` | `subscribers` |
| `jweb.mail.send` | — | `mail.recipients` |
| `jweb.fetch` — an outgoing `Fetch` | `http.method` | `http.url` (no query), `http.status` |
| `jweb.event` — a browser event | `event` (click, submit…) | `handler`, `context` |

An app opens its own:

```java
import jweb.Trace;

record Order(long id) {}
Order order = new Order(9);

try (Trace.Span span = Trace.span("order.price", "kind", "retail")) {   // start tags: low cardinality
    span.tag("order.id", order.id());                                     // later tags: high cardinality
}
Order priced = Trace.in("order.price", () -> order);
Trace.in("cache.warm", () -> {});
```

Without a sink a span costs nothing. With Spring Boot's actuator the app has an
`ObservationRegistry` and the framework binds `ObservationSink` to it: each span is a
Micrometer `Observation` — a timer per name in the meter registry, and with
`micrometer-tracing` and an exporter a span in a real trace, nested under the HTTP request's,
with the trace id in every log line:

```xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
  <groupId>io.opentelemetry</groupId>
  <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
```

```yaml
management:
  tracing:
    sampling:
      probability: 1.0
  otlp:
    tracing:
      endpoint: http://localhost:4318/v1/traces
```

Any `Trace.Sink` bean replaces the bridge; `jweb.observability.tracing: false` turns the
binding off.

## Configuration

| Property | Default | |
|---|---|---|
| `jweb.observability.request-id` | `true` | The filter that gives every request its id |
| `jweb.observability.log-correlation` | `true` | Put `[requestId traceId] ` on every log line of a request |
| `jweb.observability.tracing` | `true` | Bind `Trace` to the app's `ObservationRegistry` when it has one |

<!-- ==================== END ref-observability ==================== -->


<!-- ==================== BEGIN ref-testing ==================== -->

# Reference: testing

> Source: `framework-src/docs/testing.md` · topic id: `ref-testing`

# Testing

JWeb provides testing utilities for testing routes, handlers, and templates.

## Mock Requests

Create mock HTTP requests for testing:

```java
// GET request
Request getReq = MockRequest.get("/users").build();

// With query parameters
Request queryReq = MockRequest.get("/users")
    .queryParam("page", "1")
    .queryParam("limit", "10")
    .build();

// With headers
Request headerReq = MockRequest.get("/api/data")
    .header("Authorization", "Bearer token123")
    .header("Accept", "application/json")
    .build();

// POST with JSON body
Request jsonReq = MockRequest.post("/api/users")
    .json("{\"name\": \"John\", \"email\": \"john@example.com\"}")
    .build();

// POST with form data — formData(...) takes the whole map at once
Request formReq = MockRequest.post("/login")
    .formData(Map.of("username", "john", "password", "secret"))
    .build();

// Other methods — PATCH has no dedicated factory, use request(method, path)
MockRequest.put("/users/1")
MockRequest.request("PATCH", "/users/1")
MockRequest.delete("/users/1")
```

## Testing Routes

```java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class Routes implements JWebRoutes {
    @Override
    public void configure(JWeb app) {
        app.get("/", req -> "Welcome");
        app.get("/api/users", req -> Response.json(List.of("users")));
    }
}

@Test
void testHomePage() {
    JWeb app = JWeb.create();
    new Routes().configure(app);

    JWebTest.TestResult result = JWebTest.test(app, MockRequest.get("/"));

    assertTrue(result.isSuccess());
    assertTrue(result.bodyContains("Welcome"));
}

@Test
void testApiEndpoint() {
    JWeb app = JWeb.create();
    new Routes().configure(app);

    JWebTest.TestResult result = JWebTest.test(app,
        MockRequest.get("/api/users")
            .header("Accept", "application/json")
    );

    assertEquals(200, result.getStatus());
    assertTrue(result.bodyContains("users"));
}
```

## Testing Handlers

Test individual handlers:

```java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

@Test
void testUserHandler() {
    RouteHandler handler = req -> {
        String id = req.param("id");
        return Response.json(Map.of("id", id, "name", "John"));
    };

    // testHandler(...) takes the MockRequest itself, not a built Request
    MockRequest req = MockRequest.get("/users/123");
    JWebTest.TestResult result = JWebTest.testHandler(handler, req);

    assertTrue(result.isSuccess());
    assertTrue(result.bodyContains("123"));
}
```

## TestResult Methods

```java
JWeb app = JWeb.create();
MockRequest request = MockRequest.get("/");
JWebTest.TestResult result = JWebTest.test(app, request);

// Status checks
result.isSuccess();      // 2xx status
result.getStatus();      // HTTP status code

// Body checks
result.bodyContains("text");
result.getBody();        // the raw body String

// TestResult does not capture response headers yet
```

## HTML Assertions

Assert HTML content:

```java
Element element = div(cls("container"), h1("Welcome"));
String html = element.toHtml();

// Content assertions
JWebTest.assertContains(html, "Welcome");
JWebTest.assertNotContains(html, "Error");

// Attribute assertions
JWebTest.assertHasClass(html, "container");
JWebTest.assertHasId(html, "main");

// Tag assertions
JWebTest.assertHasTag(html, "form");
JWebTest.assertHasTag(html, "button");
```

## Testing Templates

```java
import org.junit.jupiter.api.Test;

record HomePage() implements Template {
    public Element render() {
        return div(nav(), h1("Welcome"), cls("hero"));
    }
}
record Card(String title, String content) implements Template {
    public Element render() {
        return div(cls("card"), h2(title), p(content));
    }
}

@Test
void testHomePage() {
    HomePage page = new HomePage();
    String html = page.render().toHtml();

    JWebTest.assertContains(html, "Welcome");
    JWebTest.assertHasClass(html, "hero");
    JWebTest.assertHasTag(html, "nav");
}

@Test
void testCardComponent() {
    Card card = new Card("Title", "Content");
    String html = card.render().toHtml();

    JWebTest.assertContains(html, "Title");
    JWebTest.assertContains(html, "Content");
    JWebTest.assertHasClass(html, "card");
}
```

## Mock Sessions

Test session-based features:

```java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

@Test
void testAuthenticatedRequest() {
    JWeb app = JWeb.create();
    app.get("/dashboard", req -> "Dashboard");

    MockSession session = new MockSession();
    session.setAttribute("user", Principal.of("user123", "john@example.com"));

    MockRequest req = MockRequest.get("/dashboard").session(session);

    JWebTest.TestResult result = JWebTest.test(app, req);
    assertTrue(result.isSuccess());
}
```

## Testing with Authentication

```java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

record AdminPage() implements Template {
    public Element render() { return div(h1("Admin")); }
}

@Test
void testProtectedRoute() {
    JWeb app = JWeb.create();
    app.use("/admin", Auth.requireRole("admin"));
    app.get("/admin", req -> new AdminPage());

    // Without auth - should fail
    JWebTest.TestResult result = JWebTest.test(app, MockRequest.get("/admin"));
    assertEquals(401, result.getStatus());

    // With auth — Auth.login(...) takes a built Request, so log in on a request
    // that shares the same session, then reuse that session for the real call
    MockSession session = new MockSession();
    Auth.login(MockRequest.get("/admin").session(session).build(),
        Principal.of("1", "admin@example.com", "admin"));

    result = JWebTest.test(app, MockRequest.get("/admin").session(session));
    assertTrue(result.isSuccess());
}
```

## Testing Validation

```java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

@Test
void testFormValidation() {
    JWeb app = JWeb.create();
    app.post("/register", req -> Response.redirect("/welcome"));

    // Test valid input
    MockRequest validReq = MockRequest.post("/register")
        .formData(Map.of("email", "john@example.com", "password", "securepass123"));

    JWebTest.TestResult result = JWebTest.test(app, validReq);
    assertTrue(result.isSuccess());

    // Test invalid input
    MockRequest invalidReq = MockRequest.post("/register")
        .formData(Map.of("email", "invalid", "password", "short"));

    result = JWebTest.test(app, invalidReq);
    assertEquals(400, result.getStatus());
    assertTrue(result.bodyContains("email"));
}
```

## Testing JSON APIs

```java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

@Test
void testJsonApi() {
    JWeb app = JWeb.create();
    app.post("/api/users", req -> Response.json(201, Map.of("name", "John")));
    app.get("/api/users/:id", req -> "1".equals(req.param("id"))
        ? Response.json(Map.of("id", "1"))
        : Response.notFound("User not found"));

    // Create
    JWebTest.TestResult result = JWebTest.test(app,
        MockRequest.post("/api/users")
            .json("{\"name\": \"John\", \"email\": \"john@example.com\"}")
    );

    assertEquals(201, result.getStatus());
    assertTrue(result.bodyContains("John"));

    // Read
    result = JWebTest.test(app, MockRequest.get("/api/users/1"));
    assertEquals(200, result.getStatus());

    // Not found
    result = JWebTest.test(app, MockRequest.get("/api/users/999"));
    assertEquals(404, result.getStatus());
}
```

## Integration Test Example

```java
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class RoutesIntegrationTest {

    @Autowired
    private JWeb app;

    @Test
    void testFullUserFlow() {
        // Register
        JWebTest.TestResult result = JWebTest.test(app,
            MockRequest.post("/register")
                .formData(Map.of("email", "test@example.com", "password", "password123"))
        );
        assertEquals(302, result.getStatus());  // Redirect

        // Login
        result = JWebTest.test(app,
            MockRequest.post("/login")
                .formData(Map.of("email", "test@example.com", "password", "password123"))
        );
        assertEquals(302, result.getStatus());

        // Access dashboard — Auth.login(...) takes a built Request, so log in on
        // a request that shares the session, then reuse that session below
        MockSession session = new MockSession();
        Auth.login(MockRequest.get("/dashboard").session(session).build(),
            Principal.of("1", "test@example.com", "user"));

        result = JWebTest.test(app,
            MockRequest.get("/dashboard").session(session)
        );
        assertTrue(result.isSuccess());
        assertTrue(result.bodyContains("Dashboard"));
    }
}
```

## In a Real Browser

`jweb.Browser` launches a headless Chrome and drives it over the DevTools protocol — no
WebDriver or Playwright to add. Gate the test on `Browser.available()`; it runs where a
Chrome is found (`JWEB_CHROME`, the install locations, the `PATH`) and is skipped elsewhere.

```java
import jweb.Browser;

int port = 8085;
try (Browser browser = Browser.launch();
     Browser.Page page = browser.open("http://localhost:" + port + "/todos")) {
    page.waitFor("#new-todo");
    page.type("#new-todo", "Write tests");
    page.click("#add");
    page.waitForText("ul", "Write tests");
    List<String> errors = page.errors();              // console errors and uncaught exceptions
    page.screenshot(java.nio.file.Path.of("target/todos.png"));
}
```

| Call | What it does |
|---|---|
| `open(url)`, `navigate`, `reload` | A tab at the URL, waited for its load event |
| `click(sel)`, `type(sel, text)`, `fill(sel, text)`, `press(key)`, `submit(sel)` | Real input events |
| `waitFor(sel)`, `waitForText(sel, text)`, `waitForHtml(text)`, `waitUntil(js)` | Poll up to five seconds (a `Duration` overload for longer) |
| `html()`, `text(sel)`, `value(sel)`, `exists(sel)`, `count(sel)`, `eval(js)` | Read the page as the browser has it |
| `console()`, `errors()`, `screenshot(path)`, `cookie(name, value)` | The console so far, a PNG, a cookie on the origin |

<!-- ==================== END ref-testing ==================== -->


<!-- End of JWeb documentation. -->
