diff --git a/Cargo.toml b/Cargo.toml
index e04d751..4d32ee6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -129,3 +129,7 @@ minijinja = "2"
+
+[[bench]]
+name = "throughput"
+harness = false
diff --git a/README.md b/README.md
index 1313bc5..56e8426 100644
--- a/README.md
+++ b/README.md
@@ -36,49 +36,52 @@
- [Install](#install)
- [eget](#eget)
- - [Homebrew](#homebrew-macos)
+ - [Homebrew (macOS)](#homebrew-macos)
- [cargo](#cargo)
- [Nix](#nix)
-- [Reference](#reference)
- [GET: Hello world](#get-hello-world)
- [UNIX domain sockets](#unix-domain-sockets)
- - [Watch Mode](#watch-mode)
- - [Reading from stdin](#reading-from-stdin)
+- [Requests & responses](#requests--responses)
- [POST: echo](#post-echo)
+ - [Reading from stdin](#reading-from-stdin)
- [Request metadata](#request-metadata)
- [Response metadata](#response-metadata)
- [Content-Type Inference](#content-type-inference)
- - [TLS & HTTP/2 Support](#tls-support)
- - [Logging](#logging)
- - [Trusted Proxies](#trusted-proxies)
- - [Serving Static Files](#serving-static-files)
+- [Streaming & events](#streaming--events)
- [Streaming responses](#streaming-responses)
- [server-sent events](#server-sent-events)
+ - [Streaming Input](#streaming-input)
+- [Serving & operations](#serving--operations)
+ - [Watch Mode](#watch-mode)
+ - [Serving Static Files](#serving-static-files)
+ - [Logging](#logging)
+ - [Trusted Proxies](#trusted-proxies)
+ - [TLS Support](#tls-support)
+- [State & storage](#state--storage)
- [In-memory SQLite](#in-memory-sqlite)
- [Local Bus](#local-bus)
- [Embedded cross.stream (full featured Persistent Event Stream)](#embedded-crossstream-full-featured-persistent-event-stream)
- - [Reverse Proxy](#reverse-proxy)
+- [Reverse Proxy](#reverse-proxy)
+ - [Basic Usage](#basic-usage)
+ - [Configuration Options](#configuration-options)
+ - [Examples](#examples)
+- [Templates & output](#templates--output)
- [Templates](#templates)
- - [`.mj` - Render templates](#mj---render-templates)
- - [`.mj compile` / `.mj render` - Precompiled templates](#mj-compile--mj-render---precompiled-templates)
- [Syntax Highlighting](#syntax-highlighting)
- [Markdown](#markdown)
- - [Evaluating User-Submitted Scripts](#evaluating-user-submitted-scripts)
- - [Streaming Input](#streaming-input)
+- [Embedded Modules](#embedded-modules)
+ - [Routing](#routing)
+ - [HTML DSL](#html-dsl)
+ - [Datastar SDK](#datastar-sdk)
+ - [Cookies](#cookies)
+- [Extending & eval](#extending--eval)
+ - [Eval Subcommand](#eval-subcommand)
- [Plugins](#plugins)
- [Module Paths](#module-paths)
- - [Embedded Modules](#embedded-modules)
- - [Routing](#routing)
- - [HTML DSL](#html-dsl)
- - [Datastar SDK](#datastar-sdk)
- - [Cookies](#cookies)
-- [Eval Subcommand](#eval-subcommand)
- - [Unit Testing Endpoints](#unit-testing-endpoints)
-- [Building and Releases](#building-and-releases)
- - [Available Build Targets](#available-build-targets)
- - [Examples](#examples)
- - [GitHub Releases](#github-releases)
-- [History](#history)
+ - [Runtime Constants](#runtime-constants)
+ - [Evaluating User-Submitted Scripts](#evaluating-user-submitted-scripts)
+ - [Building and Releases](#building-and-releases)
+ - [History](#history)
@@ -124,8 +127,6 @@ http-nu is available in [nixpkgs](https://github.com/NixOS/nixpkgs). For
packaging and maintenance documentation, see
[NIXOS_PACKAGING_GUIDE.md](NIXOS_PACKAGING_GUIDE.md).
-## Reference
-
### GET: Hello world
```bash
@@ -148,6 +149,16 @@ $ http-nu --datastar :3001 examples/serve.nu
$ http-nu --datastar --store ./store :3001 examples/serve.nu # enables store-dependent examples
```
+You can also run a command or pipeline without starting a server, handy for
+trying the examples throughout these docs:
+
+```bash
+$ http-nu eval -c '1 + 2'
+3
+```
+
+See [Eval Subcommand](#eval-subcommand) for details.
+
### UNIX domain sockets
```bash
@@ -156,23 +167,18 @@ $ curl -s --unix-socket ./sock localhost
Hello world
```
-### Watch Mode
+## Requests & responses
-Use `-w` / `--watch` to automatically reload when files change:
+Read the request, shape the response, and infer content types.
+
+### POST: echo
```bash
-$ http-nu :3001 -w ./serve.nu
+$ http-nu :3001 -c '{|req| $in}'
+$ curl -s -d Hai localhost:3001
+Hai
```
-This watches the script's directory for any changes (including included files)
-and hot-reloads the handler. Active [SSE connections](#server-sent-events) are
-aborted on reload to trigger client reconnection.
-
-> [!WARNING]
-> The watch is recursive: keep only files that should trigger a reload in the
-> script's directory (`serve.nu`, `templates/`, `static/`, ...). Anything else
-> that churns there reloads the handler too.
-
### Reading from stdin
Pass `-` to read the script from stdin:
@@ -189,14 +195,6 @@ $ (printf '{|req| "v1"}\0'; sleep 5; printf '{|req| "v2"}') | http-nu :3001 - -w
Each `\0`-terminated script replaces the handler.
-### POST: echo
-
-```bash
-$ http-nu :3001 -c '{|req| $in}'
-$ curl -s -d Hai localhost:3001
-Hai
-```
-
### Request metadata
The Request metadata is passed as an argument to the closure.
@@ -315,100 +313,9 @@ To consume a JSONL endpoint from Nushell:
http get http://localhost:3001 | from json --objects | each {|row| ... }
```
-### TLS Support
-
-Enable TLS by providing a PEM file containing both certificate and private key:
-
-```bash
-$ http-nu :3001 --tls combined.pem -c '{|req| "Secure Hello"}'
-$ curl -k https://localhost:3001
-Secure Hello
-```
-
-Generate a self-signed certificate for testing:
-
-```bash
-$ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
-$ cat cert.pem key.pem > combined.pem
-```
-
-HTTP/2 is automatically enabled for TLS connections:
-
-```bash
-$ curl -k --http2 -si https://localhost:3001 | head -1
-HTTP/2 200
-```
-
-### Logging
-
-Control log output with `--log-format`:
-
-- `human` (default): Live-updating terminal output with startup banner,
- per-request progress lines showing timestamp, IP, method, path, status,
- timing, and bytes
-- `jsonl`: Structured JSON lines with `scru128` stamps for log aggregation
-
-Each request emits 3 phases: **request** (received), **response** (headers
-sent), **complete** (body finished).
-
-**Human format**
-
-
-
-**JSONL format**
-
-Events share a `request_id` for correlation:
-
-```bash
-$ http-nu --log-format jsonl :3001 '{|req| "hello"}'
-{"stamp":"...","message":"started","address":"http://127.0.0.1:3001","startup_ms":42}
-{"stamp":"...","message":"request","request_id":"...","method":"GET","path":"/","request":{...}}
-{"stamp":"...","message":"response","request_id":"...","status":200,"headers":{...},"latency_ms":1}
-{"stamp":"...","message":"complete","request_id":"...","bytes":5,"duration_ms":2}
-```
-
-Lifecycle events: `started`, `reloaded`, `stopping`, `stopped`, `stop_timed_out`
-
-The `print` command outputs to the logging system (appears as `message: "print"`
-in JSONL).
-
-### Trusted Proxies
+## Streaming & events
-When behind a reverse proxy, use `--trust-proxy` to extract client IP from
-`X-Forwarded-For`. Accepts CIDR notation, repeatable:
-
-```bash
-$ http-nu --trust-proxy 10.0.0.0/8 --trust-proxy 192.168.0.0/16 :3001 '{|req| $req.trusted_ip}'
-```
-
-The `trusted_ip` field is resolved by parsing `X-Forwarded-For` right-to-left,
-stopping at the first IP not in a trusted range. Falls back to `remote_ip` when:
-
-- No `--trust-proxy` flags provided
-- Remote IP is not in trusted ranges
-- No `X-Forwarded-For` header present
-
-### Serving Static Files
-
-You can serve static files from a directory using the `.static` command. This
-command takes two arguments: the root directory path and the request path.
-
-When you call `.static`, it sets the response to serve the specified file, and
-any subsequent output in the closure will be ignored. The content type is
-automatically inferred based on the file extension (e.g., `text/css` for `.css`
-files).
-
-Here's an example:
-
-```bash
-$ http-nu :3001 -c '{|req| .static "/path/to/static/dir" $req.path}'
-```
-
-For single page applications you can provide a fallback file:
-
-```bash
-$ http-nu :3001 -c '{|req| .static "/path/to/static/dir" $req.path --fallback "index.html"}'
-```
+Stream chunks as they are produced, and push server-sent events.
### Streaming responses
@@ -501,6 +408,139 @@ data: {"date":"2025-01-31 04:01:28.390407 -05:00"}
...
```
+### Streaming Input
+
+In Nushell, input only streams when received implicitly. Referencing `$in`
+collects the entire input into memory.
+
+```nushell
+# Streams: command receives input implicitly
+{|req| from json }
+
+# Buffers: $in collects before piping
+{|req| $in | from json }
+```
+
+## Serving & operations
+
+Serve files, watch for changes, log requests, trust proxies, and enable TLS.
+
+### Watch Mode
+
+Use `-w` / `--watch` to automatically reload when files change:
+
+```bash
+$ http-nu :3001 -w ./serve.nu
+```
+
+This watches the script's directory for any changes (including included files)
+and hot-reloads the handler. Active [SSE connections](#server-sent-events) are
+aborted on reload to trigger client reconnection.
+
+> [!WARNING]
+> The watch is recursive: keep only files that should trigger a reload in the
+> script's directory (`serve.nu`, `templates/`, `static/`, ...). Anything else
+> that churns there reloads the handler too.
+
+### Serving Static Files
+
+You can serve static files from a directory using the `.static` command. This
+command takes two arguments: the root directory path and the request path.
+
+When you call `.static`, it sets the response to serve the specified file, and
+any subsequent output in the closure will be ignored. The content type is
+automatically inferred based on the file extension (e.g., `text/css` for `.css`
+files).
+
+Here's an example:
+
+```bash
+$ http-nu :3001 -c '{|req| .static "/path/to/static/dir" $req.path}'
+```
+
+For single page applications you can provide a fallback file:
+
+```bash
+$ http-nu :3001 -c '{|req| .static "/path/to/static/dir" $req.path --fallback "index.html"}'
+```
+
+### Logging
+
+Control log output with `--log-format`:
+
+- `human` (default): Live-updating terminal output with startup banner,
+ per-request progress lines showing timestamp, IP, method, path, status,
+ timing, and bytes
+- `jsonl`: Structured JSON lines with `scru128` stamps for log aggregation
+
+Each request emits 3 phases: **request** (received), **response** (headers
+sent), **complete** (body finished).
+
+**Human format**
+
+
+
+**JSONL format**
+
+Events share a `request_id` for correlation:
+
+```bash
+$ http-nu --log-format jsonl :3001 '{|req| "hello"}'
+{"stamp":"...","message":"started","address":"http://127.0.0.1:3001","startup_ms":42}
+{"stamp":"...","message":"request","request_id":"...","method":"GET","path":"/","request":{...}}
+{"stamp":"...","message":"response","request_id":"...","status":200,"headers":{...},"latency_ms":1}
+{"stamp":"...","message":"complete","request_id":"...","bytes":5,"duration_ms":2}
+```
+
+Lifecycle events: `started`, `reloaded`, `stopping`, `stopped`, `stop_timed_out`
+
+The `print` command outputs to the logging system (appears as `message: "print"`
+in JSONL).
+
+### Trusted Proxies
+
+When behind a reverse proxy, use `--trust-proxy` to extract client IP from
+`X-Forwarded-For`. Accepts CIDR notation, repeatable:
+
+```bash
+$ http-nu --trust-proxy 10.0.0.0/8 --trust-proxy 192.168.0.0/16 :3001 '{|req| $req.trusted_ip}'
+```
+
+The `trusted_ip` field is resolved by parsing `X-Forwarded-For` right-to-left,
+stopping at the first IP not in a trusted range. Falls back to `remote_ip` when:
+
+- No `--trust-proxy` flags provided
+- Remote IP is not in trusted ranges
+- No `X-Forwarded-For` header present
+
+### TLS Support
+
+Enable TLS by providing a PEM file containing both certificate and private key:
+
+```bash
+$ http-nu :3001 --tls combined.pem -c '{|req| "Secure Hello"}'
+$ curl -k https://localhost:3001
+Secure Hello
+```
+
+Generate a self-signed certificate for testing:
+
+```bash
+$ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
+$ cat cert.pem key.pem > combined.pem
+```
+
+HTTP/2 is automatically enabled for TLS connections:
+
+```bash
+$ curl -k --http2 -si https://localhost:3001 | head -1
+HTTP/2 200
+```
+
+## State & storage
+
+Keep state across requests: in-memory SQLite, an ephemeral bus, and a durable event store.
+
### In-memory SQLite
Nushell's [`stor`](https://www.nushell.sh/commands/docs/stor.html) commands
@@ -617,11 +657,14 @@ Templates can also load from the store using `.mj --topic` and
```nushell
{|req|
.last quotes --follow
- | each {|frame| $frame.meta | to datastar-patch-elements }
+ | each {|frame| {data: $frame.meta} }
| to sse
}
```
+`to sse` pairs naturally with the [Datastar SDK](#datastar-sdk) when you want
+this stream to drive live DOM updates.
+
**Combining with the [Local Bus](#local-bus):**
Bus events use `compose.close` or `editor.open` style UI signals; store events
@@ -652,7 +695,7 @@ The bus differs from `.append` / `.cat`:
See the [xs documentation](https://www.cross.stream) to learn more.
-### Reverse Proxy
+## Reverse Proxy
You can proxy HTTP requests to backend servers using the `.reverse-proxy`
command. This command takes a target URL and an optional configuration record.
@@ -675,14 +718,14 @@ closure will be ignored.
- With `preserve_host: false`: Sets Host header to match the target backend
hostname
-#### Basic Usage
+### Basic Usage
```bash
# Simple proxy to backend server
$ http-nu :3001 -c '{|req| .reverse-proxy "http://localhost:8080"}'
```
-#### Configuration Options
+### Configuration Options
The optional second parameter allows you to customize the proxy behavior:
@@ -695,7 +738,7 @@ The optional second parameter allows you to customize the proxy behavior:
}
```
-#### Examples
+### Examples
**Add custom headers:**
@@ -746,6 +789,10 @@ $ http-nu :3001 -c '{|req|
# Force context-id=smidgeons, remove debug param, preserve others
```
+## Templates & output
+
+Render templates, and turn code and Markdown into HTML.
+
### Templates
Render [minijinja](https://github.com/mitsuhiko/minijinja) (Jinja2-compatible)
@@ -851,100 +898,9 @@ fn main() {}
...
````
-### Evaluating User-Submitted Scripts
-
-The `.run` command parses, compiles, and evaluates a nushell script string. Use
-it to build web UIs that let users submit and run arbitrary commands -- an
-in-browser REPL, for example. Pipeline input is forwarded to the script.
-
-```nushell
-"hello" | .run 'str upcase' # => HELLO
-[1 2 3] | .run 'math sum' # => 6
-```
-
-Parse, compile, and runtime errors surface as distinct error types with source
-excerpts pointing at the offending span. Each call runs against a clone of the
-engine state, so any `def`, `let`, or `use` lives only for the call's duration;
-the caller's bindings and environment are also hidden from the script.
-
-> [!WARNING]
-> The submitted script has full access to whatever the http-nu process can do --
-> files, network, the embedded store. Only expose `.run` on localhost or in
-> trusted environments.
-
-### Streaming Input
-
-In Nushell, input only streams when received implicitly. Referencing `$in`
-collects the entire input into memory.
-
-```nushell
-# Streams: command receives input implicitly
-{|req| from json }
-
-# Buffers: $in collects before piping
-{|req| $in | from json }
-```
-
-For routing, `dispatch` must be first in the closure to receive the body. In
-handlers, put body-consuming commands first:
-
-```nushell
-{|req|
- dispatch $req [
- (route {method: "POST"} {|req ctx|
- from json # receives body implicitly
- })
- ]
-}
-```
-
-### Plugins
-
-Load Nushell plugins to extend available commands.
-
-```bash
-$ http-nu --plugin ~/.cargo/bin/nu_plugin_inc :3001 '{|req| 5 | inc}'
-$ curl -s localhost:3001
-6
-```
-
-Multiple plugins:
-
-```bash
-$ http-nu --plugin ~/.cargo/bin/nu_plugin_inc --plugin ~/.cargo/bin/nu_plugin_query :3001 '{|req| ...}'
-```
-
-Works with eval:
-
-```bash
-$ http-nu --plugin ~/.cargo/bin/nu_plugin_inc eval -c '1 | inc'
-2
-```
-
-### Module Paths
-
-Make module paths available with `-I` / `--include-path`:
-
-```bash
-$ http-nu -I ./lib -I ./vendor :3001 '{|req| use mymod.nu; ...}'
-```
-
-### Runtime Constants
-
-The `$HTTP_NU` const is available in all scripts and reflects the CLI options
-the server was started with:
-
-```nushell
-$HTTP_NU
-# => {dev: false, datastar: true, watch: false, store: "./store", topic: null, expose: null, tls: null, services: false}
-
-$HTTP_NU.store != null # check if store is available
-$HTTP_NU.dev # true when --dev was passed
-```
-
-### Embedded Modules
+## Embedded Modules
-#### Routing
+### Routing
http-nu includes an embedded routing module for declarative request handling.
The request body is available to handlers as `$in`.
@@ -984,6 +940,21 @@ Routes match in order. First match wins. Closure tests return a record (match,
context passed to handler) or null (no match). If no routes match, returns
`501 Not Implemented`.
+`dispatch` must come first in the closure to receive the request body, and
+inside a handler put body-consuming commands (`from json`, etc.) first, since
+input only streams when received implicitly (see
+[Streaming Input](#streaming-input)):
+
+```nushell
+{|req|
+ dispatch $req [
+ (route {method: "POST"} {|req ctx|
+ from json # receives body implicitly
+ })
+ ]
+}
+```
+
**Mounting sub-handlers:**
`mount` serves a handler under a path prefix. Requests to `/prefix` redirect to
@@ -1014,7 +985,7 @@ $req | href "/about"
# "/blog/about" when mounted under /blog, "/about" otherwise
```
-#### HTML DSL
+### HTML DSL
Build HTML with Nushell. Lisp-style nesting with uppercase tags.
@@ -1088,7 +1059,7 @@ let tpl = .mj compile --inline (UL (_for {item: items} (LI (_var "item"))))
#
a
b
c
```
-#### Datastar SDK
+### Datastar SDK
Generate [Datastar](https://data-star.dev) SSE events for hypermedia
interactions. Follows the
@@ -1183,7 +1154,7 @@ to datastar-redirect []: string -> record # "/url" | to datastar-redirect
from datastar-signals [req: record]: string -> record # $in | from datastar-signals $req
```
-#### Cookies
+### Cookies
Set and parse HTTP cookies with secure defaults.
@@ -1242,7 +1213,11 @@ cookie delete [
]: any -> any
```
-## Eval Subcommand
+## Extending & eval
+
+The eval subcommand, plugins, module paths, evaluating user scripts, and releases.
+
+### Eval Subcommand
Test http-nu commands without running a server.
@@ -1263,7 +1238,7 @@ $ http-nu eval -c '.mj compile --inline "Hello, {{ name }}" | describe'
CompiledTemplate
```
-### Unit Testing Endpoints
+#### Unit Testing Endpoints
`source` loads a handler script and returns the closure. `do` invokes it with a
request record. `assert` checks the response.
@@ -1285,20 +1260,85 @@ $ http-nu eval test.nu
See [`examples/tao/test.nu`](examples/tao/test.nu).
-## Building and Releases
+### Plugins
+
+Load Nushell plugins to extend available commands.
+
+```bash
+$ http-nu --plugin ~/.cargo/bin/nu_plugin_inc :3001 '{|req| 5 | inc}'
+$ curl -s localhost:3001
+6
+```
+
+Multiple plugins:
+
+```bash
+$ http-nu --plugin ~/.cargo/bin/nu_plugin_inc --plugin ~/.cargo/bin/nu_plugin_query :3001 '{|req| ...}'
+```
+
+Works with eval:
+
+```bash
+$ http-nu --plugin ~/.cargo/bin/nu_plugin_inc eval -c '1 | inc'
+2
+```
+
+### Module Paths
+
+Make module paths available with `-I` / `--include-path`:
+
+```bash
+$ http-nu -I ./lib -I ./vendor :3001 '{|req| use mymod.nu; ...}'
+```
+
+### Runtime Constants
+
+The `$HTTP_NU` const is available in all scripts and reflects the CLI options
+the server was started with:
+
+```nushell
+$HTTP_NU
+# => {dev: false, datastar: true, watch: false, store: "./store", topic: null, expose: null, tls: null, services: false}
+
+$HTTP_NU.store != null # check if store is available
+$HTTP_NU.dev # true when --dev was passed
+```
+
+### Evaluating User-Submitted Scripts
+
+The `.run` command parses, compiles, and evaluates a nushell script string. Use
+it to build web UIs that let users submit and run arbitrary commands -- an
+in-browser REPL, for example. Pipeline input is forwarded to the script.
+
+```nushell
+"hello" | .run 'str upcase' # => HELLO
+[1 2 3] | .run 'math sum' # => 6
+```
+
+Parse, compile, and runtime errors surface as distinct error types with source
+excerpts pointing at the offending span. Each call runs against a clone of the
+engine state, so any `def`, `let`, or `use` lives only for the call's duration;
+the caller's bindings and environment are also hidden from the script.
+
+> [!WARNING]
+> The submitted script has full access to whatever the http-nu process can do --
+> files, network, the embedded store. Only expose `.run` on localhost or in
+> trusted environments.
+
+### Building and Releases
This project uses [Dagger](https://dagger.io) for cross-platform containerized
builds that run identically locally and in CI. This means you can test builds on
your machine before pushing tags to trigger releases.
-### Available Build Targets
+#### Available Build Targets
- **Windows** (`windows-build`)
- **macOS ARM64** (`darwin-build`)
- **Linux ARM64** (`linux-arm-64-build`)
- **Linux AMD64** (`linux-amd-64-build`)
-### Examples
+#### Examples
Build a Windows binary locally:
@@ -1316,13 +1356,13 @@ dagger call windows-env --src upload --src "." terminal
The `upload` function filters files to avoid uploading everything in your local
directory.
-### GitHub Releases
+#### GitHub Releases
The GitHub workflow automatically builds all platforms and creates releases when
you push a version tag (e.g., `v1.0.0`). Development tags containing `-dev.` are
marked as prereleases.
-## History
+### History
If you prefer POSIX to [Nushell](https://www.nushell.sh), this project has a
cousin called [http-sh](https://github.com/cablehead/http-sh).
diff --git a/benches/throughput.rs b/benches/throughput.rs
new file mode 100644
index 0000000..06bf4db
--- /dev/null
+++ b/benches/throughput.rs
@@ -0,0 +1,119 @@
+// Throughput benchmarks for the request hot path.
+//
+// Run with: cargo bench --bench throughput
+//
+// Dimensions, each over N requests against an in-process `handle()` with a
+// hello-world closure (no TCP, no TLS -- this isolates the engine/handler
+// path: request -> eval thread -> closure eval -> response):
+//
+// hello sequential requests; per-request latency of the eval path
+// hello-concurrent 16 requests in flight; what the path sustains across cores
+//
+// Output is one parseable line per dimension:
+// requests= ms= req_per_s= us_per_req=
+//
+// Numbers are only comparable on the same hardware.
+
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use arc_swap::ArcSwap;
+use http_body_util::{BodyExt, Empty};
+use hyper::body::Bytes;
+use hyper::Request;
+
+use http_nu::commands::{MjCommand, PrintCommand, StaticCommand, ToSse};
+use http_nu::handler::{handle, AppConfig};
+use http_nu::Engine;
+
+const N: usize = 10_000;
+
+fn report(name: &str, n: usize, elapsed: Duration) {
+ let ms = elapsed.as_secs_f64() * 1e3;
+ let rate = n as f64 / elapsed.as_secs_f64();
+ let us = elapsed.as_secs_f64() * 1e6 / n as f64;
+ println!("{name} requests={n} ms={ms:.0} req_per_s={rate:.0} us_per_req={us:.2}");
+}
+
+fn hello_engine() -> Engine {
+ let mut engine = Engine::new().unwrap();
+ engine
+ .add_commands(vec![
+ Box::new(StaticCommand::new()),
+ Box::new(ToSse {}),
+ Box::new(MjCommand::new()),
+ Box::new(PrintCommand::new()),
+ ])
+ .unwrap();
+ engine
+ .set_http_nu_const(&http_nu::engine::HttpNuOptions::default())
+ .unwrap();
+ engine
+ .parse_closure(r#"{|req| "hello world" }"#, None)
+ .unwrap();
+ engine
+}
+
+fn config() -> Arc {
+ Arc::new(AppConfig {
+ trusted_proxies: vec![],
+ datastar: false,
+ dev: false,
+ })
+}
+
+async fn one_request(engine: Arc>, config: Arc) {
+ let req = Request::builder()
+ .method("GET")
+ .uri("/")
+ .body(Empty::::new())
+ .unwrap();
+ let resp = handle(engine, None, config, req).await.unwrap();
+ assert_eq!(resp.status(), 200);
+ let body = resp.into_body().collect().await.unwrap().to_bytes();
+ assert_eq!(&body[..], b"hello world");
+}
+
+async fn bench_hello(engine: Arc>, config: Arc) {
+ let start = Instant::now();
+ for _ in 0..N {
+ one_request(engine.clone(), config.clone()).await;
+ }
+ report("hello", N, start.elapsed());
+}
+
+async fn bench_hello_concurrent(engine: Arc>, config: Arc) {
+ const IN_FLIGHT: usize = 16;
+ let start = Instant::now();
+ let mut handles = Vec::with_capacity(IN_FLIGHT);
+ for _ in 0..IN_FLIGHT {
+ let engine = engine.clone();
+ let config = config.clone();
+ handles.push(tokio::spawn(async move {
+ for _ in 0..N / IN_FLIGHT {
+ one_request(engine.clone(), config.clone()).await;
+ }
+ }));
+ }
+ for h in handles {
+ h.await.unwrap();
+ }
+ report(
+ "hello-concurrent",
+ N / IN_FLIGHT * IN_FLIGHT,
+ start.elapsed(),
+ );
+}
+
+fn main() {
+ let engine = Arc::new(ArcSwap::from_pointee(hello_engine()));
+ let config = config();
+
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ rt.block_on(async {
+ // warm up: first eval pays one-time parse/compile costs
+ one_request(engine.clone(), config.clone()).await;
+ bench_hello(engine.clone(), config.clone()).await;
+ bench_hello_concurrent(engine, config).await;
+ });
+}
diff --git a/examples/2048/static/stellar.css b/examples/2048/static/stellar.css
new file mode 120000
index 0000000..0c98355
--- /dev/null
+++ b/examples/2048/static/stellar.css
@@ -0,0 +1 @@
+../../../www-next-gen/assets/stellar.css
\ No newline at end of file
diff --git a/examples/2048/static/styles.css b/examples/2048/static/styles.css
index c92484f..a768577 100644
--- a/examples/2048/static/styles.css
+++ b/examples/2048/static/styles.css
@@ -1,3 +1,7 @@
+/* Shared Stellar design tokens (symlinked from http-nu/www-next-gen). Must
+ precede the @font-face rules below per the CSS @import ordering rule. */
+@import "stellar.css";
+
/* Self-hosted Source Sans 3 + Source Code Pro. One variable-axis woff2
per family; both 400 and 700 declarations point at the same file and
the browser picks the weight off the wght axis. Latin + smart-quote
@@ -37,18 +41,17 @@
}
:root {
- /* Palette aligned with http-nu/www: deep blue body, warm-cream headers,
- off-white primary text. Tile colors live in render.nu (untouched --
- the wood-grain palette reads fine against blue). */
- --bg: #0077b6;
+ /* Brand colors via the shared Stellar named tokens (stellar.css above).
+ Tile colors still live in render.nu (untouched). */
+ --bg: var(--named-ocean-0);
--fg: rgba(255, 255, 255, 0.85);
- --fg-header: #f4d9a0;
+ --fg-header: var(--named-sand-0);
--tile: #eee4da;
- --accent: #f59563;
- --accent-hover: #f67c5f;
- --accent-press: #d97a45;
- --brand: #00d4ff; /* cyan from http-nu/www -- the link/underline accent that pops on blue */
- --brand-hover: #5ae5ff;
+ --accent: var(--named-orange-0);
+ --accent-hover: var(--named-orange-1);
+ --accent-press: var(--named-orange--1);
+ --brand: var(--named-stream-0);
+ --brand-hover: var(--named-stream-1);
--light: #f9f6f2;
--gap: 8px;
--radius: 4px;
diff --git a/src/engine.rs b/src/engine.rs
index c5ffccc..4dcbc07 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -9,7 +9,7 @@ use nu_command::add_shell_command_context;
use nu_engine::eval_block_with_early_return;
use nu_parser::parse;
use nu_plugin_engine::{GetPlugin, PluginDeclaration};
-use nu_protocol::engine::Command;
+use nu_protocol::engine::{Command, Job, ThreadJob};
use nu_protocol::format_cli_error;
use nu_protocol::{
debugger::WithoutDebug,
@@ -66,6 +66,22 @@ impl Engine {
let init_cwd = std::env::current_dir()?;
gather_parent_env_vars(&mut engine_state, init_cwd.as_ref());
+ // One long-lived background job shared by every request eval. Request
+ // threads need a current job so externals they spawn are tracked and
+ // signalled; sharing one (the pid list is mutex'd) means the hot path
+ // never clones the engine state or touches the jobs table per request.
+ let (sender, _receiver) = std::sync::mpsc::channel();
+ let job = ThreadJob::new(
+ engine_state.signals().clone(),
+ Some("HTTP".to_string()),
+ sender,
+ );
+ {
+ let mut jobs = engine_state.jobs.lock().expect("jobs mutex poisoned");
+ jobs.add_job(Job::Thread(job.clone()));
+ }
+ engine_state.current_job.background_thread_job = Some(job);
+
Ok(Self {
state: engine_state,
closure: None,
@@ -374,9 +390,7 @@ impl Engine {
};
let res = xs::nu::add_core_commands(&mut xe, store)
.and_then(|()| xs::nu::add_read_commands(&mut xe, store, xs::nu::ReadMode::Stream))
- .and_then(|()| {
- xs::nu::add_write_commands(&mut xe, store, xs::nu::AppendMode::Direct)
- });
+ .and_then(|()| xs::nu::add_write_commands(&mut xe, store, xs::nu::AppendMode::Direct));
self.state = xe.state;
res.map_err(|e| Error::from(e.to_string()))
}
diff --git a/src/worker.rs b/src/worker.rs
index 1e48d0b..4d0f6fb 100644
--- a/src/worker.rs
+++ b/src/worker.rs
@@ -5,14 +5,68 @@ use crate::response::{
extract_http_response_meta, value_to_bytes, value_to_json, HttpResponseMeta, Response,
ResponseTransport,
};
-use nu_protocol::{
- engine::{Job, StateWorkingSet, ThreadJob},
- format_cli_error, PipelineData, Value,
-};
+use nu_protocol::{engine::StateWorkingSet, format_cli_error, PipelineData, Value};
use std::io::Read;
-use std::sync::{mpsc, Arc};
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::{Arc, Mutex, OnceLock};
use tokio::sync::{mpsc as tokio_mpsc, oneshot};
+type EvalJob = Box;
+
+/// Fixed pool of eval threads for the request hot path. Spawning a thread per
+/// request cost ~10us; reusing workers removes that. Plain-value responses
+/// complete on the worker in microseconds. Streaming responses (SSE, byte
+/// streams) hand their drain loop to a freshly spawned thread -- amortized
+/// over the connection's lifetime -- so a long-lived stream never pins a
+/// worker. If every worker is busy (slow, blocking evals), execute falls back
+/// to thread-per-request, so the pool can never starve the server.
+struct EvalPool {
+ tx: std::sync::mpsc::Sender,
+ idle: Arc,
+}
+
+static EVAL_POOL: OnceLock = OnceLock::new();
+
+fn eval_pool() -> &'static EvalPool {
+ EVAL_POOL.get_or_init(|| {
+ let (tx, rx) = std::sync::mpsc::channel::();
+ let rx = Arc::new(Mutex::new(rx));
+ let idle = Arc::new(AtomicUsize::new(0));
+ let workers = std::thread::available_parallelism()
+ .map(|n| n.get())
+ .unwrap_or(4);
+ for i in 0..workers {
+ let rx = rx.clone();
+ let idle = idle.clone();
+ std::thread::Builder::new()
+ .name(format!("eval-{i}"))
+ .spawn(move || loop {
+ idle.fetch_add(1, Ordering::Relaxed);
+ let job = rx.lock().expect("eval pool rx poisoned").recv();
+ idle.fetch_sub(1, Ordering::Relaxed);
+ match job {
+ Ok(job) => job(),
+ Err(_) => break,
+ }
+ })
+ .expect("failed to spawn eval worker");
+ }
+ EvalPool { tx, idle }
+ })
+}
+
+impl EvalPool {
+ fn execute(&self, job: EvalJob) {
+ if self.idle.load(Ordering::Relaxed) == 0 {
+ std::thread::spawn(job);
+ return;
+ }
+ if let Err(send_err) = self.tx.send(job) {
+ std::thread::spawn(send_err.0);
+ }
+ }
+}
+
/// Check if a value is a record without __html field
fn is_jsonl_record(value: &Value) -> bool {
matches!(value, Value::Record { val, .. } if val.get("__html").is_none())
@@ -123,57 +177,66 @@ pub fn spawn_eval_thread(
let (stream_tx, stream_rx) = tokio_mpsc::channel(32);
let mut iter = stream.into_inner();
- // Peek first value to determine mode
- let first = iter.next();
- let use_jsonl = first.as_ref().is_some_and(is_jsonl_record);
- let content_type = if use_jsonl {
- Some("application/x-ndjson".to_string())
- } else {
- inferred_content_type
- };
-
- let _ = body_tx.send((
- content_type,
- http_meta,
- ResponseTransport::Stream(stream_rx),
- ));
-
- // Helper to send a value
- let send_value = |stream_tx: &tokio_mpsc::Sender>, value: Value| -> bool {
- let bytes = if use_jsonl {
- let mut line =
- serde_json::to_vec(&value_to_json(&value)).unwrap_or_default();
- line.push(b'\n');
- line
+ // Everything from the first-value peek on runs handler code:
+ // the stream is lazy, and for a follow-style SSE handler even
+ // the *first* value can block for minutes. Hand the whole
+ // thing -- peek, response send, drain loop -- to a dedicated
+ // thread so it never pins a pool worker. The client sees the
+ // same timing as before: the response starts once the first
+ // value (and with it the content-type) is known.
+ std::thread::spawn(move || {
+ let first = iter.next();
+ let use_jsonl = first.as_ref().is_some_and(is_jsonl_record);
+ let content_type = if use_jsonl {
+ Some("application/x-ndjson".to_string())
} else {
- value_to_bytes(value)
+ inferred_content_type
};
- stream_tx.blocking_send(bytes).is_ok()
- };
- // Process first value
- if let Some(value) = first {
- if let Value::Error { error, .. } = &value {
- let working_set = StateWorkingSet::new(&engine.state);
- log_error(&format_cli_error(None, &working_set, error.as_ref(), None));
- return Ok(());
- }
- if !send_value(&stream_tx, value) {
- return Ok(());
- }
- }
+ let _ = body_tx.send((
+ content_type,
+ http_meta,
+ ResponseTransport::Stream(stream_rx),
+ ));
+
+ // Helper to send a value
+ let send_value =
+ |stream_tx: &tokio_mpsc::Sender>, value: Value| -> bool {
+ let bytes = if use_jsonl {
+ let mut line =
+ serde_json::to_vec(&value_to_json(&value)).unwrap_or_default();
+ line.push(b'\n');
+ line
+ } else {
+ value_to_bytes(value)
+ };
+ stream_tx.blocking_send(bytes).is_ok()
+ };
- // Process remaining values
- for value in iter {
- if let Value::Error { error, .. } = &value {
- let working_set = StateWorkingSet::new(&engine.state);
- log_error(&format_cli_error(None, &working_set, error.as_ref(), None));
- break;
+ // Process first value
+ if let Some(value) = first {
+ if let Value::Error { error, .. } = &value {
+ let working_set = StateWorkingSet::new(&engine.state);
+ log_error(&format_cli_error(None, &working_set, error.as_ref(), None));
+ return;
+ }
+ if !send_value(&stream_tx, value) {
+ return;
+ }
}
- if !send_value(&stream_tx, value) {
- break;
+
+ // Process remaining values
+ for value in iter {
+ if let Value::Error { error, .. } = &value {
+ let working_set = StateWorkingSet::new(&engine.state);
+ log_error(&format_cli_error(None, &working_set, error.as_ref(), None));
+ break;
+ }
+ if !send_value(&stream_tx, value) {
+ break;
+ }
}
- }
+ });
Ok(())
}
PipelineData::ByteStream(stream, meta) => {
@@ -191,61 +254,60 @@ pub fn spawn_eval_thread(
let mut reader = stream
.reader()
.ok_or_else(|| "ByteStream has no reader".to_string())?;
- let mut buf = vec![0; 8192];
- loop {
- match reader.read(&mut buf) {
- Ok(0) => break, // EOF
- Ok(n) => {
- if stream_tx.blocking_send(buf[..n].to_vec()).is_err() {
- break;
+ // Drain on a dedicated thread; see the ListStream arm.
+ std::thread::spawn(move || {
+ let mut buf = vec![0; 8192];
+ loop {
+ match reader.read(&mut buf) {
+ Ok(0) => break, // EOF
+ Ok(n) => {
+ if stream_tx.blocking_send(buf[..n].to_vec()).is_err() {
+ break;
+ }
}
- }
- Err(err) => {
- // Try to extract ShellError from the io::Error for proper formatting
- use nu_protocol::shell_error::bridge::ShellErrorBridge;
- if let Some(bridge) = err
- .get_ref()
- .and_then(|e| e.downcast_ref::())
- {
- let working_set = StateWorkingSet::new(&engine.state);
- log_error(&format_cli_error(None, &working_set, &bridge.0, None));
- break; // Error already logged, just stop streaming
+ Err(err) => {
+ // Try to extract ShellError from the io::Error for proper formatting
+ use nu_protocol::shell_error::bridge::ShellErrorBridge;
+ if let Some(bridge) = err
+ .get_ref()
+ .and_then(|e| e.downcast_ref::())
+ {
+ let working_set = StateWorkingSet::new(&engine.state);
+ log_error(&format_cli_error(
+ None,
+ &working_set,
+ &bridge.0,
+ None,
+ ));
+ } else {
+ log_error(&err.to_string());
+ }
+ break;
}
- return Err(err.into());
}
}
- }
+ });
Ok(())
}
}
}
- // Create a thread job for this evaluation
- let (sender, _receiver) = mpsc::channel();
- let signals = engine.state.signals().clone();
- let job = ThreadJob::new(signals, Some("HTTP Request".to_string()), sender);
-
- // Add the job to the engine's job table
- let job_id = {
- let mut jobs = engine.state.jobs.lock().expect("jobs mutex poisoned");
- jobs.add_job(Job::Thread(job.clone()))
- };
-
- std::thread::spawn(move || -> Result<(), std::convert::Infallible> {
+ eval_pool().execute(Box::new(move || {
let mut meta_tx_opt = Some(meta_tx);
let mut body_tx_opt = Some(body_tx);
// Wrap the evaluation in catch_unwind so that panics don't poison the
// async runtime and we can still send a response back to the caller.
+ //
+ // The engine is used as-is: run_closure never mutates the state, and
+ // the engine carries a long-lived background job (attached at
+ // construction) so externals spawned by the handler are tracked.
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
- let mut local_engine = (*engine).clone();
- local_engine.state.current_job.background_thread_job = Some(job);
-
// Take the senders for the inner call. If the evaluation completes
// successfully, these senders will have been consumed. Otherwise we
// will use the remaining ones to send an error response.
inner(
- Arc::new(local_engine),
+ engine,
request,
stream,
meta_tx_opt.take().unwrap(),
@@ -276,15 +338,7 @@ pub fn spawn_eval_thread(
));
}
}
-
- // Clean up job when done
- {
- let mut jobs = engine.state.jobs.lock().expect("jobs mutex poisoned");
- jobs.remove_job(job_id);
- }
-
- Ok(())
- });
+ }));
(meta_rx, body_rx)
}
diff --git a/www-next-gen/.gitignore b/www-next-gen/.gitignore
new file mode 100644
index 0000000..8b5f1da
--- /dev/null
+++ b/www-next-gen/.gitignore
@@ -0,0 +1,8 @@
+# Stellar license key - never commit
+stellar.key
+
+# transient stellar gen output
+/css/
+
+# local cross.stream store, if used
+/store/
diff --git a/www-next-gen/README.md b/www-next-gen/README.md
new file mode 100644
index 0000000..4fa5710
--- /dev/null
+++ b/www-next-gen/README.md
@@ -0,0 +1,87 @@
+# www-next-gen
+
+A work-in-progress redesign of the http-nu site, served by http-nu itself.
+
+- The whole site rides a brand-blue surface generated from
+ [Stellar](https://github.com/starfederation/stellar) design tokens (ocean in
+ light, a deep navy in dark), with a light/dark toggle on every page.
+- The docs are the project [`README.md`](../README.md) split into navigable
+ pages at request time by `readme.nu` (via Nushell's `from md --verbose` AST),
+ so the README stays the single source of truth.
+
+## Run
+
+```bash
+http-nu --watch --datastar :3001 www-next-gen/www.nu
+```
+
+Then visit http://localhost:3001 (landing) and http://localhost:3001/docs.
+`--watch` hot-reloads the handler when files in this directory change.
+
+## Styling tiers
+
+Stellar custom properties sit at the base; everything draws from them.
+
+- `assets/stellar.css` - the generated token dump (see below).
+- `assets/base.css` - raw HTML elements (great typography with no classes),
+ then atomic utilities, then a small set of components.
+- `assets/brand.css` - the brand layer: the blue surface, the nav treatment,
+ and the landing hero. Colors come from stellar named-color seeds.
+
+## Regenerating stellar.css
+
+`stellar.css` is generated from `stellar.config.json`, which carries the design
+decisions: the www palette as named colors (ocean/navy/sand/orange/grape/red/
+green/stream), Source Sans 3 / Source Code Pro as the `sans` / `mono` families,
+and a `1.125` base-font multiplier (18px root). Do typography in the config, not
+in CSS overrides.
+
+The config is on the Stellar **v0.0.2** schema. The license key lives in
+`~/.config/stellar/stellar.key` (found automatically; never commit it).
+
+```bash
+cd www-next-gen
+stellar gen -i stellar.config.json # writes css/stellar.css
+mv css/stellar.css assets/stellar.css && rm -rf css
+```
+
+If `stellar` rejects the config (an older v1 schema), migrate it once first
+(idempotent): `python3 ~/understand-stellar/tools/migrate-config.py
+stellar.config.json`.
+
+## Fonts
+
+Both fonts are **self-hosted** `woff2` in `assets/`, declared with `@font-face`
+in `base.css` whose family names match the `--font-sans` / `--font-mono` tokens
+exactly. `settings.export.includeFontImports` is off, so `stellar.css` imports
+no CDN font either - the page makes zero third-party font requests. The
+`/assets/:file` route matches one path segment, so keep font files flat in
+`assets/`.
+
+- **Prose** (`--font-sans`): Source Sans 3, 400 / 700.
+- **Code** (`--font-mono`): **Iosevka X**, a custom no-ligature build, 400 / 700.
+ Chosen over Source Code Pro because it ships box-drawing glyphs, so Nushell
+ table output and ASCII line up (Source Code Pro lacked them, forcing a
+ mismatched-width fallback). Ligatures are off so readers see the literal
+ characters they would type.
+
+### Regenerating Iosevka X
+
+The build recipe is [`iosevka-x.build-plan.toml`](iosevka-x.build-plan.toml)
+(no ligatures, dotted zero, a few variant touches; regular + bold, upright,
+normal width). To rebuild:
+
+```bash
+git clone --depth 1 -b v34.6.3 https://github.com/be5invis/Iosevka.git
+cp iosevka-x.build-plan.toml Iosevka/private-build-plans.toml
+cd Iosevka && npm install && npm run build -- ttf::iosevka-X # needs ttfautohint
+```
+
+Then subset each weight to the web (keeps it ~40 KB instead of ~10 MB):
+
+```bash
+RANGES="U+0000-00FF,U+2010-2027,U+2030-205F,U+20AC,U+2190-21FF,U+2200-22FF,U+2500-259F,U+25A0-25FF"
+pyftsubset dist/iosevka-X/TTF/iosevka-X-Regular.ttf --unicodes="$RANGES" \
+ --layout-features='' --flavor=woff2 --output-file=assets/iosevka-x-400.woff2
+# repeat for Bold -> iosevka-x-700.woff2
+```
diff --git a/www-next-gen/assets/README.md b/www-next-gen/assets/README.md
new file mode 100644
index 0000000..bf3f023
--- /dev/null
+++ b/www-next-gen/assets/README.md
@@ -0,0 +1,100 @@
+# Code syntax highlighting
+
+How fenced code in the docs gets colored, and how to keep the
+TextMate-scope -> stellar-token mapping in `base.css` complete as languages
+change.
+
+## Pipeline
+
+`readme.nu` renders each page with `... | .md | get __html`. http-nu's `.md`
+highlights fenced code via TextMate grammars (the `.highlight` engine),
+emitting nested ``, where `` is a TextMate
+scope name flattened into space-separated classes - e.g.
+`class="keyword operator comparison nu"` or `class="support function builtin bash"`.
+
+## The mapping (base.css)
+
+The `pre . { color: var(--code-) }` rules map those scope classes
+to stellar's `--code-*` syntax tokens. CSS multi-class selectors
+(`.keyword.operator`, `.entity.name.struct`) mean the most-specific matching
+rule wins; anything unmatched inherits `pre .source` -> `--code-fg`.
+
+Stellar generates the `--code-*` palette (plus a coordinated dark variant) from
+`colors.code` in `stellar.config.json`; we only consume the tokens.
+
+## Documented languages
+
+The README uses three fenced languages: **bash**, **nushell**, **rust**. The
+mapping is audited against all three.
+
+## Re-running the audit
+
+1. Write an *exhaustive* sample per language covering every construct: comments
+ (line / block / doc), strings (single / double / interpolated / raw / byte /
+ char + escapes), numbers (int / float / hex / bin / oct / suffix), operators
+ (arithmetic, comparison, logical, assignment), keywords (control +
+ declaration), function calls, builtins / macros, variables / members /
+ params, types, structural punctuation, and a deliberately invalid token.
+
+2. Highlight each and capture the emitted scopes:
+
+ ```bash
+ http-nu eval -c '(open --raw sample.nu) | .highlight nu | get __html'
+ ```
+
+3. Cross-reference the emitted scopes against the CSS - resolve each scope to a
+ token (most-specific wins), and report scopes that fall through to
+ `--code-fg` plus `--code-*` tokens nothing uses:
+
+ ```python
+ import re
+ base = open("base.css").read()
+ mapped = []
+ for m in re.finditer(r'((?:pre \.[\w.-]+,\s*)*pre \.[\w.-]+)\s*\{\s*color:\s*var\((--code-[\w-]+)\)', base):
+ for sel in re.findall(r'pre (\.[\w.-]+)', m.group(1)):
+ mapped.append((frozenset(sel.strip(".").split(".")), m.group(2)))
+
+ def resolve(classes): # classes = a span's class list, minus the lang tag
+ cs = set(classes)
+ hits = [(len(req), tok) for req, tok in mapped if req <= cs]
+ return max(hits)[1] if hits else "--code-fg (fall-through)"
+ ```
+
+## Findings
+
+Every *meaningful* scope the three grammars emit maps to an appropriate token.
+The only fall-throughs to `--code-fg` are **structural wrappers** -
+`meta.braces / block / group / number`, `punctuation.section / separator` -
+which is correct: their inner tokens are already colored, so the wrapper should
+stay the default foreground (coloring it would just tint whitespace/brackets).
+
+Assignments added during the audit (the gaps rust/nu exposed):
+
+| scope | token |
+| --- | --- |
+| `entity.name.struct` / `trait` / `impl` | `--code-name-class` |
+| `entity.name.label` (loop labels) | `--code-name-label` |
+| `support.macro` (rust macros) | `--code-name-function` |
+| `constant.other` | `--code-name-constant` |
+| `constant.other.placeholder` (`{}` / `{name}`) | `--code-string-escape` |
+| `invalid` (bad identifiers) | `--code-error` |
+
+### Stellar code tokens we don't use
+
+These have no scope in bash / nushell / rust (they serve languages and
+constructs we don't document), so they are intentionally unused. They would
+light up if we added the relevant language:
+
+- markup / diff / markdown: `--code-emph`, `--code-strong`, `--code-deleted`,
+ `--code-inserted`, `--code-comment-special`
+- HTML / XML: `--code-name-tag`, `--code-name-attribute`, `--code-name-entity`
+- other languages: `--code-name-decorator` (Python), `--code-name-exception`,
+ `--code-name-namespace`, `--code-keyword-const` (nu booleans scope as builtins)
+- specialty strings: `--code-string-affix`, `--code-string-doc`,
+ `--code-string-regex`
+
+## Adding a language
+
+Add a fenced sample, re-run the audit, and map any new non-structural
+fall-throughs to the closest `--code-*` token. If a needed concept has no
+matching stellar token, that's a `colors.code` config question, not a CSS one.
diff --git a/www-next-gen/assets/base.css b/www-next-gen/assets/base.css
new file mode 100644
index 0000000..b3e7a80
--- /dev/null
+++ b/www-next-gen/assets/base.css
@@ -0,0 +1,1050 @@
+/*
+ * base.css - semantic HTML on stellar variables.
+ *
+ * Layer 1 styles raw elements only, so plain semantic markup gets good
+ * typography with no classes. Layer 2 is a small set of utility classes
+ * for the bits element selectors cannot cover.
+ *
+ * Pairs with stellar.css (regenerate with: stellar gen, with stellar.key
+ * next to stellar.config.json). Light/dark is handled by stellar's
+ * :root.dark variable overrides; toggle the `dark` class on .
+ */
+
+/* ------------------------------------------------------------------ */
+/* MPA view transitions: opt into the browser's default cross-fade on */
+/* same-origin navigations (Themes -> Reference, etc). Progressive */
+/* enhancement - unsupported browsers just navigate instantly. */
+/* ------------------------------------------------------------------ */
+@view-transition {
+ navigation: auto;
+}
+/* the root cross-fade, tuned live by the dialog in vt-tuner.js via these custom
+ properties and classes on (persisted to localStorage). */
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation-duration: var(--vt-duration, var(--anim-duration-fast));
+ animation-timing-function: var(--vt-ease, var(--anim-ease-standard));
+}
+/* off: swap with no fade */
+:root.vt-off::view-transition-old(root),
+:root.vt-off::view-transition-new(root) {
+ animation: none;
+}
+/* fade-in only: hold the outgoing page, fade the new one in over it */
+:root.vt-fadein::view-transition-old(root) {
+ animation: none;
+}
+/* slide: the new page rises as it fades in */
+:root.vt-slide::view-transition-new(root) {
+ animation-name: vt-slide-in;
+}
+@keyframes vt-slide-in {
+ from { opacity: 0; transform: translateY(0.8rem); }
+ to { opacity: 1; transform: translateY(0); }
+}
+/* scope: name the nav and main so they are their own groups; the page chrome
+ stays put while only the content cross-fades. */
+:root.vt-scope .nav { view-transition-name: vt-nav; }
+:root.vt-scope main { view-transition-name: vt-main; }
+@media (prefers-reduced-motion: reduce) {
+ ::view-transition-old(root),
+ ::view-transition-new(root) {
+ animation-duration: 0s;
+ }
+}
+
+/* the tuner: launched from a toolbox button in the nav, opens this dialog. */
+.vt-toggle iconify-icon {
+ display: block;
+ transition: transform var(--anim-duration-base) var(--anim-ease-bounce);
+}
+.vt-toggle:hover iconify-icon { transform: rotate(-9deg) scale(1.14); }
+.vt-toggle[aria-pressed="true"] iconify-icon { transform: rotate(-6deg); }
+#vt-tuner {
+ border: var(--border-width-1) solid var(--neutral-5);
+ border-radius: var(--border-radius-2);
+ background: var(--surface);
+ color: var(--surface-on);
+ padding: var(--size-1) var(--size-2);
+ max-width: min(92vw, 24rem);
+ font-size: var(--font-size--1);
+}
+/* open as a non-modal panel docked to the right edge, so the page transition
+ stays visible behind it; named so it holds steady through the transition. */
+#vt-tuner[open] {
+ position: fixed;
+ inset: auto var(--size-1) auto auto;
+ top: 50%;
+ transform: translateY(-50%);
+ margin: 0;
+ max-height: 92vh;
+ overflow: auto;
+ view-transition-name: vt-panel;
+}
+#vt-tuner h2 { margin: 0 0 var(--size-0); font-size: var(--font-size-1); }
+.vt-note {
+ margin: calc(-1 * var(--size--2)) 0 var(--size-0);
+ max-width: 34ch;
+ font-size: var(--font-size--2);
+ opacity: 0.72;
+}
+.vt-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--size-1);
+ margin-block: var(--size-0);
+}
+.vt-row input[type="range"] { flex: 1; }
+.vt-row select,
+.vt-row input,
+#vt-tuner button { font: inherit; }
+.vt-hint { margin: var(--size-1) 0 var(--size-0); opacity: 0.7; }
+.vt-tryto { display: flex; gap: var(--size-1); flex-wrap: wrap; }
+.vt-tryto a {
+ text-decoration: none;
+ border: var(--border-width-1) solid var(--neutral-5);
+ border-radius: var(--border-radius-1);
+ padding: 0.1em 0.7em;
+ color: inherit;
+}
+.vt-tryto a:hover { background: var(--named-ocean-1); }
+.vt-actions { display: flex; gap: var(--size-1); justify-content: flex-end; margin-top: var(--size-1); }
+
+/* ------------------------------------------------------------------ */
+/* Fonts: self-hosted, names must match the stellar --font-* tokens. */
+/* Source Sans 3 for prose (--font-sans); Iosevka X for code */
+/* (--font-mono) - a custom no-ligature build with box-drawing glyphs, */
+/* so Nushell tables and ASCII line up (Source Code Pro lacked them). */
+/* 400 + 700 only; subset to Latin + punctuation + box/blocks/shapes. */
+/* ------------------------------------------------------------------ */
+
+@font-face {
+ font-family: "Source Sans 3";
+ src: url("/assets/source-sans-3-400.woff2") format("woff2");
+ font-weight: 400;
+ font-display: swap;
+}
+@font-face {
+ font-family: "Source Sans 3";
+ src: url("/assets/source-sans-3-700.woff2") format("woff2");
+ font-weight: 700;
+ font-display: swap;
+}
+@font-face {
+ font-family: "Iosevka X";
+ src: url("/assets/iosevka-x-400.woff2") format("woff2");
+ font-weight: 400;
+ font-display: swap;
+}
+@font-face {
+ font-family: "Iosevka X";
+ src: url("/assets/iosevka-x-700.woff2") format("woff2");
+ font-weight: 700;
+ font-display: swap;
+}
+
+/* ------------------------------------------------------------------ */
+/* Layer 1: elements */
+/* ------------------------------------------------------------------ */
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ color-scheme: light;
+}
+
+html.dark {
+ color-scheme: dark;
+}
+
+body {
+ margin: 0;
+ font-family: var(--font-sans);
+ font-size: var(--font-size-0);
+ line-height: var(--font-line-height-2);
+ background: var(--neutral-1);
+ color: var(--neutral-1-on);
+ -webkit-text-size-adjust: 100%;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ color: inherit;
+ line-height: var(--font-line-height-0);
+ text-wrap: balance;
+}
+
+h1 {
+ font-size: var(--font-size-5);
+ font-weight: var(--font-weight-bold);
+ letter-spacing: var(--font-letter-spacing--1);
+ margin: var(--size-3) 0 var(--size-1);
+}
+
+h2 {
+ font-size: var(--font-size-3);
+ font-weight: var(--font-weight-semi-bold);
+ margin: var(--size-3) 0 var(--size-1);
+}
+
+h3 {
+ font-size: var(--font-size-2);
+ font-weight: var(--font-weight-semi-bold);
+ margin: var(--size-2) 0 var(--size-0);
+}
+
+h4 {
+ font-size: var(--font-size-1);
+ font-weight: var(--font-weight-semi-bold);
+ margin: var(--size-1) 0 var(--size-0);
+}
+
+h5,
+h6 {
+ font-size: var(--font-size-0);
+ font-weight: var(--font-weight-semi-bold);
+ margin: var(--size-1) 0 var(--size-0);
+}
+
+/* Headings that are themselves links read as headings (no underline); they
+ inherit the heading color from the core `a` rule below. */
+h1 a,
+h2 a,
+h3 a,
+h4 a,
+h5 a,
+h6 a {
+ text-decoration: none;
+}
+
+h1 a:hover,
+h2 a:hover,
+h3 a:hover,
+h4 a:hover,
+h5 a:hover,
+h6 a:hover {
+ text-decoration: underline;
+}
+
+p {
+ margin: var(--size--2) 0;
+}
+
+/* a paragraph that introduces a list hugs it, so a "label:" + its list read as
+ one unit. 0.4em is a justified literal - below the stellar size floor. */
+p:has(+ ul),
+p:has(+ ol) {
+ margin-bottom: 0.4em;
+}
+p + ul,
+p + ol {
+ margin-top: 0;
+}
+
+/* links ride the surrounding text color, set apart by their underline; this
+ works on any surface (neutral or a brand blue), so no per-surface color */
+a {
+ color: inherit;
+ text-underline-offset: 0.15em;
+ /* the font's own (thin) underline metric; border-width tokens are too heavy
+ for a text underline. Thickens to a token on hover. */
+ text-decoration-thickness: from-font;
+}
+
+a:hover {
+ text-decoration-thickness: var(--border-width-1);
+}
+
+strong {
+ font-weight: var(--font-weight-bold);
+}
+
+small {
+ font-size: var(--font-size--1);
+ color: var(--neutral-1-dim);
+}
+
+mark {
+ background: var(--tertiary-4);
+ color: var(--tertiary-4-on);
+}
+
+code,
+kbd,
+samp {
+ font-family: var(--font-mono);
+ font-size: 0.9em;
+}
+
+code {
+ background: var(--code-bg);
+ color: var(--code-fg);
+ /* justified literal: stellar's spacing scale is viewport-fluid and floors at
+ ~13px, too wide for an inline chip, so it can't express tight text-relative
+ padding. em keeps it proportional to the text; the vertical stays small so
+ the chip doesn't blow out the prose line-height. */
+ padding: 0.2em 0.45em;
+ border-radius: var(--border-radius-1);
+ /* keep short code spans whole - don't break a token like --topic at its hyphen */
+ white-space: nowrap;
+}
+
+kbd {
+ background: var(--neutral-3);
+ color: var(--neutral-3-on);
+ border: var(--border-width-1) solid var(--neutral-5);
+ border-bottom-width: var(--border-width-2);
+ border-radius: var(--border-radius-1);
+ padding: var(--size--2) var(--size--1);
+}
+
+/* code line-height: a tight terminal ratio so stacked box-drawing glyphs
+ (Nushell tables) join into continuous lines. A unitless literal on purpose -
+ stellar's line-heights are absolute rems tuned for prose (~1.5 on the code
+ font), too loose to connect box-drawing; this is the named exception. */
+:root {
+ --code-line-height: 1.25;
+}
+
+pre {
+ font-family: var(--font-mono);
+ font-size: var(--font-size--1);
+ line-height: var(--code-line-height);
+ background: var(--code-bg);
+ color: var(--code-fg);
+ border: var(--border-width-1) solid var(--code-border);
+ border-radius: var(--border-radius-1);
+ padding: var(--size--2) var(--size--1);
+ overflow-x: auto;
+ scrollbar-gutter: stable;
+ margin: var(--size--1) 0;
+}
+
+pre code {
+ background: none;
+ border-radius: 0;
+ padding: 0;
+ color: inherit;
+ font-size: inherit;
+ /* block code keeps the pre's preserved newlines; the inline `code` nowrap
+ must not collapse a fenced block onto one line */
+ white-space: pre;
+}
+
+ul,
+ol {
+ margin: var(--size--2) 0;
+ padding-left: var(--size-3);
+ line-height: var(--font-line-height-0);
+}
+
+li {
+ /* tight item spacing; --size--2 (the floor, ~13px) is far too padded for a
+ list. em-relative, justified literal like the other tight component gaps. */
+ margin: 0.35em 0;
+}
+
+li > ul,
+li > ol {
+ margin: var(--size--2) 0;
+}
+
+dl {
+ margin: var(--size-0) 0;
+}
+
+dt {
+ font-weight: var(--font-weight-semi-bold);
+}
+
+dd {
+ margin: 0 0 var(--size--1) var(--size-2);
+}
+
+blockquote {
+ margin: var(--size-1) 0;
+ padding: var(--size--2) var(--size-2);
+ border-left: var(--border-width-3) solid var(--neutral-4);
+ color: var(--neutral-1-dim);
+}
+
+blockquote > :first-child {
+ margin-top: 0;
+}
+
+blockquote > :last-child {
+ margin-bottom: 0;
+}
+
+table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: var(--size-1) 0;
+}
+
+th {
+ background: var(--neutral-3);
+ color: var(--neutral-3-on);
+ font-weight: var(--font-weight-semi-bold);
+ text-align: left;
+}
+
+th,
+td {
+ /* tight fixed cell padding: stellar's size scale floors at ~13px and is
+ viewport-fluid (layout spacing), too loose for compact table cells - the
+ same justified-literal case as inline code. */
+ padding: 0.2rem 0.55rem;
+ border: var(--border-width-1) solid var(--neutral-4);
+}
+
+hr {
+ border: none;
+ border-top: var(--border-width-1) solid var(--neutral-4);
+ margin: var(--size-2) 0;
+}
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+img {
+ border-radius: var(--border-radius-1);
+}
+
+/* long-form content column: cap the reading measure (was the .prose utility) */
+article {
+ max-width: 99ch;
+}
+
+figure {
+ margin: var(--size-1) 0;
+}
+
+figcaption {
+ font-size: var(--font-size--1);
+ color: var(--neutral-1-dim);
+ text-align: center;
+ margin-top: var(--size--2);
+}
+
+details {
+ margin: var(--size-0) 0;
+ border: var(--border-width-1) solid var(--neutral-4);
+ border-radius: var(--border-radius-1);
+ padding: var(--size--1) var(--size-0);
+}
+
+summary {
+ cursor: pointer;
+ font-weight: var(--font-weight-semi-bold);
+}
+
+/* one button: a mono, currentColor-outlined action that reads on any surface.
+ variants .btn-go (lit primary) and .btn-tab (segmented) live in brand.css. */
+button {
+ -webkit-appearance: none;
+ appearance: none;
+ font-family: var(--font-mono);
+ font-size: var(--font-size--1);
+ color: inherit;
+ background: transparent;
+ border: var(--border-width-1) solid color-mix(in oklch, currentColor 35%, transparent);
+ border-radius: var(--border-radius-1);
+ padding: 0.1em 0.6em;
+ cursor: pointer;
+ transition: background var(--anim-duration-fast) var(--anim-ease-standard),
+ border-color var(--anim-duration-fast) var(--anim-ease-standard);
+}
+button:hover {
+ background: color-mix(in oklch, currentColor 12%, transparent);
+ border-color: currentColor;
+}
+button:active { transform: translateY(1px); }
+
+input,
+select,
+textarea {
+ font: inherit;
+ color: var(--neutral-2-on);
+ background: var(--neutral-2);
+ border: var(--border-width-1) solid var(--neutral-5);
+ border-radius: var(--border-radius-1);
+ padding: var(--size--2) var(--size-0);
+}
+
+:focus-visible {
+ outline: var(--border-width-2) solid var(--primary-9);
+ outline-offset: 2px;
+}
+
+/* Syntax tokens emitted by .highlight / .md code blocks. */
+
+pre .source {
+ color: var(--code-fg);
+}
+
+pre .comment {
+ color: var(--code-comment);
+}
+
+pre .string {
+ color: var(--code-string);
+}
+
+pre .constant.numeric {
+ color: var(--code-number);
+}
+
+pre .constant.language {
+ color: var(--code-keyword-const);
+}
+
+pre .constant.character {
+ color: var(--code-string-escape);
+}
+
+pre .keyword {
+ color: var(--code-keyword);
+}
+
+pre .keyword.operator {
+ color: var(--code-operator);
+}
+
+pre .storage,
+pre .storage.type {
+ color: var(--code-keyword-decl);
+}
+
+pre .entity.name.function {
+ color: var(--code-name-function);
+}
+
+pre .entity.name.type,
+pre .entity.name.class {
+ color: var(--code-name-class);
+}
+
+pre .entity.name.tag {
+ color: var(--code-name-tag);
+}
+
+pre .entity.other.attribute-name {
+ color: var(--code-name-attribute);
+}
+
+pre .variable {
+ color: var(--code-name-variable);
+}
+
+pre .variable.other.member {
+ color: var(--code-name-property);
+}
+
+pre .variable.parameter.option {
+ color: var(--code-name-label);
+}
+
+pre .support.function {
+ color: var(--code-name-builtin);
+}
+
+pre .support.function.keywords {
+ color: var(--code-keyword-namespace);
+}
+
+pre .support.type,
+pre .support.class {
+ color: var(--code-type);
+}
+
+pre .punctuation {
+ color: var(--code-fg);
+}
+
+pre .punctuation.definition.string {
+ color: var(--code-string-delim);
+}
+
+pre .punctuation.definition.comment {
+ color: var(--code-comment);
+}
+
+pre .meta.function-call {
+ color: var(--code-name);
+}
+
+pre .meta.string {
+ color: var(--code-string);
+}
+
+/* type-like definitions (rust struct/trait/impl) */
+pre .entity.name.struct,
+pre .entity.name.trait,
+pre .entity.name.impl {
+ color: var(--code-name-class);
+}
+
+pre .entity.name.label {
+ color: var(--code-name-label);
+}
+
+/* rust macros invoke like functions */
+pre .support.macro {
+ color: var(--code-name-function);
+}
+
+pre .constant.other {
+ color: var(--code-name-constant);
+}
+
+/* format-string placeholders ({} / {name}) read as special string content */
+pre .constant.other.placeholder {
+ color: var(--code-string-escape);
+}
+
+/* syntax errors (e.g. invalid variable names) */
+pre .invalid {
+ color: var(--code-error);
+}
+
+/* ------------------------------------------------------------------ */
+/* Layer 2: utilities (atomic, single purpose, composed in markup) */
+/* ------------------------------------------------------------------ */
+
+.container { width: 100%; max-width: 1200px; margin-inline: auto; padding-inline: var(--size-2); }
+.center { text-align: center; }
+.muted { color: var(--neutral-1-dim); }
+.mx-auto { margin-inline: auto; }
+.upper { text-transform: uppercase; letter-spacing: var(--font-letter-spacing-1); }
+.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--size-2); }
+
+/* ------------------------------------------------------------------ */
+/* Layer 3: components (a small, composable set) */
+/* ------------------------------------------------------------------ */
+
+/* nav: brand left, links right; colors inherit from the surrounding context */
+.nav {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--size-1);
+ max-width: 1200px;
+ margin-inline: auto;
+ padding: var(--size-1) var(--size-2);
+}
+
+.nav a { text-decoration: none; }
+.nav a:hover { text-decoration: underline; }
+.nav .brand { font-weight: var(--font-weight-bold); }
+.nav .links { display: flex; align-items: center; gap: var(--size-2); }
+
+/* structure only; the surface comes from a `.panel` utility composed in markup */
+.card {
+ display: block;
+ border: var(--border-width-1) solid var(--neutral-4);
+ border-radius: var(--border-radius-2);
+ padding: var(--size-1);
+ box-shadow: var(--shadow-2);
+ text-decoration: none;
+ transition-property: transform, box-shadow, background;
+ transition-duration: var(--anim-duration-fast);
+ transition-timing-function: var(--anim-ease-standard);
+}
+
+/* sub-text inside a card dims the card's own color, not the page surface */
+.card :is(small, .muted) {
+ color: inherit;
+ opacity: 0.7;
+}
+
+/* sidebar layout: a flex row that stacks on small screens */
+.with-sidebar { display: flex; gap: var(--size-3); align-items: flex-start; }
+.with-sidebar > * { min-width: 0; }
+
+/* docs menu: a sticky sidebar on desktop, a collapsible disclosure on narrow
+ screens, the page list collapses behind a toggle driven by a Datastar `nav`
+ signal (the toggle button only shows on mobile; the sidebar itself is the
+ `.docs-side` nav). */
+.docs-toggle {
+ font: inherit;
+ font-weight: var(--font-weight-semi-bold);
+ background: none;
+ border: none;
+ border-bottom: var(--border-width-1) solid var(--line, var(--neutral-4));
+ border-radius: 0;
+ color: inherit;
+ cursor: pointer;
+ width: 100%;
+ text-align: left;
+ padding: var(--size--1) 0;
+}
+.docs-toggle:hover { background: none; }
+.docs-toggle::after { content: " \25be"; }
+.docs-menu.open .docs-toggle::after { content: " \25b4"; }
+
+/* 940px: below this, the sidebar would squeeze the reading column under 72ch,
+ so collapse it to the toggle and give the article the full width. */
+@media (min-width: 941px) {
+ .docs-menu {
+ flex-shrink: 0;
+ width: 210px;
+ position: sticky;
+ top: var(--size-2);
+ max-height: calc(100vh - var(--size-3));
+ overflow-y: auto;
+ }
+ .docs-toggle { display: none; }
+}
+
+@media (max-width: 940px) {
+ /* stretch (not flex-start) so the stacked article fills the column and
+ shrinks to the viewport; wide code then scrolls inside its own block
+ instead of pushing the page wider */
+ .with-sidebar { flex-direction: column; align-items: stretch; }
+ .docs-menu { width: 100%; }
+ .docs-side { display: none; }
+ .docs-menu.open .docs-side { display: block; }
+}
+
+/* toc: nav list with group labels and an active item */
+.toc ul { list-style: none; padding-left: 0; margin: 0; }
+.toc ul ul { padding-left: var(--size-1); }
+.toc li { margin: var(--size--2) 0; }
+.toc a { color: var(--neutral-1-dim); text-decoration: none; }
+.toc a:hover { color: var(--neutral-1-on); text-decoration: underline; }
+.toc a.active { color: var(--neutral-1-on); font-weight: var(--font-weight-semi-bold); }
+.toc-group {
+ display: block;
+ font-size: var(--font-size--1);
+ font-weight: var(--font-weight-semi-bold);
+ text-transform: uppercase;
+ letter-spacing: var(--font-letter-spacing-1);
+ color: var(--neutral-1-dim);
+ margin: var(--size-1) 0 var(--size--2);
+}
+
+/* pager: prev / next cards beneath a doc page. Two columns so the next card
+ stays right even when there is no prev; each is a panel that lifts on hover. */
+.pager {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--size-1);
+ margin-top: var(--size-3);
+}
+.pager-link {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15em;
+ /* compact; the stellar size floor is too padded for a small nav card */
+ padding: 0.5rem 0.85rem;
+ border: var(--border-width-1) solid var(--neutral-4);
+ border-radius: var(--border-radius-1);
+ text-decoration: none;
+ transition-property: transform, box-shadow, background;
+ transition-duration: var(--anim-duration-fast);
+ transition-timing-function: var(--anim-ease-standard);
+}
+.pager-link:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-3);
+}
+.pager-prev {
+ grid-column: 1;
+ align-items: flex-start;
+}
+.pager-next {
+ grid-column: 2;
+ align-items: flex-end;
+ text-align: right;
+}
+.pager-dir {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3em;
+ font-size: var(--font-size--1);
+ text-transform: uppercase;
+ letter-spacing: var(--font-letter-spacing-1);
+}
+.pager-page {
+ font-weight: var(--font-weight-semi-bold);
+}
+
+/* code block with a copy button (markup from inject-copy-btns) */
+.code-block { position: relative; }
+.copy-btn {
+ position: absolute;
+ top: var(--size--1);
+ right: var(--size--1);
+ background: var(--code-bg);
+ border: var(--border-width-1) solid var(--code-border);
+ color: var(--code-fg);
+ cursor: pointer;
+ opacity: 0;
+ padding: 0.35rem;
+ border-radius: var(--border-radius-1);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ z-index: 1;
+}
+
+.code-block:hover .copy-btn {
+ opacity: 0.7;
+ transition: opacity var(--anim-duration-fast) var(--anim-ease-standard);
+}
+
+.copy-btn:hover {
+ opacity: 1;
+ background: var(--primary-6);
+ color: var(--primary-6-on);
+}
+
+/* --- theme namespace: facet nav, playground, chips ------------------ */
+
+/* facet switcher (Overview | Reference | Example) under the theme title */
+.facets {
+ display: flex;
+ gap: var(--size-1);
+ margin: var(--size--1) 0 var(--size-2);
+ border-bottom: var(--border-width-1) solid var(--neutral-4);
+}
+.facets a {
+ text-decoration: none;
+ padding: var(--size--2) 0;
+ font-weight: var(--font-weight-medium);
+ border-bottom: var(--border-width-2) solid transparent;
+ margin-bottom: calc(-1 * var(--border-width-1));
+}
+.facets a.active {
+ border-bottom-color: currentColor;
+}
+
+/* the .mj playground: two source panes, preset chips, a live output */
+.playground {
+ margin: var(--size-1) 0 var(--size-3);
+}
+.pg-inputs {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--size-0);
+}
+.pg-field,
+.pg-output {
+ display: flex;
+ flex-direction: column;
+ gap: var(--size--2);
+}
+.pg-field label,
+.pg-output label,
+.pg-presets {
+ font-size: var(--font-size--1);
+ text-transform: uppercase;
+ letter-spacing: var(--font-letter-spacing-1);
+}
+.playground textarea {
+ font-family: var(--font-mono);
+ font-size: var(--font-size--1);
+ line-height: var(--code-line-height);
+ padding: var(--size--1);
+ border: var(--border-width-1) solid var(--neutral-4);
+ border-radius: var(--border-radius-1);
+ background: var(--code-bg);
+ color: var(--code-fg);
+ resize: vertical;
+ outline: none;
+}
+.pg-presets {
+ display: flex;
+ align-items: center;
+ gap: var(--size--2);
+ margin: var(--size-0) 0;
+}
+.pg-output {
+ margin-top: var(--size-1);
+}
+.tpl-out {
+ padding: var(--size--1) var(--size-0);
+ border: var(--border-width-1) dashed var(--neutral-4);
+ border-radius: var(--border-radius-1);
+ min-height: 2.5em;
+}
+.tpl-out.is-err {
+ font-family: var(--font-mono);
+ font-size: var(--font-size--1);
+ color: var(--code-error);
+}
+
+/* streaming theme: a live SSE row (buttons that patch in place) */
+.pg-live {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--size-0);
+ font-family: var(--font-mono);
+ font-size: var(--font-size--1);
+}
+.pg-val {
+ font-weight: var(--font-weight-bold);
+}
+
+/* storage theme: the guestbook input + list */
+.pg-input {
+ font-family: var(--font-mono);
+ font-size: var(--font-size--1);
+ padding: 0.2em 0.6em;
+ border: var(--border-width-1) solid var(--neutral-4);
+ border-radius: var(--border-radius-1);
+ background: var(--code-bg);
+ color: var(--code-fg);
+ outline: none;
+}
+.guestbook ul {
+ margin: var(--size--1) 0 0;
+}
+
+/* playground stays within narrow viewports: let grid cells shrink, stack inputs */
+.pg-field,
+.pg-output {
+ min-width: 0;
+}
+.playground textarea {
+ width: 100%;
+}
+@media (max-width: 540px) {
+ .pg-inputs {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* narrow screens: let the top nav wrap instead of overflowing */
+@media (max-width: 540px) {
+ .nav {
+ flex-wrap: wrap;
+ }
+ .nav .links {
+ gap: var(--size-0);
+ }
+}
+
+/* let the preset chips wrap rather than overflow on narrow screens */
+.pg-presets {
+ flex-wrap: wrap;
+}
+
+/* shared site footer */
+.site-footer {
+ margin-top: var(--size-5);
+ padding: var(--size-2) 0;
+ border-top: var(--border-width-1) solid var(--neutral-4);
+ font-size: var(--font-size--1);
+}
+
+/* hello-world tutorial: reuse the splash .terminal, two windows broken out to
+ full viewport width with economical chrome, so the log lines fit with little
+ scroll */
+.terminal-row {
+ display: flex;
+ flex-direction: column;
+ gap: var(--size-1);
+ margin-block: var(--size-2);
+}
+.terminal-row .terminal {
+ margin-block: 0;
+}
+.term-cmd {
+ display: flex;
+ align-items: center;
+ gap: var(--size--2);
+ flex-wrap: wrap;
+}
+
+/* design section: brand palette table */
+.pal-table { border-collapse: collapse; width: 100%; max-width: 46rem; margin-block: var(--size-2); }
+.pal-table th {
+ text-align: left;
+ font-size: var(--font-size--1);
+ font-weight: var(--font-weight-semi-bold);
+ color: var(--neutral-1-dim);
+ padding: var(--size--2) var(--size-0);
+ border-bottom: var(--border-width-2) solid var(--neutral-4);
+}
+.pal-table td {
+ padding: var(--size--2) var(--size-0);
+ border-bottom: var(--border-width-1) solid var(--neutral-3);
+ vertical-align: middle;
+}
+.pal-cell {
+ width: 2.6rem;
+ height: 1.4rem;
+ border-radius: var(--border-radius-1);
+ border: var(--border-width-1) solid var(--neutral-4);
+}
+.pal-name { font-weight: var(--font-weight-bold); }
+.pal-ramp { display: flex; gap: var(--size--2); }
+.pal-ramp .pal-cell { width: 3.2rem; }
+
+/* design section: component breakdown with per-part tokens */
+.dz-example { margin: var(--size-1) 0 var(--size-3); }
+.dz-btns { display: flex; gap: var(--size-0); align-items: center; flex-wrap: wrap; }
+.dz-part { margin-block: var(--size-2); }
+.dz-part h3 { margin-bottom: var(--size--2); }
+.dz-demo { margin: var(--size-0) 0; }
+.dz-tokens { display: flex; flex-direction: column; gap: var(--size--2); font-size: var(--font-size--1); }
+.tok { display: flex; align-items: center; gap: var(--size-0); }
+.tok-label { min-width: 7em; color: var(--neutral-1-dim); }
+.tok-sw {
+ flex: none;
+ width: 1.1em;
+ height: 1.1em;
+ border-radius: var(--border-radius-1);
+ border: var(--border-width-1) solid var(--neutral-4);
+}
+.tok-sw-none { border: 0; visibility: hidden; }
+.terminal-title {
+ display: flex;
+ align-items: center;
+ gap: var(--size--1);
+}
+.terminal-action {
+ margin-left: auto;
+}
+.terminal-body code {
+ background: transparent;
+ padding: 0;
+ color: inherit;
+ white-space: pre;
+}
+.terminal-body pre {
+ background: transparent;
+ border: 0;
+ margin: 0;
+ padding: 0;
+ font: inherit;
+ color: inherit;
+ white-space: pre;
+}
+.term-out {
+ white-space: pre;
+ overflow-x: auto;
+}
+/* on wide viewports the splash demo runs server + client side by side; the
+ tutorial page keeps them stacked in its narrower reading column. */
+@media (min-width: 900px) {
+ .demo-wide .terminal-row { display: grid; grid-template-columns: 1fr 1fr; align-items: start; }
+ .demo-wide .terminal-row > * { min-width: 0; }
+}
diff --git a/www-next-gen/assets/brand.css b/www-next-gen/assets/brand.css
new file mode 100644
index 0000000..fc8b9a7
--- /dev/null
+++ b/www-next-gen/assets/brand.css
@@ -0,0 +1,407 @@
+/*
+ * brand.css - the http-nu brand layer, loaded site-wide on top of base.css.
+ *
+ * Two brand colors, one per mode: ocean for light, a deeper navy for dark -
+ * hand-picked stellar named seeds (stellar.config.json, with the sand / orange
+ * grape / red / green / stream accents). Whichever mode is active, that one
+ * color IS the page surface; `--surface` / `--surface-on` / `--on-dim` /
+ * `--line` name its parts, and the `.dark body` block swaps the whole set
+ * ocean->navy. We choose the two colors by hand because they're exact brand
+ * blues stellar's tonal math wouldn't land on, and because the ocean->navy pair
+ * is a deliberate design choice, not two ends of one ramp.
+ *
+ * Within a mode, everything is built from the active color's ramp the way the
+ * reference builds from neutral: the page is step 0, raised panels and borders
+ * are higher steps (the `.panel` utility, `--line`), `-on` is the text, `-dim`
+ * is secondary text. No hand-mixed tints.
+ *
+ * A few hero geometry values (sticker shadow, tilt angles, tight badge padding)
+ * are literal on purpose - no stellar token matches them - and keep the landing
+ * pixel-faithful to the original ./www splash. Fonts also match the original.
+ */
+
+/* --- the brand surface: ocean (light mode) ------------------------------ */
+/* the active brand color and its parts: the step is the page, -on is text,
+ -dim is secondary text, and a higher step is the border (raised panels use
+ the .panel utility). `.dark body` below swaps all of this to the navy color. */
+body {
+ --surface: var(--named-ocean-0);
+ --surface-on: var(--named-ocean-0-on);
+ --on-dim: var(--named-ocean-0-dim);
+ --line: var(--named-ocean-2);
+ /* hard offset "sticker" shadow shared by badges and cards; stellar only
+ models soft elevation, so this playful brand atom is a literal */
+ --sticker-shadow: 2px 2px 0 rgba(0, 0, 0, 0.2);
+ background: var(--surface);
+ color: var(--surface-on);
+}
+
+/* --- the brand surface: navy (dark mode) -------------------------------- */
+/* the second brand color; swapping these four tokens re-points the whole
+ surface system (panels, borders, dim text) at navy in one place. */
+.dark body {
+ --surface: var(--named-navy-0);
+ --surface-on: var(--named-navy-0-on);
+ --on-dim: var(--named-navy-0-dim);
+ --line: var(--named-navy-2);
+}
+
+/* in the docs article, headings take the cream brand accent; their self-anchor
+ links inherit it (core `a` is `inherit`), so one rule covers both. Elsewhere
+ - card titles, nav - headings ride the local surface color from base's
+ `h1..h6: inherit`. */
+article :is(h1, h2, h3, h4, h5, h6) {
+ color: var(--named-sand-0);
+}
+body small,
+body .muted,
+body figcaption,
+body blockquote {
+ color: var(--on-dim);
+}
+body hr,
+body blockquote {
+ border-color: var(--line);
+}
+body :is(td, th) {
+ border-color: var(--line);
+}
+
+/* raised surface utility: the +1 ramp step of the surface (a lighter ocean,
+ navy in dark) with its own -on for text - the reference's "surface step for
+ the background, its -on for text". Cards compose `card panel`; table headers
+ ride it too (they can't take a class). */
+.panel,
+body th {
+ background: var(--named-ocean-1);
+ color: var(--named-ocean-1-on);
+}
+.dark .panel,
+.dark body th {
+ background: var(--named-navy-1);
+ color: var(--named-navy-1-on);
+}
+
+/* code blocks use base's --code-bg: stellar's primary code surface, the one its
+ syntax palette is tuned for; the hue is set by colors.code.index (a warm cream
+ index that echoes the sand brand accent - it reads as a light cream panel on the
+ blue and drops to a near-black on navy in dark). The brand only adds the line
+ border and gentle corners; inline keeps the secondary --code-inner-bg. */
+body pre {
+ border-color: var(--line);
+}
+
+/* cards are blocky badge-style panels: the `.panel` surface step, gentle corners,
+ and the same hard sticker shadow as the splash badges. Each card sits at a
+ slight fixed tilt that varies card to card (a 5-step cycle, so it scatters
+ across the 3-column grid rather than lining up); hover does not change the
+ tilt - it lifts the card straight up and grows the shadow. The tilt is held
+ in --card-tilt so hover can keep the angle and add the lift. (The tilt
+ degrees and offset shadow are brand literals, like the badges.) */
+body .card {
+ border-color: var(--line);
+ box-shadow: var(--sticker-shadow);
+}
+.grid .card {
+ transform: rotate(var(--card-tilt, 0deg));
+}
+.grid .card:nth-child(5n + 1) { --card-tilt: -1deg; }
+.grid .card:nth-child(5n + 2) { --card-tilt: 0.7deg; }
+.grid .card:nth-child(5n + 3) { --card-tilt: -0.5deg; }
+.grid .card:nth-child(5n + 4) { --card-tilt: 1deg; }
+.grid .card:nth-child(5n + 5) { --card-tilt: -0.8deg; }
+body a.card:hover {
+ background: var(--named-ocean-2);
+ box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.2);
+ transform: rotate(var(--card-tilt, 0deg)) translateY(-4px);
+}
+.dark body a.card:hover {
+ background: var(--named-navy-2);
+}
+
+/* pager: prev/next ride the .panel surface; cream page title, dim direction
+ label, brightening one ramp step on hover */
+.pager-link {
+ border-color: var(--line);
+}
+.pager-link:hover {
+ background: var(--named-ocean-2);
+}
+.dark .pager-link:hover {
+ background: var(--named-navy-2);
+}
+.pager-dir {
+ color: var(--on-dim);
+}
+.pager-page {
+ color: var(--named-sand-0);
+}
+
+/* sidebar */
+.toc a {
+ color: var(--on-dim);
+}
+.toc a:hover {
+ color: var(--surface-on);
+}
+.toc a.active {
+ color: var(--named-sand-0);
+}
+.toc-group {
+ color: var(--on-dim);
+}
+
+/* --- nav: cream mono brand, ghost toggle (text inherits the body font) -- */
+.nav .brand a {
+ color: var(--named-sand-0);
+ font-family: var(--font-mono);
+ font-size: var(--font-size-3);
+ text-decoration: underline;
+}
+.nav .brand a:hover,
+.nav .links a:hover {
+ color: var(--named-sand-0);
+}
+.nav .vt-toggle[aria-pressed="true"] {
+ color: var(--named-sand-0);
+ border-color: var(--named-sand-0);
+}
+
+/* --- tone utilities: a surface and its readable foreground (badges) ----- */
+.tone-orange {
+ background: var(--named-orange-0);
+ color: var(--named-orange-0-on);
+}
+.tone-grape {
+ background: var(--named-grape-0);
+ color: var(--named-grape-0-on);
+}
+.tone-red {
+ background: var(--named-red-0);
+ color: var(--named-red-0-on);
+}
+.tone-green {
+ background: var(--named-green-0);
+ color: var(--named-green-0-on);
+}
+
+/* --- landing hero (these selectors only match on the landing markup) ---- */
+
+/* tagline card: just tilted, on the surface, like the original */
+.taglines {
+ max-width: 48rem;
+ margin: 0 auto;
+ text-align: center;
+ transform: rotate(-1deg);
+ padding: 3rem 1.5rem 0.5rem;
+}
+
+.taglines img {
+ display: block;
+ margin: 0 auto;
+ max-width: 90%;
+}
+
+.curve {
+ display: block;
+ width: 100%;
+}
+
+.taglines .curve:last-child {
+ margin-top: var(--size-2);
+}
+
+.stream {
+ color: var(--named-stream-0);
+}
+
+/* badges */
+.badges {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+}
+
+.badge {
+ display: block;
+ width: fit-content;
+ margin-block: 0.5rem;
+ padding: 0.4rem 1.1rem;
+ font-weight: var(--font-weight-bold);
+ font-size: var(--font-size-2);
+ line-height: 1.5;
+ box-shadow: var(--sticker-shadow);
+}
+
+.badge a {
+ text-decoration: none;
+}
+
+/* the playful tilt, by position rather than a class per badge */
+.badges .badge:nth-child(1) {
+ transform: rotate(-5deg);
+}
+.badges .badge:nth-child(2) {
+ transform: rotate(2deg);
+}
+.badges .badge:nth-child(3) {
+ transform: rotate(-3deg);
+}
+
+.wave {
+ display: block;
+ width: 100%;
+ height: 100px;
+}
+
+/* --- terminal: a window-chrome code panel (install tabs + hello world) -- *
+ * Playful, like the original: a purple titlebar and a body that's just the
+ * page surface darkened with a black overlay (so it stays a dark terminal on
+ * either blue, no fixed color). On narrow screens it bleeds to both edges. */
+.terminal {
+ margin-block: var(--size-2);
+ border-radius: var(--border-radius-2);
+ overflow: hidden;
+ box-shadow: var(--shadow-2);
+ font-family: var(--font-mono);
+ font-size: var(--font-size--1);
+}
+.terminal-bar {
+ display: flex;
+ align-items: center;
+ gap: var(--size--1);
+ padding: 0.2rem var(--size--1);
+ background: var(--named-grape-0);
+ color: var(--named-grape-0-on);
+}
+.terminal-dots {
+ display: flex;
+ gap: 0.35rem;
+ margin-right: var(--size--1);
+}
+.terminal-dots span {
+ width: 0.7rem;
+ height: 0.7rem;
+ border-radius: 50%;
+}
+.terminal-dots span:nth-child(1) {
+ background: var(--named-red-0);
+}
+.terminal-dots span:nth-child(2) {
+ background: #ffbd2e;
+}
+.terminal-dots span:nth-child(3) {
+ background: var(--named-green-0);
+}
+.btn-tab {
+ font: inherit;
+ background: none;
+ border: none;
+ color: inherit;
+ opacity: 0.5;
+ cursor: pointer;
+ padding: 0.15rem 0.5rem;
+}
+.btn-tab:hover {
+ background: none;
+ opacity: 0.8;
+}
+.btn-tab.is-active {
+ opacity: 1;
+ font-weight: var(--font-weight-bold);
+}
+.terminal-body {
+ background: rgba(0, 0, 0, 0.25);
+ color: var(--surface-on);
+ padding: var(--size--2);
+ overflow-x: auto;
+ line-height: var(--code-line-height);
+}
+.terminal-body .prompt,
+.terminal-body .out {
+ opacity: 0.6;
+}
+
+/* "Start Server": a key in the title bar that lights up green once the server
+ is running (same lit-while-active idea as the splash badges and the 2048
+ audio button). It stays put -- the bar's look is permanent, the key just
+ lights up. */
+.btn-go {
+ font-family: var(--font-mono);
+ font-size: var(--font-size--1);
+ color: var(--named-grape-0-on);
+ background: rgba(0, 0, 0, 0.22);
+ border: 0;
+ border-radius: var(--border-radius-1);
+ padding: 0.15em 0.7em;
+ cursor: pointer;
+ box-shadow: 0 2px 0 rgba(0, 0, 0, 0.3);
+ transition: transform var(--anim-duration-fast) var(--anim-ease-standard),
+ box-shadow var(--anim-duration-base) var(--anim-ease-standard),
+ background var(--anim-duration-base) var(--anim-ease-standard),
+ color var(--anim-duration-base) var(--anim-ease-standard);
+}
+.btn-go:hover {
+ background: rgba(0, 0, 0, 0.32);
+}
+.btn-go:active {
+ transform: translateY(2px);
+ box-shadow: none;
+}
+.btn-go.is-lit {
+ background: var(--named-green--1);
+ color: var(--named-green--1-on);
+ box-shadow: 0 2px 0 rgba(0, 0, 0, 0.2), 0 0 0.7rem -0.1rem var(--named-green--1);
+}
+
+/* the client terminal fades in once its server is up. */
+.client-pane {
+ animation: term-fade-in var(--anim-duration-base) var(--anim-ease-standard);
+}
+@keyframes term-fade-in {
+ from {
+ opacity: 0;
+ transform: translateY(0.3rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* animated rocket gif by the section heading */
+.rocket {
+ height: 1em;
+ margin-left: 0.25em;
+ vertical-align: -0.15em;
+ border-radius: 0;
+}
+
+@media (max-width: 640px) {
+ .terminal {
+ margin-inline: calc(-1 * var(--size-2));
+ border-radius: 0;
+ }
+}
+
+/* theme facet nav: dim inactive, cream active (matches .toc a.active) */
+.facets a {
+ color: var(--on-dim);
+}
+.facets a:hover {
+ color: var(--surface-on);
+}
+.facets a.active {
+ color: var(--named-sand-0);
+}
+/* the tagline card is tilted for effect; clip the tilt so it never causes
+ horizontal overflow on narrow screens */
+.splash-hero {
+ overflow-x: clip;
+}
+
+/* footer divider uses the brand line color */
+.site-footer {
+ border-color: var(--line);
+}
diff --git a/www-next-gen/assets/ellie.png b/www-next-gen/assets/ellie.png
new file mode 100644
index 0000000..1ddefa7
Binary files /dev/null and b/www-next-gen/assets/ellie.png differ
diff --git a/www-next-gen/assets/iosevka-x-400.woff2 b/www-next-gen/assets/iosevka-x-400.woff2
new file mode 100644
index 0000000..5e98f56
Binary files /dev/null and b/www-next-gen/assets/iosevka-x-400.woff2 differ
diff --git a/www-next-gen/assets/iosevka-x-700.woff2 b/www-next-gen/assets/iosevka-x-700.woff2
new file mode 100644
index 0000000..3e28c32
Binary files /dev/null and b/www-next-gen/assets/iosevka-x-700.woff2 differ
diff --git a/www-next-gen/assets/palette-test.html b/www-next-gen/assets/palette-test.html
new file mode 100644
index 0000000..819ba6f
--- /dev/null
+++ b/www-next-gen/assets/palette-test.html
@@ -0,0 +1,84 @@
+code palette index test
+
+
code palette — all index variations
+
The routing example on /docs/embedded-modules, each index left=light (on the ocean blue) / right=dark (on the navy). colors.code.index sets the code surface and its whole syntax palette.
',
+ '',
+ ].join("");
+
+ document.body.appendChild(dlg);
+
+ function sync() {
+ dlg.querySelectorAll("[data-k]").forEach(function (el) {
+ var k = el.dataset.k;
+ if (el.type === "checkbox") el.checked = !!state[k];
+ else el.value = state[k];
+ });
+ var out = dlg.querySelector('[data-out="duration"]');
+ if (out) out.textContent = state.duration + "ms";
+ }
+
+ dlg.addEventListener("input", function (e) {
+ var el = e.target;
+ if (!el.dataset || !el.dataset.k) return;
+ var k = el.dataset.k;
+ state[k] = el.type === "checkbox" ? el.checked : k === "duration" ? +el.value : el.value;
+ apply();
+ save();
+ sync();
+ });
+ dlg.addEventListener("click", function (e) {
+ var actEl = e.target.closest ? e.target.closest("[data-act]") : null;
+ var act = actEl ? actEl.dataset.act : null;
+ if (act === "close") dlg.close();
+ if (act === "reset") {
+ state = Object.assign({}, DEFAULTS);
+ apply();
+ save();
+ sync();
+ }
+ });
+ function openPanel() {
+ sync();
+ setOpen(true);
+ btn.setAttribute("aria-pressed", "true");
+ if (!dlg.open) dlg.show(); // non-modal: no backdrop, so the page transition behind stays visible
+ }
+ dlg.addEventListener("close", function () {
+ setOpen(false);
+ btn.setAttribute("aria-pressed", "false");
+ });
+ document.addEventListener("keydown", function (e) {
+ if (e.key === "Escape" && dlg.open) dlg.close();
+ });
+ // light dismiss: click anywhere outside the panel (but not the launcher) closes it
+ document.addEventListener("click", function (e) {
+ if (dlg.open && !dlg.contains(e.target) && !btn.contains(e.target)) dlg.close();
+ });
+ btn.addEventListener("click", function () {
+ if (dlg.open) dlg.close();
+ else openPanel();
+ });
+ sync();
+ if (isOpen()) openPanel();
+ }
+
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", build);
+ else build();
+})();
diff --git a/www-next-gen/demo-banner.txt b/www-next-gen/demo-banner.txt
new file mode 100644
index 0000000..573bc7d
--- /dev/null
+++ b/www-next-gen/demo-banner.txt
@@ -0,0 +1,5 @@
+
+ __ ,
+ .--()°'.' pid 1516765 · http://127.0.0.1:3001 · 23ms 💜
+'|, . ,' Wed, 17 Jun 2026 23:37:38 +0000
+ !_-(_\ nu 0.113.1
\ No newline at end of file
diff --git a/www-next-gen/how-tos/render-readme-as-doc-site.md b/www-next-gen/how-tos/render-readme-as-doc-site.md
new file mode 100644
index 0000000..fd7be2d
--- /dev/null
+++ b/www-next-gen/how-tos/render-readme-as-doc-site.md
@@ -0,0 +1,76 @@
+# Render a README as a doc site
+
+Your `README.md` is already the story of your project. http-nu turns it into a
+documentation site as rich as [Astro Starlight](https://starlight.astro.build),
+with much less fuss. The whole job is two pieces: `.md`, which renders Markdown to
+HTML with syntax-highlighted code blocks, and a handful of lines of Nushell to
+route it.
+
+This page is one of those Markdown files, rendered exactly the way it describes.
+
+## The smallest doc site
+
+One route, the README rendered to HTML:
+
+```nushell
+const readme = (open --raw README.md | decode utf-8)
+
+{|req| $readme | .md }
+```
+
+```bash
+http-nu :3001 serve.nu
+```
+
+Open it and you have a readable page: headings, lists, links, and code blocks
+colored by `.md`, which highlights `bash`, `nushell`, `rust`, and more via
+TextMate grammars. No build step, no `node_modules`.
+
+## Split it into pages
+
+A long README scrolls forever. Break it into navigable pages by heading, and
+route each one by its slug:
+
+```nushell
+const readme = (open --raw README.md | decode utf-8)
+
+# one page per "## " section
+let pages = ($readme | split row "\n## " | skip 1 | each {|s|
+ let title = ($s | lines | first)
+ { slug: ($title | str downcase | str replace --all " " "-")
+ body: $"## ($s)" }
+})
+
+{|req|
+ let slug = ($req.path | str trim --left --char "/")
+ let page = ($pages | where slug == $slug | get 0?)
+ if $page == null { "not found" } else { $page.body | .md }
+}
+```
+
+Add a sidebar that lists the sections and you have navigation. The README stays
+the single source of truth; you just slice it.
+
+> The [Reference](/reference) section of this site is the code above, running:
+> the [http-nu README](https://github.com/cablehead/http-nu/blob/main/README.md),
+> sliced one page per heading, with a sidebar of slugs and a prev/next pager.
+> Click through it, then come back here, the code reads like a description of
+> what you just used.
+
+## Typography
+
+`.md` gives you semantic HTML, so style raw tags once and every page looks good
+with no per-page classes. This site uses stellar tokens plus a small base layer,
+but bring whatever CSS you like.
+
+## Beyond Markdown
+
+When a page is rendered from data rather than static prose, reach for `.mj`
+(minijinja templates). You can poke at both `.md` and `.mj` in the
+[Templates theme](/themes/templates).
+
+## The result
+
+This site. The [Reference](/reference) is this project's own README, rendered;
+the themes wrap interactive toys around it. A README, a few lines of Nushell, and
+you are done.
diff --git a/www-next-gen/how-tos/reverse-proxy-a-backend.md b/www-next-gen/how-tos/reverse-proxy-a-backend.md
new file mode 100644
index 0000000..0dcf101
--- /dev/null
+++ b/www-next-gen/how-tos/reverse-proxy-a-backend.md
@@ -0,0 +1,57 @@
+# Reverse-proxy a backend
+
+Put http-nu in front of an existing service to add routing, headers, or path
+rewriting without touching it. `.reverse-proxy` forwards the request and returns
+the backend's response.
+
+## The whole thing
+
+```bash
+http-nu :3001 -c '{|req| .reverse-proxy "http://localhost:8080"}'
+```
+
+Every request is forwarded to the backend and its response comes straight back.
+When `.reverse-proxy` is first in the closure, the original request body is
+forwarded too.
+
+## As an API gateway
+
+Strip a public prefix before forwarding, so `/api/v1/users` reaches the backend
+as `/users`:
+
+```bash
+http-nu :3001 -c '{|req|
+ .reverse-proxy "http://localhost:8080" {
+ strip_prefix: "/api/v1"
+ }
+}'
+```
+
+## Add headers, rewrite the query
+
+The optional config record carries headers, host handling, and query rewrites:
+
+```nushell
+{|req|
+ .reverse-proxy "http://backend:8080" {
+ headers: { "X-API-Key": "secret123" }
+ query: ($req.query | upsert "context-id" "smidgeons" | reject "debug")
+ }
+}
+```
+
+## Proxy some paths, serve others
+
+http-nu is a full server, so proxy the API and answer everything else yourself:
+
+```nushell
+{|req|
+ if ($req.path | str starts-with "/api/") {
+ .reverse-proxy "http://localhost:8080" { strip_prefix: "/api" }
+ } else {
+ "hello from http-nu"
+ }
+}
+```
+
+See the [Reference](/reference/reverse-proxy) for every option.
diff --git a/www-next-gen/how-tos/serve-a-single-page-app.md b/www-next-gen/how-tos/serve-a-single-page-app.md
new file mode 100644
index 0000000..e9944e0
--- /dev/null
+++ b/www-next-gen/how-tos/serve-a-single-page-app.md
@@ -0,0 +1,48 @@
+# Serve a single-page app
+
+A single-page app ships one `index.html` and lets the client router handle the
+rest. The catch with static serving is deep links: a request for `/dashboard`
+has no file on disk, so it 404s on refresh. http-nu's `.static` has a
+`--fallback` for exactly this.
+
+## Static files
+
+Serve a directory, mapping the request path to a file:
+
+```bash
+http-nu :3001 -c '{|req| .static "./dist" $req.path}'
+```
+
+`.static` infers the content type from the file extension and ignores anything
+else the closure returns.
+
+## The SPA fallback
+
+Add `--fallback` so any path that does not match a file serves your app shell
+instead of returning 404:
+
+```bash
+http-nu :3001 -c '{|req| .static "./dist" $req.path --fallback "index.html"}'
+```
+
+Now `/`, `/dashboard`, and `/users/42` all return `index.html`, the client
+router takes it from there, and real assets like `/app.js` and `/style.css`
+still serve directly.
+
+## Mixing with an API
+
+Routes compose, so serve the app and an API from one closure:
+
+```nushell
+use http-nu/router *
+
+{|req|
+ dispatch $req [
+ (route {path-matches: "/api/health"} {|req ctx| {ok: true} })
+ (route true {|req ctx| .static "./dist" $req.path --fallback "index.html" })
+ ]
+}
+```
+
+API routes win; everything else falls through to the app shell. See the
+[Reference](/reference/serving--operations) for the rest of `.static`.
diff --git a/www-next-gen/iosevka-x.build-plan.toml b/www-next-gen/iosevka-x.build-plan.toml
new file mode 100644
index 0000000..5b40746
--- /dev/null
+++ b/www-next-gen/iosevka-x.build-plan.toml
@@ -0,0 +1,38 @@
+# Iosevka X - docs/web build: monospace, no ligatures, regular + bold only,
+# dotted zero for 0/O clarity, plus a few clean variant touches.
+[buildPlans.iosevka-X]
+family = "Iosevka X"
+spacing = "normal"
+serifs = "sans"
+noLigation = true
+
+[buildPlans.iosevka-X.variants.design]
+zero = 'dotted'
+asterisk = 'penta-low'
+tilde = 'low'
+underscore = 'low'
+brace = 'straight'
+dollar = 'open'
+four = 'closed-serifless'
+eight = 'crossing'
+
+[buildPlans.iosevka-X.weights.Regular]
+shape = 400
+menu = 400
+css = 400
+
+[buildPlans.iosevka-X.weights.Bold]
+shape = 700
+menu = 700
+css = 700
+
+[buildPlans.iosevka-X.slopes.Upright]
+angle = 0
+shape = "upright"
+menu = "upright"
+css = "normal"
+
+[buildPlans.iosevka-X.widths.Normal]
+shape = 500
+menu = 5
+css = "normal"
diff --git a/www-next-gen/readme.nu b/www-next-gen/readme.nu
new file mode 100644
index 0000000..fc684a7
--- /dev/null
+++ b/www-next-gen/readme.nu
@@ -0,0 +1,178 @@
+# readme.nu - split one large markdown document (a README) into navigable
+# doc pages, driven by nushell's `from md --verbose` AST instead of
+# hand-maintained section files.
+#
+# The AST gives every heading's level and exact source line, so pages are
+# sliced out of the raw text by line range: code fences can never be
+# mistaken for headings, and the README stays the single source of truth.
+
+# GitHub-style anchor slug for a heading title.
+export def slugify []: string -> string {
+ $in
+ | str downcase
+ | str replace -ra '[^a-z0-9 -]' ''
+ | str replace --all ' ' '-'
+}
+
+# Collect the plain text of an AST node (headings contain nested link /
+# code_inline / text children).
+def node-text []: record -> string {
+ let node = $in
+ let own = ($node.attrs?.value? | default "")
+ let kids = ($node.children? | default [] | each { $in | node-text } | str join "")
+ $own + $kids
+}
+
+# All headings in the document: {level, line, title, slug}.
+export def headings []: string -> table {
+ $in
+ | from md --verbose
+ | where type =~ '^h[1-6]$'
+ | each {|h|
+ let title = ($h | node-text | str trim)
+ {
+ level: $h.attrs.level
+ line: $h.position.start.line
+ title: $title
+ slug: ($title | slugify)
+ }
+ }
+}
+
+# Split the document into pages.
+#
+# $split lists heading slugs that act as groups: their child headings
+# become pages of their own (recursively), instead of being folded into
+# one page. Everything before the first heading-page (logo, badges, toc)
+# is skipped.
+#
+# Returns: {slug, title, level, start, end, group}
+# start/end are 1-based source line ranges, group is the slug of the
+# enclosing group heading or null.
+export def pages [split: list]: string -> table {
+ let text = $in
+ let total = ($text | lines | length)
+ let hs = ($text | headings)
+
+ let result = ($hs | enumerate | reduce --fold {stack: [], pages: []} {|it, acc|
+ let h = $it.item
+ let idx = $it.index
+
+ # pop ancestors at the same or deeper level
+ let stack = ($acc.stack | where level < $h.level)
+ let ancestors_are_groups = ($stack | all {|a| $a.is_group })
+ let is_group = ($h.slug in $split)
+
+ # section end: line before the next heading at the same or higher level
+ let next = ($hs | skip ($idx + 1) | where level <= $h.level | first | default null)
+ let section_end = (if $next == null { $total } else { $next.line - 1 })
+
+ let pages = (if (not $ancestors_are_groups) {
+ $acc.pages
+ } else if $is_group {
+ # group intro: lines between the group heading and its first child
+ let child = ($hs | skip ($idx + 1) | where line <= $section_end | first | default null)
+ let intro_end = (if $child == null { $section_end } else { $child.line - 1 })
+ let has_body = (
+ $text | lines | slice $h.line..($intro_end - 1) | any {|l| ($l | str trim) != "" }
+ )
+ if $has_body {
+ $acc.pages | append {
+ slug: $h.slug, title: $h.title, level: $h.level
+ start: $h.line, end: $intro_end
+ group: ($stack | last | get slug? | default null)
+ }
+ } else {
+ $acc.pages
+ }
+ } else {
+ $acc.pages | append {
+ slug: $h.slug, title: $h.title, level: $h.level
+ start: $h.line, end: $section_end
+ group: ($stack | last | get slug? | default null)
+ }
+ })
+
+ {
+ stack: ($stack | append {level: $h.level, is_group: $is_group, slug: $h.slug})
+ pages: $pages
+ }
+ })
+
+ $result.pages
+}
+
+# Map every heading anchor to the page that renders it.
+export def anchor-map [split: list]: string -> record {
+ let text = $in
+ let pgs = ($text | pages $split)
+ $text
+ | headings
+ | reduce --fold {} {|h, acc|
+ let owner = ($pgs | where {|p| $h.line >= $p.start and $h.line <= $p.end } | first | default null)
+ # duplicate heading titles: first occurrence keeps the plain slug,
+ # matching GitHub's anchor scheme
+ if $owner == null or ($h.slug in $acc) { $acc } else { $acc | insert $h.slug $owner.slug }
+ }
+}
+
+# Rewrite the href attributes of already-rendered HTML:
+# #anchor -> /base/#anchor (or left alone if the
+# anchor is on this same page)
+# relative/path -> $repo/relative/path (links into the source tree)
+# Absolute http(s):// links and mailto: are left untouched.
+def rewrite-links [anchors: record base: string repo: string]: string -> string {
+ $in | str replace -ra 'href="([^"]*)"' {|href|
+ let new = if ($href | str starts-with "#") {
+ let slug = ($href | str substring 1..)
+ let pg = ($anchors | get -o $slug)
+ if $pg == null { $href } else { $"($base)/($pg)#($slug)" }
+ } else if ($href =~ '^[a-z][a-z0-9+.-]*:') or ($href | str starts-with "/") or ($href | str starts-with "//") {
+ $href
+ } else {
+ $"($repo)/($href)"
+ }
+ $'href="($new)"'
+ }
+}
+
+# Render one page to {__html}. Headings are rebased so the page heading
+# becomes